Organized

This commit is contained in:
Jason Thistlethwaite
2026-10-17 20:50:35 -04:00
parent bd26c8894f
commit 9ee9eb3ec6
130 changed files with 24172 additions and 140 deletions
@@ -0,0 +1,218 @@
# Local `search` in `redmine_tickets.py` (pre-sanity snapshot)
**Date:** 2026-07-14
**Status:** documentation of current behavior *before* any search sanity/cleanup pass
**Script:** `/home/jason/projects/redmine/redmine_tickets.py`
**Typical cache:** `/home/jason/projects/redmine/cache/redmine_tickets`
This note captures what local `search` does and does **not** do as of 2026-07-14, after remote sync filters and run-log work landed. It is intentionally frank: docs had been thin; this is the baseline.
---
## Mental model
```text
sync = talk to Redmine → update local JSON cache
search = filter/list what is already cached
```
`search` is a **cache browser / structured filterer**, not:
- a live Redmine issue query tool
- full-text corpus search
- the semantic Redmine search path (that lives elsewhere)
If a ticket is not already under the cache directory for the selected project, **`search` cannot see it.**
---
## Where data comes from
Cached issues live as one JSON file per ticket:
```text
<cache-dir>/<project>/<issue-id>.json
```
Example for the durable customer-service cache:
```text
/home/jason/projects/redmine/cache/redmine_tickets/customer-service/*.json
/home/jason/projects/redmine/cache/redmine_tickets/manifest.json
```
`search` loads via `TicketCache.iter_cached_issues()` (reads every `*.json` in the project subdir). It does **not** call Redmine and does **not** require `REDMINE_API_KEY`.
---
## CLI surface (current)
Parent options still apply:
- `--base-url` (unused by search, but accepted on the parent parser)
- `--project`
- `--cache-dir`
Subcommand:
```bash
./redmine_tickets.py \
--project customer-service \
--cache-dir /home/jason/projects/redmine/cache/redmine_tickets \
search [filters...] [output...]
```
### Filters (all local, AND’d)
| Flag | Matches on | Notes |
|------|------------|--------|
| `--updated-since` | issue `updated_on` ≥ value | via `parse_dt` (absolute date/datetime) |
| `--updated-before` | issue `updated_on` ≤ value | same |
| `--status` | status **name** | case-insensitive **exact** match (`Closed`, `New`, …) — **not** `status_id` |
| `--category` | category **name** | case-insensitive **exact** match |
Implementation: `filter_issues()` in `redmine_tickets.py`.
### Order + limit
| Flag | Behavior |
|------|----------|
| `--order newest-updated-first` | **default** |
| `--order oldest-updated-first` | |
| `--order id-asc` / `id-desc` | |
| `--order as-is` | filesystem glob order from `iter_cached_issues` |
| `--max-tickets N` | after filter + sort, keep first N |
Implementation: `select_issues()`.
### Output
| Mode | What you get |
|------|----------------|
| default table | `#id`, `updated_on`, status name, category name, subject (via `print_issue_rows`) |
| `--json` | full cached issue payloads as one JSON array |
| `--jsonl` | one full issue JSON object per line (good for piping) |
Human table is summary-only. Full subject/body/journals/helpdesk are only in `--json` / `--jsonl`.
---
## What it allows (summary)
1. Scope one project + one cache root.
2. Filter cached issues by `updated_on` window, status **name**, category **name**.
3. Sort and cap results.
4. Emit table, JSON, or JSONL.
5. Run offline against cache (no Redmine, no API key).
---
## What it does **not** allow (current gaps)
This is the important half of the snapshot:
- **No free-text search** (subject / description / journals / from-address)
- **No sender / email / contact filters**
- **No tracker, assignee, priority, tags**
- **No `status_id` / `open` / `closed` shorthand** on search (only status **name**)
- **No remote/API filters** on `search`
(`--remote-updated-on`, `--remote-updated-since`, `--remote-status-id` are **sync-only**)
- **No relative time helpers** like `30d` / `2 days ago` on local search flags
(those helpers exist for **remote sync** convenience flags; local `--updated-since` wants a real parseable date/datetime)
- **No boolean logic** (AND/OR/NOT nesting)
- **No query language** comparable to Redmine UI filters or `rg`
- **No ranking / relevance**
- **No “search then sync missing ones”**
- **No pagination UI** beyond `--max-tickets`
- **No writes** (search never mutates cache or Redmine)
### Practical content search today
Pipe full payloads and use external tools:
```bash
./redmine_tickets.py \
--project customer-service \
--cache-dir /home/jason/projects/redmine/cache/redmine_tickets \
search --jsonl | rg -i 'gaylord|rate sheet'
```
Or inspect per-ticket files under the project cache dir directly.
---
## Examples that work
```bash
cd /home/jason/projects/redmine
# Everything currently cached for customer-service (table)
./redmine_tickets.py \
--project customer-service \
--cache-dir /home/jason/projects/redmine/cache/redmine_tickets \
search
# Cached tickets updated since a date, newest first, cap 50
./redmine_tickets.py \
--project customer-service \
--cache-dir /home/jason/projects/redmine/cache/redmine_tickets \
search \
--updated-since 2026-06-14 \
--max-tickets 50
# Only closed, as full JSONL for downstream tools
./redmine_tickets.py \
--project customer-service \
--cache-dir /home/jason/projects/redmine/cache/redmine_tickets \
search \
--status Closed \
--jsonl
```
---
## Related commands people confuse with “search”
| Command | Relation to local search |
|---------|--------------------------|
| `sync` | fills the cache that `search` reads; supports **server-side** `status_id` + `updated_on` |
| `spam-check` | walks **cache**, scores spam; not general search |
| `known-spam-scan` | profile match over **cache** |
| `decompose` | one-ticket analysis, cache-first |
| semantic index / external search | separate stack; not this subcommand |
---
## Remote vs local filter naming (context for sanity later)
As of the 2026-07-14 sync work:
| Side | Flags (examples) | Used by |
|------|------------------|---------|
| **Remote (API)** | `--status-id` / `--remote-status-id`, `--remote-updated-on`, `--remote-updated-since`, `--remote-updated-before` | `sync` list call only |
| **Local (post)** | `--updated-since`, `--updated-before`, `--status`, `--category` | `sync` post-filter **and** `search` |
Help text for the shared local flags still says things like “applied after API list,” which is accurate for `sync` but slightly misleading when those same flags appear under `search` (where there is no API list). A future sanity pass may want search-specific wording.
---
## Implementation pointers
| Piece | Location |
|-------|----------|
| Filter logic | `filter_issues()` |
| Sort / max | `select_issues()` |
| Cache read | `TicketCache.iter_cached_issues()` |
| Table output | `print_issue_rows()` |
| CLI wiring | `build_parser()` → `search` subparser; `main()` branch `args.command == "search"` |
| Shared filter args | `add_common_filters()` |
---
## Bottom line (baseline for sanity)
**Does:** local structured filter/list over already-synced ticket JSON — date window, status name, category name, sort, limit, table/json/jsonl.
**Doesn’t:** live Redmine query, free-text, contact/sender, remote filters, relative-date helpers on local flags, or advanced query syntax.
Any “make search sane” work after this file should treat the above as the pre-change contract and call out intentional expansions (e.g. `--from`, `--subject-contains`, `--text`, relative dates on local filters) separately.
+685
View File
@@ -0,0 +1,685 @@
# Power-user Redmine client — usage (`redmine_tickets.py`)
Scriptable local client for **humans and agents**: durable ticket cache, property search, Markdown view, and interactive walk.
| Env | Redmine | Cache |
|-----|---------|--------|
| **Prod** | `https://rm.ldrprep.com` | `/home/jason/projects/redmine/cache/redmine_tickets` |
| **Dev / mutation testing** | LAN (e.g. `192.168.50.170` / `retired.int.ldrprep.com`) | `/home/jason/projects/redmine/cache-dev/redmine_tickets` |
**Safety:** treat prod cache as read/sync. Test any **writes** on LAN first. Mutations impersonate `rebot` when implemented.
Related:
- Roadmap index: [raw-notes/features-for-redmine_tickets-sync.md](raw-notes/features-for-redmine_tickets-sync.md)
- Pre-MVP search snapshot: [2026-07-14_localsearch_before_sanity.md](2026-07-14_localsearch_before_sanity.md)
- Sync plan: [ticket_sync_tool_plan.md](ticket_sync_tool_plan.md)
- Cron scripts: `cron-scripts/sync-live-customer-service.sh`, `cron-scripts/sync-dev-customer-service.sh`
---
## Setup
```bash
cd /home/jason/projects/redmine
export REDMINE_API_KEY=… # required for sync / live API only
CACHE=/home/jason/projects/redmine/cache/redmine_tickets
PROJECT=customer-service
```
Shorthand used below:
```bash
RT="./redmine_tickets.py --project $PROJECT --cache-dir $CACHE"
```
---
## Sync (fill / refresh cache)
Remote filters hit Redmine; local cache is updated only for tickets whose `updated_on` changed.
```bash
# Initial / catch-up window
$RT sync --remote-status-id '*' --remote-updated-since 30d --run-log
# Frequent refresh (preferred after warm cache)
$RT sync --remote-status-id '*' --remote-updated-since 2h --run-log --quiet
```
| Flag | Meaning |
|------|---------|
| `--remote-status-id` / `--status-id` | Server: `*`, `open`, `closed`, or numeric |
| `--remote-updated-since` | Server: `30d`, `2h`, `YYYY-MM-DD`, … → `updated_on >= …` |
| `--remote-updated-on` | Server: raw Redmine operator string |
| `--run-log` | Ring-buffer stats → `$CACHE/run-stats.json` |
| `--run-log-keep N` | Keep last N runs (default 3; cron uses 20) |
| `--quiet` | Less progress noise |
**Do not** use local `--updated-since` as a substitute for remote filters on large projects (local only post-filters stubs already listed).
Cron note: live scripts currently still use `30d` on a short interval (~3–4s after warm-up). **Tighten later** to `2h`/`6h`.
---
## Search (local cache only)
No API key required. Only sees tickets already on disk.
## Search table columns
Default table (tab-separated):
```text
#id updated_on status assignee contact category control_tags subject
```
Contact comes from `helpdesk_ticket.contact.email` (or name/company/from_address).
### Classic filters
```bash
$RT search --status New --max-tickets 50
$RT search --updated-since 2026-07-01 --order newest-updated-first
```
### Property queries (`--where`)
Engine: `redmine_ticket_query.py`. Repeatable `--where` = **AND**.
```bash
$RT search --where 'subject.includes("account")' --max-tickets 20
$RT search --where 'assigned_to IS NULL' --max-tickets 10
$RT search \
--where 'helpdesk_ticket.contact.email == helpdesk_ticket.contact.company' \
--ids-only
$RT search \
--where '(subject.includes("verification code") OR subject.includes("magic link")) AND assigned_to IS NULL'
```
| Construct | Example |
|-----------|---------|
| Equality | `status.name == "New"` |
| Inequality | `assigned_to != "Jason T."` |
| Null | `assigned_to IS NULL` / `IS NOT NULL` |
| Substring | `subject.includes("rate sheet")` |
| Relative time | `updated_on.since("5 days")` |
| Path == path | `helpdesk_ticket.contact.email == helpdesk_ticket.contact.company` |
| Boolean | `AND` `OR` `&&` `||` + `()` |
Nested dicts compared to strings use `.name` (or email/id) when present.
### Output modes
| Flag | Output |
|------|--------|
| (default) | Table: id, updated_on, status, category, subject |
| `--json` | Full issue JSON array |
| `--jsonl` | One full issue JSON per line |
| `--ids-only` | Issue ids only (best for scripts) |
Match count goes to **stderr** on table mode so stdout stays pipe-clean for `--ids-only` / jsonl.
### Automation pattern
```bash
$RT search --where 'status.name == "New"' --where 'assigned_to IS NULL' --ids-only \
| while read -r id; do
$RT view "$id" --print > "/tmp/ticket-$id.md"
done
```
---
## Saved filters (`saved-filters/`)
Put one expression per file (comments with `#` allowed):
```text
saved-filters/unassigned.expr
saved-filters/helpdeskEmailMatchCompany.expr
saved-filters/triage-needed-5d.expr
```
Reference them from CLI:
```bash
$RT search --where '@unassigned' --max-tickets 20
$RT search --where 'alias.helpdeskEmailMatchCompany' --ids-only
$RT walk --where '@triage-needed-5d' --max-tickets 10 --pager cat
```
Bare names that match a file also work: `--where unassigned`.
### Short configs / wrappers
```bash
./bin/rtl search --where 'assigned_to IS NULL' --max-tickets 10 # prod CS
./bin/rtd note --issue-id 39305 --body 'LAN' --apply # dev CS
./redmine_tickets.py --config configs/live.json ...
```
`--allow-prod-writes` is a **parent** flag (before the subcommand):
```bash
./bin/rtl --allow-prod-writes draft apply <draft_id>
```
---
## Named queue folders (`materialize-queues`)
Config: [queues.example.json](../queues.example.json)
```bash
$RT materialize-queues --config queues.example.json
# writes: $CACHE/queues/<name>/*.json → symlinks into $CACHE/$PROJECT/<id>.json
```
Agents that only understand files can list `$CACHE/queues/unassigned/`.
Dry-run:
```bash
$RT materialize-queues --config queues.example.json --dry-run
```
Refresh after cron sync if you want queues kept current (optional second cron step).
---
## View (single ticket → Markdown)
Cache-first. Does not call Redmine unless you add that later.
```bash
$RT view 41934 --print
$RT view --issue-id 41934 --pager auto # glow → bat → batcat → cat
$RT view 41934 --write-md --pager cat
```
| Flag | Meaning |
|------|---------|
| `--print` | Markdown to stdout (no pager) |
| `--pager auto\|glow\|bat\|batcat\|cat` | How to display when not `--print` |
| `--write-md` | Force write under markdown cache |
| `--force-md` | Regen even if `updated_on` stamp matches |
Markdown cache path:
```text
$CACHE/$PROJECT/markdown/<id>.md
$CACHE/$PROJECT/markdown/<id>.md.updated_on
```
Regenerates when ticket `updated_on` changes (stamp file).
---
## Walk (interactive feed)
```bash
# From property search
$RT walk --where 'assigned_to IS NULL' --max-tickets 10 --pager cat
# Explicit ids
$RT walk --ids 41934,41899 --pager cat
```
Controls: `n` / Enter = next, `p` = previous, `q` = quit, `r` = redraw.
Same filter flags as search (`--where`, `--status`, `--max-tickets`, …). `--ids` overrides search selection.
---
## Internal note vs customer email (critical)
These are **different Redmine/Helpdesk operations**. The CLI uses **separate commands** so they are hard to confuse.
| | `note` | `customer-email` |
|--|--------|------------------|
| **What it is** | Internal journal note on the issue | Helpdesk “send note” / customer reply |
| **Emails customer?** | **No** | **Yes** (when applied) |
| **API** | `PUT /issues/:id.json` with `notes` (+ optional `private_notes`) | `POST /helpdesk/email_note.json` with `message.content` |
| **Private?** | Optional `--private` | **Never** (private cannot email) |
| **Confirm flag** | None needed | **Required:** `--i-understand-this-emails-the-customer` |
| **Default** | Queue local draft | Queue local draft |
| **Apply now** | `--apply` | `--apply` (+ confirm flag already required) |
| **Prod writes** | Need `--allow-prod-writes` | Need `--allow-prod-writes` |
| **Impersonation** | `--impersonate-user rebot` (default) on apply | same |
### Queue an internal note (safe default — no email)
```bash
$RT note --issue-id 41934 --body 'Staff-only follow-up' --private
# later:
$RT draft apply <draft-id> # still does NOT email
```
### Apply internal note immediately (LAN/test preferred)
```bash
$RT --base-url http://192.168.50.170 note --issue-id 123 \
--body 'LAN test note' --apply
```
### Queue a customer email (explicit, scary name + confirm flag)
```bash
$RT customer-email --issue-id 41934 \
--body 'Thanks for writing — we received your request.' \
--i-understand-this-emails-the-customer
# later:
$RT draft apply <draft-id> --i-understand-this-emails-the-customer
```
### Send customer email immediately
```bash
$RT --base-url http://192.168.50.170 customer-email --issue-id 123 \
--body 'LAN test customer reply' \
--i-understand-this-emails-the-customer \
--apply
```
### Production writes
Writes to hosts outside the LAN allow-list (e.g. `https://rm.ldrprep.com`) are **refused** unless you pass `--allow-prod-writes`. Prefer testing on LAN first.
### Draft list
```bash
$RT draft list
# columns include EMAILS_CUSTOMER vs no-email
```
**Do not** use `note` when you mean to email a customer. **Do not** use `customer-email` for staff chatter.
---
## set-fields (status / assignee / priority / category / due date)
Does **not** email the customer. Default queues a draft; `--apply` writes (LAN-safe by default).
```bash
# By numeric id
$RT set-fields --issue-id 41934 --status-id 5 --assigned-to-id 0
# By human name (uses meta cache; run meta-refresh once)
$RT meta-refresh --force
$RT set-fields --issue-id 41934 --status Closed --assigned-to unassigned \
--notes 'Unassign + close' --private
```
| Flag | Meaning |
|------|---------|
| `--status` / `--status-id` | name or numeric status |
| `--assigned-to` / `--assigned-to-id` | name/login or id; `unassigned` / `0` clears |
| `--priority` / `--priority-id` | name or id |
| `--category` / `--category-id` | name or id; `none` / `0` clears |
| `--due-date` | `YYYY-MM-DD` |
| `--subject` | replace subject |
| `--notes` / `--private` | optional internal journal note with the update |
| `--refresh-meta` | force name→id cache refresh |
Meta cache: `$CACHE/meta/<project>/`
---
## Contacts (local mirror + light mutations)
Separate from full `redmine_contacts.py` CRM CLI on purpose (different cache + surface), but **workflows can hand off**.
```bash
# Mirror / search / show
$RT contacts-sync --max-contacts 100
$RT contacts-search --query bavaria --max-contacts 20
$RT contact-show --contact-id 1890
# Ticket → contact bridge (starts on ticket, ends on contact tools)
$RT ticket-contact --issue-id 41934
$RT ticket-contact --issue-id 41934 --show --fetch
# Contact mutation (draft/apply, same safety as tickets; does NOT email)
$RT contact-set --contact-id 4515 --add-tag maybe-customer
$RT --allow-prod-writes draft apply <id> # if draft was for prod
# LAN apply:
$RT --base-url http://192.168.50.170 contact-set --contact-id 2284 \
--add-tag maybe-customer --apply --refresh-cache
```
| Piece | Path |
|-------|------|
| Local mirror | `$CACHE/contacts/<id>.json` |
| Full CRM (batch/loops/custom fields) | `redmine_contacts.py` |
| Ticket workbench contacts | `contacts-*`, `contact-set`, `ticket-contact` |
Store: `$CACHE/contacts/<id>.json` + `manifest.json`.
---
## Local control tags (not Redmine tags)
```bash
$RT control-tag add --tag bavaria --issue-id 41934 --property subject --annotation '…'
$RT control-tag list
$RT search --control-tag bavaria
$RT control-tag suggest --tag account --where 'subject.includes("account")' --max-tickets 20
$RT control-tag suggest --tag account --where 'subject.includes("account")' --apply
$RT control-tag wipe --i-understand-this-wipes-local-control-tags
```
Store: `$CACHE/control-tags/tags.json`
---
## Attachments
```bash
$RT attachments list --issue-id 39305 --refresh
$RT attachments download --issue-id 39305 --all
$RT attachments upload --issue-id 39305 --file ./doc.pdf # dry-run
$RT --base-url http://192.168.50.170 attachments upload \
--issue-id 39305 --file ./doc.pdf --apply --notes 'attached scan'
```
Default download dir: `$CACHE/$PROJECT/attachments/<issue_id>/`.
Does **not** email the customer. Prod write needs `--allow-prod-writes`.
---
## Entities (tracking / order patterns)
```bash
$RT entities show --issue-id 41394
$RT entities scan --where 'subject.includes("1Z")' --max-tickets 20
$RT entities scan --type tracking_ups --ids-only
```
Heuristic only — not NMI-backed. Also appears in `view` Markdown.
---
## Browse (interactive loop: list → pager → actions)
```bash
# Default: fzf (preview pane) → glow/bat pager → action menu → back to fzf
./bin/rtl browse --where 'assigned_to IS NULL' --max-tickets 80 --open-only
# Explicit pager / no preview
./bin/rtl browse --where '@unassigned' --pager glow
./bin/rtl browse --status New --no-preview
# One-shot (pick once, page, exit — no menu/loop)
./bin/rtl browse --where 'subject.includes("invoice")' --once
```
### Loop keys
| Step | What happens |
|------|----------------|
| **List** | fzf list (Esc quits). Header lists actions. Preview shows ticket snippet. |
| **Enter** | Open ticket Markdown in glow/bat/batcat (`--pager`) |
| **After pager** | Action menu for that ticket |
### Action menu
| Key | Action |
|-----|--------|
| Enter / `v` | View again |
| `n` | Queue **internal note** (no email) |
| `p` | Queue **private** internal note |
| `f` | Queue **field update** (status/assignee by name) |
| `t` | Add **local control-tag** |
| `c` | Show ticket→contact handoff |
| `e` | Extract entities (patterns) |
| `m` | **NMI brief** → confirm → queue as **private** note draft |
| `i` | Print issue id |
| `l` | Back to list |
| `q` | Quit browse |
Mutations from browse **queue drafts only** (loud `draft_id=` banner). Apply later with `draft apply`.
Customer email is **not** in the menu (use CLI deliberately).
```bash
# Notes from file (+ optional Textile via pandoc)
./bin/rtl note --issue-id 41934 --body-file ./reply.md --private
./bin/rtl note --issue-id 41934 --body-file ./reply.md --to-textile --private
# Open tickets only (search/browse)
./bin/rtl search --open-only --where 'assigned_to IS NULL' --max-tickets 20
```
Non-interactive (no TTY): prints list lines and exits.
---
## OCR rename (scanned attachments → better subject)
```bash
# One ticket
./bin/rtl ocr-rename --issue-id 12345
# Scan for generic "Scanned document…" tickets
./bin/rtl ocr-rename --where '@scanned-document' --only-generic-subjects --max-tickets 15
# Apply subject on LAN immediately (note still queued private by default)
./bin/rtd ocr-rename --issue-id 12345 --apply-subject
```
Uses `pdftotext`, else `pdftoppm`+`tesseract`. Queues subject `fields` draft + private OCR note (disable with `--no-queue-subject` / `--no-queue-note`).
---
## Categories
```bash
./bin/rtl categories list
./bin/rtl categories list --refresh
```
---
## Entities + NMI
```bash
./bin/rtl entities show --issue-id 41394
./bin/rtl entities nmi --issue-id 41394
# Env: NMI_CLI, NMI_ENV_FILE, NMI_MODE — see docs/raw-notes/features/nmi-bridge.md
# Browse action `m` queues NMI brief as private note draft
```
---
## Local drafts (queue + apply)
```bash
$RT draft list
$RT draft cancel <id>
$RT draft cancel --all-pending
$RT draft apply <id> # internal notes
$RT draft apply <id> --i-understand-this-emails-the-customer # customer_email only
```
Stored under `$CACHE/drafts/*.json`.
---
## Other subcommands (ops)
| Command | Role |
|---------|------|
| `spam-check` / `close-if-spam` | Deterministic spam tooling (LAN for apply) |
| `known-spam-scan` / `known-spam-close` | Profile-based spam |
| `tag-maybe-customer` | Contact tagging from inquiry evidence |
| `decompose` | Dry-run vault-oriented plan for one ticket |
---
## Modules
| File | Role |
|------|------|
| `redmine_tickets.py` | CLI entry: sync, search, view, walk, spam, … |
| `redmine_ticket_query.py` | Property expression compiler |
| `redmine_ticket_view.py` | Markdown render + pager |
| `redmine_contacts.py` | Shared Redmine client / contacts |
Tests: `tests/test_redmine_ticket_query_and_view.py`, `tests/test_redmine_tickets_remote_filters.py`.
---
## What is **not** here yet
- Queued notes / helpdesk replies / field mutations (P2 remaining)
- Free-text semantic search (Qdrant / semantic_index — separate track)
- SQLite projection layer (planned architecture, not shipped)
**Shipped for queues/filters:** saved-filters + `materialize-queues` (see above).
---
## Quick “is the client alive?” check
```bash
$RT search --max-tickets 3
$RT search --where 'assigned_to IS NULL' --max-tickets 3 --ids-only
ID=$($RT search --max-tickets 1 --ids-only | head -1)
$RT view "$ID" --print | head -30
```
---
## TUI (curses keyboard-driven batch processor) [NEW]
Fast single-screen TUI for the exact workflow you described: queue a template of changes, scroll/cycle tickets, paint the template onto many tickets with one key, then one button to write everything.
```bash
./bin/rtl tui --where 'assigned_to IS NULL' --max-tickets 60 --open-only
./bin/rtl tui --control-tag triage-needed --max-tickets 30
```
**Workflow in the TUI:**
1. **Set template once** (optional fields; menus or type+filter):
- `c` : Category picker (from meta cache; type to filter)
- `a` : Assignee picker (unassigned=0)
- `s` : Status picker
- `p` : Priority (type a number, e.g. 2; hotkeys 1/2/3 also work)
- `P` : Project ID (for moving the ticket)
- `t` : Local control tag (string)
- `C` : Add contact tag (repeatable; creates if new)
- `n` : Private note (applies to painted tickets)
- `e` / `m` : Toggle entity-extraction / NMI-brief flags (become control tags + notes)
2. **Navigate the list**:
- `j` / `k` / arrows : move
- `/` : filter the visible list (live)
- `r` : redraw
3. **Paint / apply**:
- `SPACE` or `Enter` on a ticket: **apply the current template** to it (records per-ticket pending: fields, note, tags, toggles). Shows marker [FNTEM] etc.
4. **Commit**:
- `A` : Queue drafts for all painted tickets (safe default).
- (If you passed `--apply` and have `--allow-prod-writes` on LAN/prod, `S` for direct apply — use with care.)
Other keys: `x` clear pending on current, `R` force meta refresh, `q` quit (prompts if pending), `?` help.
**What gets written on commit:**
- Field updates (status/assignee/priority/category/project) via KIND_FIELDS draft (or direct).
- Private note (per ticket or from template).
- Local control tags (via control-tag add).
- Contact tags (via contact_fields drafts for the helpdesk contact).
- Special toggles → control tags ("entity-extract") + placeholder NMI note if needed.
All the safety rules (drafts vs apply, prod gate, impersonate rebot) still apply. The TUI just removes the per-ticket menu tax.
**Tips:**
- Run `meta-refresh --force` once for full picker lists.
- Combine with saved filters: `./bin/rtl tui --where '@unassigned'`
- The TUI loads from the same cache/search logic as browse/walk.
## TUI Commit behavior update (auto-apply on A)
As of latest change:
- Pressing **A** in the TUI now:
1. Shows a **single** "Are you SURE?" confirmation for the whole batch.
2. Queues the drafts for every painted ticket.
3. Immediately applies those drafts (using the normal apply_draft_to_redmine + mark_applied path).
This removes the need to manually run `draft apply <id>` for each ticket.
`S` remains available for the old direct-apply (bypass draft) path if you ever want it.
All normal safety (LAN/prod gate, --allow-prod-writes, impersonate rebot, no customer emails from this path) remains in force.
See the module docstring in redmine_ticket_tui.py or run the TUI and press `?` for current keys.
## NMI Enrichment Improvements (entities nmi)
The `entities nmi` command (and the NMI path used by browse 'm' and TUI) now:
- Automatically generates rich deep links when it sees item numbers, tracking, request ids, order ids, accounts:
- Item Manager + Item Statement for item_no
- Box History (boxFinder) for tracking numbers
- Order summary and package/request history pages
- User Info for account numbers
(See also the reference note: [[INBOX/WIP/Linking to Entities Within NMI]] )
- When an item_no is resolved, it calls the new `getItemHistory` API endpoint via `nmi item history --item-no=... --snapshot`:
- Returns merged timeline of product_history (moves, condition changes), service_fees (billings), and comments.
- The private note / cs-brief output now includes a "=== NMI Deep Links ===" section and a "=== Item History (getItemHistory) ===" section (truncated for notes; full in --format json).
Usage:
```
rtl entities nmi --issue-id 123456 --format note
rtl entities nmi --issue-id 123456 --format json # full history payload
rtl entities nmi --issue-id 123456 --no-history
```
The same enrichment logic feeds the TUI and browse NMI quick action.
---
## Post-apply cache refresh (anti-staleness)
After any mutation that hits live Redmine (TUI `A`, `draft apply`, `set-fields --apply`, direct notes, etc.) the affected ticket's JSON is immediately re-fetched and overwritten in the local cache.
This means:
- Next `rtl browse`, `rtl tui`, or search sees the new status / assignee / journals / updated_on right away.
- No need to wait for the next full sync for the tickets you just touched.
In the TUI:
- `r` → local redraw (fast)
- `R` → force live refresh of the **currently highlighted** ticket (pulls fresh from Redmine + updates cache + view)
This directly supports the rule: "update the existing cached file when the ticket has changed" and "agents update the ticket when their work is done."
### TUI "S" key (direct apply)
`S` is the **direct/bypass** path (still requires write permission).
- It skips the draft queue for field changes and does an immediate `update_issue`.
- We now also call the same live cache refresh that `A` uses, so the TUI and later `browse` see the change.
- Behavior difference from `A`:
- No "are you sure?" batch confirmation.
- No draft records created for the main fields.
- Still gated by `--allow-prod-writes` (or `--apply`).
Current recommended path for most batch work remains **A** (one confirm, goes through drafts for auditability, then auto-applies).
### TUI safety / UX improvements (2026-07)
- If you start without `--allow-prod-writes`, a clear **READ-ONLY** banner appears near the top.
- Bottom status/help line is now wrapped to avoid `_curses.error` on narrow/short terminals (common when ssh'ing from Windows Terminal, Tabby, or split panes).
- After confirming `A`, the screen immediately shows "COMMIT + APPLY IN PROGRESS" feedback so it doesn't look hung while the writes happen.
- `S` now shows "fields updated DIRECTLY" + cache sync messages when it actually performs a live write.
+183
View File
@@ -0,0 +1,183 @@
# Contact Loop Filter Contract
## Purpose
This document defines the extensible contact-tagging loop contract for `redmine_contacts.py loop-run`.
The loop runner is intentionally boring: it selects contacts, fetches recent Redmine helpdesk context, calls filters, and handles generic queue-state tags. Rich business logic belongs in FILTER-2 scripts/programs.
## Architecture
```mermaid
flowchart TD
A[loop-run profile + CLI filters] --> B[Select contacts]
B --> C{For each contact}
C --> D[Run FILTER-1]
D -->|false| E[Runner adds DTAG]
D -->|integer X| F[Runner fetches X recent tickets]
F --> G[Optional junk/text sanitizer]
G --> H[Run FILTER-2 command]
H -->|false / no-action| E
H -->|true / handled| I[Runner removes RTAGS]
E --> J[Next contact]
I --> J
```
## Runtime profile
Profiles are JSON files. The initial version deliberately supports a small surface that can grow without forcing all policy logic into the main CLI.
Example:
```json
{
"name": "noop-ai-checked",
"default_tag": "ai-checked",
"remove_tags": ["needs-review"],
"filter_1": {
"type": "builtin",
"name": "fixed-ticket-limit",
"limit": 5
},
"ticket_fetch": {
"fetch_full_issues": false,
"message_limit": 25,
"sanitize_text": true
},
"filter_2": {
"type": "command",
"argv": ["python3", "filters/noop_filter2.py"],
"timeout_seconds": 30,
"allow_mutation": false
}
}
```
Required fields:
- `default_tag`: tag added by the runner when FILTER-1 or FILTER-2 returns false.
- `filter_1`: contact-level gate.
- `filter_2`: ticket/contact worker.
Optional fields:
- `name`: human label for reports.
- `remove_tags`: tags removed by the runner when FILTER-2 returns true.
- `ticket_fetch.default_limit`: fallback ticket count if FILTER-1 omits a limit.
- `ticket_fetch.fetch_full_issues`: if true, fetch `issue-full` payloads for each recent issue.
- `ticket_fetch.message_limit`: message limit for full issue payloads.
- `ticket_fetch.sanitize_text`: compact obvious junk/noisy text before passing to FILTER-2.
## FILTER-1 contract
FILTER-1 receives a contact object and decides whether to continue.
Supported v0 modes:
- builtin `fixed-ticket-limit`: returns a configured integer.
- command filters can be added using the same stdin/stdout JSON pattern as FILTER-2.
Result meanings:
```json
{"result": false, "reason": "skip this contact"}
```
Runner behavior: add `default_tag`, do not fetch tickets, move to next contact.
```json
{"result": 5, "reason": "inspect recent tickets"}
```
Runner behavior: fetch 5 recent helpdesk issues for this contact and call FILTER-2.
## FILTER-2 contract
FILTER-2 is the extensibility point for meaningful business workflows. It may classify contacts, update tickets, update contacts, create/link external records, or do nothing.
The runner passes one JSON bundle on stdin:
```json
{
"profile": {
"name": "noop-ai-checked",
"default_tag": "ai-checked",
"remove_tags": ["needs-review"]
},
"contact": {"id": 123},
"issues": [
{
"issue_summary": {"issue_id": 456, "subject": "Example"},
"full_issue": null
}
],
"runtime": {
"base_url": "http://192.168.50.170",
"project": "customer-service",
"dry_run": true,
"helper_command": "/home/jason/projects/redmine/redmine_contacts.py"
}
}
```
FILTER-2 writes JSON to stdout:
```json
{"result": false, "summary": "not enough evidence"}
```
Runner behavior: add `default_tag`.
```json
{
"result": true,
"summary": "classified as customer",
"mutations": ["contact:123 add customer, needs-enriched, needs-nmi-sync"]
}
```
Runner behavior: remove `remove_tags` from the contact. FILTER-2 is responsible for any domain-specific mutations it claims to perform.
## Redmine access for FILTER-2
FILTER-2 programs get standard environment variables:
- `REDMINE_BASE_URL`
- `REDMINE_PROJECT`
- `REDMINE_API_KEY` (inherited only if present; never printed by the runner)
- `REDMINE_CONTACTS_HELPER`
- `REDMINE_LOOP_DRY_RUN` (`1` unless `loop-run --apply` is used)
Command FILTER paths are executed with the profile directory as the working directory unless `filter_2.cwd` overrides it. Keep profile-local scripts under `filters/` when possible.
Recommended mutation path for v0 workers is to call the helper CLI, for example:
```bash
$REDMINE_CONTACTS_HELPER update 123 --add-tag customer --apply
```
In dry-run mode, FILTER-2 must not mutate Redmine. The runner sets `REDMINE_LOOP_DRY_RUN=1` so scripts can enforce this consistently.
## Runner-owned semantics
The runner always owns generic queue state:
- FILTER-1 false -> add `default_tag`.
- FILTER-2 false -> add `default_tag`.
- FILTER-2 true -> remove `remove_tags`.
- FILTER crash/non-JSON -> fail the run; do not mutate that contact as a fallback.
This keeps specialized FILTER-2 programs powerful without making failure handling spooky.
## Initial tracer goal
The first implementation should prove the conveyor belt, not intelligence:
- select a filtered contact batch,
- run builtin FILTER-1 with a fixed ticket limit,
- fetch recent helpdesk issues,
- call a no-op FILTER-2 command,
- dry-run the default tag update,
- optionally apply it with `--apply`.
Once that is reliable, richer FILTER-2 programs can classify customers, automailers, vendors, enrichment targets, and company/person relationships.
@@ -0,0 +1,57 @@
# Contact Triage Loop – Draft-Stage Contact-First Approach (2026-07-09)
**Status**: Draft / exploratory. Intended as a lightweight, iterative starting point that can be tested manually in small batches before any automation is written.
**Context**: This note captures the refined direction discussed on 2026-07-09 for cleaning the customer-service project (currently >1700 open tickets). The previous keyword-on-subject approach is deprioritized. The stronger signal is **contact-first review** using recent activity history.
## Core Principle (Contact-First)
- Group decisions by **contact** (author/email) rather than individual tickets.
- For each contact, examine their **most recent 3–5 tickets/messages**.
- Decide whether that contact (and most of their open tickets) can be safely closed as spam, stale, promotional, or low-value.
- This directly targets the observed pattern that many spam/low-value tickets come from the same small set of repeat contacts.
## Draft Workflow (Repeatable in Small Batches)
1. **Discover candidate contacts**
Find authors with multiple open tickets whose most recent activity is old or low-value.
Starting signals:
- Authors with 5+ open tickets
- Authors whose most recent ticket is >60–90 days old
- Contacts whose tickets are mostly short/generic with zero follow-up
2. **Per-contact review (the decision step)**
For each contact, review their last 3–5 tickets and ask:
- Is this contact mainly sending spam, sales pitches, or unrelated noise?
- Has there been any meaningful reply or resolution path in the last 60–90 days?
- Are the tickets duplicates, test messages, or one-off low-value imports?
- Do they match existing `spam` or `promotional` tag patterns?
3. **Close decision rules**
Close most or all of a contact’s open tickets if any of the following are true:
- Recent pattern is clearly spam/stale/no-value
- No meaningful activity or resolution path in 90+ days
- Tickets are duplicates or low-value imports
**Do not close** if the contact shows any recent legitimate customer issue or money-related matter.
4. **Execution & logging**
- Use existing Redmine MCP tools or the semantic search CLI on mcpness for discovery.
- Apply `spam`/`promotional` tags where appropriate for future filtering.
- Record a short note on the contact or in a running log (which contacts were reviewed and outcome).
5. **Guardrails**
- Never close tickets involving money, formal complaints, or recent customer replies.
- When in doubt, skip the contact.
- After bulk cleanup, maintain a monthly review of “Open tickets from contacts with no activity in 60+ days.”
## Why This Stays Draft-Stage for Now
- Allows manual testing on the first 10–20 worst contacts to see what the real data looks like.
- Validates the contact-history signal before investing in looping scripts or automation.
- Directly supports shrinking the 1700+ open ticket count with low risk.
## Next Natural Steps (for the other thread)
- Run the first discovery pass on the worst offending contacts using current tools.
- Refine the decision questions or tagging rules based on what the contacts actually show.
- Decide whether to move any part of this into a lightweight script once the manual pattern is validated.
This note is a clean hand-off for the active thread working on the contact triage loop project.
@@ -0,0 +1,154 @@
eh, I think you misunderstood me about the filters. I see this:
` batch-update Apply one payload to a filtered contact batch.`
Unless I'm misunderstanding how to use that. it sounds like it's not what I wanted/
I was hoping to be able to do this:
1. In batches of perhaps 10-20, pull a list of contacts that have not been updated for a while, and who either:
- also have specific tags
- also lack specific tags
2. For each of those contacts, do the following:
- check if there's anything about the contact itself that informs me I shouldn't need to look at anything else about it, and I can quickly tag them as something like "spam". I expect most of the time, I _won't_ be able to do that.
- Fetch the 3-5 most recent tickets from the contact, and look at them, using some framework to score any tags that should apply
- Examples:
- **customer**:
- somebody on our side emailed back +1
- seems like they're talking about packages or items in our warehouse +1
- **promo** + **spam**: contact seems like a business sending us advertisements
- from address is something like "no-reply", "noreply", "deals", or "promotions" +1
- it mentions a deal, discount, or coupon +1
- If the scores for any particular set set of tags >= a threshhold, use add_tag to add those tags to the contact
- otherwise, add a tag like "ai-checked"
- The last step caused the updated date of the contact to increase
3. Due to the very last step above (which bumps the updated date), if I wait and loop back through Step 1 again a few minutes or hours later, I won't retrieve the same contacts I already tagged.
- In this way, a looping/repeating task will eventually cycle through all contacts.
- Eventually, the ones it has already tagged will have been updated long enough ago, that they will get pulled again
## To be clear:
I am describing a broad pattern of workflow here, not specifics. I am just explaining this because:
a) The way you implemented the changes seems counterintuitive for how I want to use this, but I could of course be misunderstanding.
b) I think the basic concept of this loop should be added to the project documentation, maybe even with a diagram
## Further down the road:
- [ ] I will want to develop tags that indicates a specific kind of evaluation needs performed with a contact, just as summarizing their most recent communications with us.
- [ ] When looping through contacts, I can add that tag to contacts whose comms need to be summarized
- [ ] Then, another agent or script can fetch only contacts tagged to indicate they need summarized
- [ ] That script/agent fetches their recent communications, does it's job, and removes the tag, indicating that job is finished
- [ ] Other likely tags I will eventually implement:
- [ ] basic info needs corrected:
- Typical auto-import problems exist in the contact data, such as:
- company field contains their email address
- username portion of email address is in first_name, but their actual name is in the footer of the email or in the "from" part
- basic contact info is actually in the ticket, but not in the correct contact fields:
- first name, last name
- additional email addresses
- phone number(s)
- job title
- name of their business
- We know the name of the company they work for, but no contact.is_company=true exists with that name
- Create it
- Tag it as needing enrichment
- Update the contact(s) who work for that company, setting company = that company
- [ ] **NMI mapping**:
- [ ] Contact seems to be a customer, but their "Account Number" field is blank, so we're not sure which NMI account is their's
- [ ] Try to figure it out by comparing stuff like:
- [ ] email address
- [ ] name of company
- [ ] tracking numbers or order ids mentioned
- [ ] ^^ above, but for other contacts who work at the same company
- [ ] Update linkages all around:
- [ ] Update that company (and it's employees) contacts in redmine so "Account Number" matches `user_account.user_account_id` in NMI
- [ ] Set the redmine.contact_id's into a mapping table in NMI that link which contacts are for which account
- [ ] Update obsidian profile about the contact/person to include both pieces of info
- [ ] **entity extraction and enrichment**:
- members of our team responded to somebody, but we don't have (much) info about them in either redmine contacts or in obsidian
- This tells us the person is probably an active employee, so we:
- search for 5-10 other tickets they worked on lately, and use that to infer a profile of what their job is or fill in context about what they've worked on lately
- **phone number linkage to accounts**:
- [ ] transcribe/ingest tickets that are call transcripts, adding summary to contact file automatically
- [ ] add "note/call" to contact automatically
- [ ] identify if follow-up action is needed; if it is, create ticket, assigned to relevant person
- [ ] **bobcat caller id**: recognized contact calls us...
- [ ] **route call based on context, not just the number**:
- [ ] we're super busy, short staffed, and person likes to talk alot: "sorry, we're not available"
- [ ] Bob usually talks to this person, but Bob is marked "do not disturb": "voice mail / not available"
- [ ] **show on-screen bobcat overlay caller-id**
- [ ] **Needing enrichment** (likely more than one tag to represent the kind of enrichment):
- [ ] **social media**:
- [ ] try to identify their social media profiles (linkedin, facebook, twitter, etc) using name, email, etc.
- [ ] possible search known social media groups for people with same/similar name
- [ ] identify product website(s)=
- [ ] **potential suppliers**:
- [ ] search places like alibaba for products we've seen them sell, and try to narrow down who their suppliers are
- [ ] pull checkin pictures of shipping labels from them, and cross-reference with other info to locate possible suppliers
- [ ] **special handling**:
- [ ] **SLA/VIP**: this contact needs very fast or special responses for some reason
- [ ] possibly tied to dedicated agent who does something special with their emails, like:
- [ ] **trace package**: pull data we'd need to trace the workflows about it:
- [ ] if it looks like it has a tracking number, kick off the workflow to pull that up in NMI, get pictures, check who worked on it, tracking status, etc.
- [ ] Possibly fetch surveillance from synology autmatically:
- [ ] For each major timestamp involved ( box.checkin_date, box.presort_date, individual_product.checkin_date ) pull 2-3 minutes of footage before/after from cameras known to overlook the 2-3 nearest locations in the warehouse
- [ ] split the video feed into a series of still images 30-seconds apart
- [ ] have a VLM explain what it sees in the images, even checking for specific things:
- [ ] presence of truck at loading dock
- [ ] person seen walking away from area carrying similar locking box/item
- [ ] **import order**: email is an invoice, PO, or similar listing what a person is sending to us, perhaps the reason, and when it should arrive.
- [ ] _they're a vendor_: log that supplies were being delivered and what we spent on them
- [ ] _prep customer_: make a prep inbound order if it doesn't exist already
- [ ] **vendor receipt or invoice**:
- [ ] automatically log/input that into the expense tracker if it's not there already
- [ ] check if there is a purchase order for it.
- [ ] if there is, do something like mark it en-route, received, etc.
- [ ] create ticket, assigning somebody to put it away
- [ ] assign an AI-INFO-BLOCK to the manager, asking them to find out who ordered it, what for, and why they didn't make a PO
- [ ] **employee and it's about scheduling**
- [ ] check the attendance-schedule project to see if they already reported it in there
- [ ] if not, add it
- [ ] assign an AI-INFO-BLOCK asking them to explain why they could not do that themselves
- [ ] **check for time-off blockers**:
- [ ] somebody else already has time off within 1-2 days
- [ ] customer has complained about delays in the past week
- [ ] same person asked off on the same weekday/pattern in the past 3-4 weeks, and no good reason
- [ ] overall billings/productivity have been low for over a week
- [ ] **if blocked**: warn them they can't have the day off unless they can prove it's manditory/unavoidable
- [ ] if they say it is, AI-INFO-BLOCK until they provide proof or admit they don't have it
- [ ] **very simple question**
- [ ] **sales 20-questions**: they sent an email that amounts to playing 20-questions about capacity (wanting to start project)
- [ ] **AI-agent uses obsidian and other data**:
- [ ] generate really slick looking brochure quickly, using obsidian-backed data
- [ ] loop human in to approve it
- [ ] update/expand based on human input, then send it over to client
- [ ] save it for later, so next time, it can just be sent right away
- [ ] **spammer-be-gone**:
- [ ] Identify habitual or serious spammers, time wasters, and promoters emailing us
- [ ] use gmail MCP to add filters to contact@ldrprep.com so that person's mail doesn't get imported into redmine anymore
- [ ] **high-risk contact**:
- [ ] there is some special risk or liability related to talking to that contact. For examples:
- [ ] litigious person or debt collector
- [ ] disgruntled former employee
- [ ] stalker spouse of current employee
- [ ] handle ticket(s) in a manner people who shouldn't talk to them can't see it or can't respond to it
- [ ] phone calls from them do not ring to the general line anyone can answer
- [ ] **lead follow-up**:
- [ ] contacts marked as wanting to potentially buy something from us or start services, but we've heard crickets for 7+ days
- [ ] encourage somebody to call/email them, maybe even suggest what to say/send
## Please ensure this info is saved with the project and easy to locate
For right now, much of this will not be immediately practical to implement, but I think storing it here is both important context and saves time.
+211
View File
@@ -0,0 +1,211 @@
# Contact Triage Loop Workflow (Vision + Operating Pattern)
## Why this exists
This document captures Jason's intended **repeatable contact-review loop** so work can proceed in deterministic batches without reprocessing the same contacts immediately.
Captured from uploaded project note on **2026-07-04** (saved into repo context to avoid losing the design intent).
Raw source capture:
- `/home/jason/projects/redmine/docs/contact_triage_loop_raw_notes_2026-07-04.md`
---
## Core loop pattern
This is a **multi-pass routing system**, not a final one-shot classifier.
1. Select a small batch (10-20 contacts) where `updated_on` is old enough and optional tag constraints match.
2. Sort **newest stale first** (recently stale / last-updated descending), so the loop works through the freshest cooled-down contacts first.
3. For each contact:
- quick contact-only triage
- inspect recent helpdesk tickets/messages when the current pass requires it
- route by adding/removing tags that represent either classifications or workflow state
4. Apply tag updates:
- if the pass identifies a route, add that route/classification tag
- otherwise add a status marker like `ai-checked`
5. Because contact metadata was updated, `updated_on` advances.
6. On future runs, recently processed contacts fall out of the immediate stale batch; later passes can pick them up again when stale or when a designated tag routes them to another worker.
7. Later filters/workers may deliberately remove status tags such as `ai-checked` after consuming them, allowing the contact to re-enter another pass of the conveyor belt.
This forms a self-advancing conveyor belt for incremental cleanup/enrichment. Tags are not all semantic classifications: some are routing/status markers. For example, `ai-checked` means this pass touched the contact; it does not mean the contact's final identity is "AI checked." Likewise, `spam`/`promotions` can be step 1 in a longer handling pipeline, not the final disposition.
Operational objective: reducing clutter in the customer-service/sales channel is valid even when a sender is technically a vendor, billing system, automailer, or promotional sender, because Redmine customer-service is not the only channel available to them.
---
## Existing tooling alignment (current)
`redmine_contacts.py` already supports key building blocks:
- batch candidate selection:
- `batch-update --max-contacts`
- date/tag filters (`--updated-before`, `--with-tag`, `--without-tag`, `--no-tags`)
- deterministic ordering (`--order newest-updated-first` etc)
- safe mutation workflow:
- dry-run by default
- write only with `--apply`
What is still missing for full loop automation:
- richer FILTER-2 workers that classify contacts and mutate tickets/contacts when needed
- scoring engines that map evidence to tags with thresholds
- downstream queue workers for enrichment/NMI/company/person linking
Implemented tracer pieces:
- `redmine_contacts.py loop-run`
- profile contract in `docs/contact_loop_filter_contract.md`
- sample tracer profile: `profiles/contact-loop/noop-ai-checked.json`
- sample no-op FILTER-2 worker: `profiles/contact-loop/filters/noop_filter2.py`
The intended architecture is now: **runner owns conveyor-belt mechanics; FILTER-2 owns domain-specific ticket/contact mutation logic.**
---
## Suggested scoring framework (v0)
### customer
Signals:
- internal/team response exists (`+1`)
- discussion indicates warehouse packages/items (`+1`)
### promo + spam
Signals:
- sender hints (`no-reply`, `noreply`, `deals`, `promotions`) (`+1`)
- message mentions discount/coupon/deal language (`+1`)
Threshold model (initial):
- tag set applied when score >= `2`
- if no tag set crosses threshold: add `ai-checked`
---
## Diagrams
### Diagram 1 — Conveyor-belt loop
```mermaid
flowchart TD
A[Candidate pool\nRedmine contacts] --> B[Select stale contacts\nupdated_before + tag filters\nmax 10-20]
B --> C[Sort newest stale first\nupdated_on descending]
C --> D{For each contact}
D --> E[Run current pass\nFILTER-1 / worker gate]
E -->|pass can route immediately| F[Add route/status/classification tags]
E -->|needs evidence| G[Fetch recent tickets/messages]
G --> H[Manual review or FILTER-2 worker]
H --> I{Route decided?}
I -->|yes| F
I -->|no| J[Add status marker\nai-checked]
F --> K[Update contact/tickets as allowed]
J --> K
K --> L[updated_on advances\ncontact leaves immediate batch]
L --> M[Later passes use staleness + tags\nto route next work]
M --> N[Later worker may remove status tag\ne.g. ai-checked]
N --> B
```
### Diagram 2 — Tag/status re-entry lifecycle
```mermaid
stateDiagram-v2
[*] --> Eligible: updated_on older than cutoff\nand tag filters match
Eligible --> InCurrentPass: newest-stale-first selection
InCurrentPass --> Routed: worker adds route tag\nspam/promotions/etc.
InCurrentPass --> Checked: no route yet\nadd ai-checked
Routed --> CoolingDown: tag update bumps updated_on
Checked --> CoolingDown: status tag update bumps updated_on
CoolingDown --> HeldOut: too fresh for current cutoff
HeldOut --> Eligible: time passes / becomes stale again
Checked --> DownstreamQueued: later worker selects ai-checked\nor another status tag
DownstreamQueued --> ReEntered: worker removes ai-checked\nafter consuming status marker
ReEntered --> Eligible: tag filters match next pass
```
---
## Future queue/tag orchestration pattern
The long-term architecture should treat tags as explicit work queues, for example:
- `needs-summary` -> summary worker processes recent communications, then removes tag
- `needs-enrichment:*` -> enrichment workers (social/entity/profile data)
- `needs-nmi-mapping` -> account-linkage worker for NMI mapping tasks
- `special-handling:*` -> SLA/VIP/high-risk handling workflows
This lets one loop classify and route, while specialized workers execute downstream tasks.
---
## Proposed implementation phases
### Phase 1 (implemented tracer)
- Add `loop-run` command that performs a **per-contact review loop** in dry-run mode:
- select candidates
- run FILTER-1
- fetch recent tickets/messages
- call a profile-defined FILTER-2 command
- print proposed runner-owned tag updates
### Phase 2
- Replace no-op FILTER-2 with a classifier/mutator worker.
- Use FILTER-2 to update contacts/tickets directly through helper CLI calls.
- Keep runner-owned fallback `ai-checked` and RTAG removal semantics.
### Phase 3
- Add queue tags + worker handoff contracts (`needs-summary`, `needs-nmi-mapping`, etc.).
### Phase 4
- Integrate external systems (NMI mapping tables, obsidian profile updates, comm summaries, etc.) with strict scoped guards.
---
## Practical run pattern (current CLI)
Dry-run stale-first loop tracer:
```bash
./redmine_contacts.py \
--base-url http://192.168.50.170 \
--project customer-service \
loop-run \
--profile profiles/contact-loop/noop-ai-checked.json \
--source cache \
--updated-before 2026-06-01 \
--without-tag ai-checked \
--max-contacts 10 \
--order newest-updated-first
```
Apply runner-owned fallback/default tags after reviewing output:
```bash
./redmine_contacts.py \
--base-url http://192.168.50.170 \
--project customer-service \
loop-run \
--profile profiles/contact-loop/noop-ai-checked.json \
--source cache \
--updated-before 2026-06-01 \
--without-tag ai-checked \
--max-contacts 10 \
--order newest-updated-first \
--apply
```
Older dry-run batch-update skeleton:
```bash
./redmine_contacts.py \
--base-url http://192.168.50.170 \
--project customer-service \
batch-update \
--source cache \
--updated-before 2026-06-01 \
--with-tag customer \
--max-contacts 20 \
--order newest-updated-first \
--set background=ai-checked
```
> Replace payload fields/tags only after reviewing dry-run output.
@@ -0,0 +1,53 @@
{
"generated_at": "2026-07-09",
"status": "proposal-only-not-applied",
"notes": "Generated by ticket decomposition pilot. Do not apply blindly; first decide Redmine custom field/background convention for Onyx article links.",
"proposals": [
{
"redmine_issue_id": 41424,
"contact_id": 4470,
"email": "support@repackify.com",
"entity_note": "/home/jason/lilaBuild/vaults/obsidian-ldr/entities/organizations/repackify.md",
"onyx_url": "https://onyx.int.ldrprep.com/projects/obsidian-ldr/entities/organizations/repackify.md",
"add_tags": [
"maybe-customer",
"partnership-opportunity",
"buy-from-us",
"gaylords"
],
"confidence": "medium",
"rationale": "Inbound request to buy used Gaylord boxes from LDR; not generic spam, but not confirmed customer."
},
{
"redmine_issue_id": 41448,
"contact_id": 3919,
"email": "sales@u2wear.com",
"entity_note": "/home/jason/lilaBuild/vaults/obsidian-ldr/entities/organizations/u2wear.md",
"onyx_url": "https://onyx.int.ldrprep.com/projects/obsidian-ldr/entities/organizations/u2wear.md",
"add_tags": [
"Customer",
"returns-management",
"fulfillment",
"elora",
"u2wear"
],
"confidence": "high",
"rationale": "Jason confirmed sales/info@u2wear.com are customers; ticket references a previously discussed fulfillment and returns workflow."
},
{
"redmine_issue_id": 41504,
"contact_id": 4476,
"email": "justine.micks@snugglepuppy.com",
"entity_note": "/home/jason/lilaBuild/vaults/obsidian-ldr/entities/organizations/snuggle-puppy.md",
"onyx_url": "https://onyx.int.ldrprep.com/projects/obsidian-ldr/entities/organizations/snuggle-puppy.md",
"add_tags": [
"maybe-customer",
"returns-management",
"fba-returns",
"pricing"
],
"confidence": "high",
"rationale": "Prospect/customer inquiry about warehousing and FBA returns; exposed returns-pricing/rate-sheet collateral gap."
}
]
}
+290
View File
@@ -0,0 +1,290 @@
# KarpathyWiki Obsidian Plugin Inspection
**Date:** 2026-07-09
**Plugin:** `karpathywiki` / `green-dalii/obsidian-llm-wiki`
**Repo inspected:** `https://github.com/green-dalii/obsidian-llm-wiki`
**Fresh clone verification:** commit `0979d50`
**License:** Apache-2.0
**Version observed:** 1.24.0
## Why inspected
Jason observed that the Obsidian Karpathy LLM Wiki plugin is doing much of what the Redmine ticket decomposition process should do: entity/concept extraction, knowledge graph page generation, links, indexes, and conversational query. The question was whether we can trigger it headlessly/CLI or mirror its logic.
## Repository / structure findings
The plugin is a compact TypeScript Obsidian plugin.
Important files:
```text
src/main.ts
src/wiki/wiki-engine.ts
src/wiki/source-analyzer.ts
src/wiki/page-factory.ts
src/wiki/prompts/ingestion.ts
src/wiki/prompts/generation.ts
src/wiki/prompts/merge.ts
src/wiki/prompts/lint.ts
src/wiki/query-engine/
src/core/
```
Package metadata:
```json
{
"name": "karpathywiki",
"version": "1.24.0",
"main": "main.js"
}
```
Fresh clone verification confirmed `package.json` has no `bin` entry:
```text
PACKAGE {'name': 'karpathywiki', 'version': '1.24.0', 'main': 'main.js', 'bin': None, 'license': 'Apache-2.0'}
```
There is no package `bin` entry and no obvious standalone CLI exposed by the project.
## Obsidian command entry points
`src/main.ts` registers Obsidian commands:
```text
ingest-source
ingest-folder
ingest-multiple-files
query-wiki
lint-wiki
regenerate-index
cancel-ingestion
ingest-active-file
view-ingestion-history
recreate-welcome-note
```
These are Obsidian command palette commands, not shell commands.
If Obsidian is running, it may be possible to trigger commands through Obsidian/Advanced URI style automation, but that would still depend on Obsidian being open and the plugin being loaded. It is not a true headless pipeline.
## Core engine dependency
The core class is `WikiEngine` in `src/wiki/wiki-engine.ts`.
It is separate from the plugin UI, but its constructor requires Obsidian types and APIs:
```ts
constructor(
app: App,
settings: LLMWikiSettings,
getLLMClient: () => LLMClient | null,
schemaManager: SchemaManager,
onFileWrite?: (path: string) => void,
onProgress?: (message: string) => void,
onDone?: (report: IngestReport) => void
)
```
It uses:
- `app.vault.read`
- `app.vault.getMarkdownFiles`
- `app.vault.process`
- `app.vault.create`
- `app.vault.getAbstractFileByPath`
- `app.metadataCache.getFileCache`
- `TFile`, `TFolder`, `Notice`, `normalizePath`
Therefore, the engine cannot be called directly from Node/Python CLI without either:
1. running inside Obsidian, or
2. writing an adapter/shim that emulates enough of Obsidian's `App/Vault/metadataCache/TFile` behavior.
## Portable logic worth mirroring
The useful logic is not locked to Obsidian conceptually. It can be mirrored or adapted:
### Source analysis
`src/wiki/source-analyzer.ts`:
- reads source content
- checks blank/duplicate content
- prompts LLM for structured JSON
- extracts entities and concepts
- includes aliases
- collects verbatim quotes / provenance
- identifies contradictions, related pages, key points
- normalizes irregular LLM JSON
The core ingestion prompt is in `src/wiki/prompts/ingestion.ts`.
Notable extraction output shape:
```json
{
"source_title": "...",
"summary": "...",
"entities": [
{
"name": "...",
"type": "person|organization|project|product|event|place|other",
"aliases": [],
"summary": "...",
"mentions_in_source": [],
"mentions_with_provenance": [],
"related_entities": [],
"related_concepts": []
}
],
"concepts": [],
"contradictions": [],
"related_pages": [],
"key_points": []
}
```
### Page generation
`src/wiki/page-factory.ts`:
- slugifies entities/concepts
- checks existing pages
- resolves duplicates via slug/alias/LLM semantic match
- generates entity/concept pages
- merges into existing pages
- protects reviewed pages
- corrects polluted wikilinks
- injects provenance/mentions
### Prompts
`src/wiki/prompts/generation.ts` includes high-value generation prompts for:
- entity pages
- concept pages
- source summary pages
- preserving reviewed pages
### Query / graph retrieval
The README claims zero-embedding graph retrieval via Personalized PageRank over the wiki link graph. Source references in `WikiEngine` show:
```ts
buildGraphFromContent(...)
getOrBuildGraph(...)
```
and query pipeline files exist under:
```text
src/wiki/query-engine/pipeline/
```
This is useful design inspiration, but not required for the first Redmine decomposition tool because gbrain already supplies search/indexing and the vault graph itself supplies wikilinks.
## Recommendation
Do **not** depend on Obsidian running for Redmine ticket decomposition.
Instead:
1. Mirror the plugin's source-analysis and page-generation logic in our own CLI-oriented tool.
2. Reuse its prompt structure and output schema ideas.
3. Write directly to the filesystem vault, but outside the plugin-owned `wiki/` tree by default.
4. Use gbrain for indexing/search after files are written.
5. Keep Redmine contact updates as a separate explicit apply phase.
6. Preserve compatibility with Obsidian/KarpathyWiki by using normal Markdown, wikilinks, and frontmatter fields the plugin understands where practical.
Compatibility rule: the plugin owns `wiki/entities`, `wiki/concepts`, `wiki/sources`, `wiki/index.md`, and `wiki/log.md`. The headless decomposer should default to LDR-owned paths like `Timeline/`, `entities/organizations/`, and `Processes/Redmine/`, linking to `wiki/...` pages when useful but not editing them.
This gives us the same broad effect without requiring a GUI Obsidian session and without competing with the existing plugin state.
## Possible implementation paths
### Path A — Thin TypeScript headless port
Build a Node/TypeScript CLI in `/home/jason/projects/redmine` or a sibling project that imports/copied logic from the plugin and supplies a fake Obsidian adapter.
Pros:
- Closer to upstream code.
- Easier to vendor future prompt improvements.
- Can reuse zod/AI SDK provider setup.
Cons:
- Need to emulate enough of Obsidian `Vault`, `TFile`, metadata cache, and settings.
- Adds Node/TypeScript toolchain complexity to Redmine workflow.
### Path B — Python mirror using existing Redmine tooling
Implement the same conceptual pipeline in Python alongside `redmine_tickets.py`:
- source normalization
- LLM JSON extraction
- entity/concept dedup via filesystem search + slug rules
- page generation templates
- timeline/entity writers
- gbrain sync hooks
Pros:
- Fits current Redmine tooling.
- Easier to integrate with ticket cache and contact updates.
- No Obsidian runtime needed.
Cons:
- We do not directly reuse TypeScript implementation.
- Need to maintain our own prompt/schema logic.
### Path C — Obsidian-driven automation
Use Obsidian command automation / Advanced URI to trigger plugin commands.
Pros:
- Minimal code if it works.
Cons:
- Requires Obsidian running.
- Harder to make deterministic/headless/cron-safe.
- Harder to feed Redmine tickets and capture structured contact-update proposals.
## Best fit for LDR Redmine decomposition
Path B is the best first implementation path.
Reasons:
- Redmine tickets are already fetched/cached in Python.
- We need Redmine contact updates and safety gates.
- We need Onyx URLs and gbrain sync hooks.
- We need headless/cron/agent execution.
- We already proved manual note generation format in the decomposition pilot.
Use the plugin as a reference for:
- `src/wiki/prompts/ingestion.ts` — source analysis prompt and entity/concept output shape
- `src/wiki/prompts/generation.ts` — entity/concept/source page generation prompts
- `src/wiki/prompts/merge.ts` — classify-then-route merge strategy for updating existing pages
- `src/wiki/prompts/lint.ts` — wiki quality checks and contradiction review ideas
- `src/wiki/source-analyzer.ts` — JSON normalization, duplicate/blank source handling, provenance mentions
- `src/wiki/page-factory.ts` — slug/alias resolution, cross-type collision handling, merge-vs-append routing
- `src/wiki/query-engine/` — graph retrieval ideas for later, not first pass
Do not try to invoke it via Obsidian as the primary production pipeline; use the source as a design reference for a headless Python mirror.
## Immediate next action
Update `ticket_decomposition_knowledge_ingestion_plan.md` so Phase 2 says:
- use KarpathyWiki plugin prompt/schema as reference
- implement a Python headless mirror first
- consider TypeScript adapter later only if Python mirror becomes too costly
Then implement a `decompose --dry-run` engine using the pilot tickets as fixtures.
+623
View File
@@ -0,0 +1,623 @@
# Known-Spam Fast Close Implementation Plan
**Date:** 2026-07-09
**Project:** `/home/jason/projects/redmine`
**Primary tool:** `redmine_tickets.py`
**Target Redmine project:** `customer-service`
**Production base URL:** `https://rm.ldrprep.com`
**Agent write attribution:** `rebot` / Harry Botter
## Goal
Build a reliable two-prong production spam-cleanup workflow:
1. **Slow/reviewed path** — the current classifier-driven `sync → spam-check → close-if-spam` flow, used for discovering and validating new spam patterns.
2. **Fast known-profile path** — a verified-profile bulk closer that quickly locates all open `customer-service` tickets matching known spam sender/profile rules and closes them in one bounded, idempotent production operation.
The immediate business goal is to close known promotional/spam clutter quickly without risking customer tickets.
---
## Current Reality
The slow path already works in production:
- `redmine_tickets.py sync` caches enriched tickets using `include=journals,helpdesk`.
- `spam-check` categorizes tickets into classes such as:
- `spam-promotional`
- `automated-operational`
- `unknown-review`
- `close-if-spam` closes reviewed `spam-promotional` tickets using `--impersonate-user rebot`.
- Writes are verified by API as `status=Closed` and close-note author `Harry B.`.
- Idempotency is recorded via local action markers.
- Customer-contact guard is active: before spam-close, the tool fetches the Helpdesk contact and skips if the contact has tag `Customer`.
Validated production cleanup so far:
- First production pass closed 20 reviewed promotional-junk tickets.
- Current 200-ticket cache has a reviewed 27-ticket candidate set pending apply.
- Customer pre-tagging has been performed for confirmed customers including Cyberstorm, Esire-family contacts, Shinkiya, Bohoflow, BavariaTrachten, Solride, and u2wear.
Known safety rule:
> Absence of `Customer` does **not** imply spam. Presence of `Customer` blocks automated spam closure.
---
## Desired Two-Prong Architecture
```text
┌────────────────────────────┐
│ redmine_tickets.py substrate│
│ sync/cache/helpdesk/actions │
└──────────────┬─────────────┘
│
┌───────────────────────────┴───────────────────────────┐
│ │
Slow / reviewed path Fast / known profile path
discover new patterns close already-verified spam
│ │
sync → spam-check → review → close-if-spam known-spam-profile scan → dry-run → apply
│ │
produces new safe profiles uses frozen allowlisted rules
```
### Slow path responsibilities
Use for:
- New sender types.
- Ambiguous business/vendor/customer cases.
- Classifier development.
- False-positive discovery.
- Contact/customer enrichment.
### Fast path responsibilities
Use for:
- Previously reviewed spam senders/domains/campaigns.
- High-volume obvious junk such as known promo newsletters and sales blasts.
- Rapid production cleanup once a profile has been approved.
The fast path must not rediscover intent. It should only act on a curated profile registry.
---
## Fast Known-Spam Profile Design
### Risk Model Update — Closed Is Recoverable
Jason clarified an important Redmine/Helpdesk behavior:
- Closed tickets are not deleted.
- If the other party sends a new message to a closed ticket, Redmine reopens it and status changes to `New Message`.
That means accidental closure is usually recoverable if the other party still cares enough to reply. The larger risk is **re-closing a ticket after the sender already reopened it**, because that means the system is ignoring renewed human intent.
Policy consequence:
1. If a ticket is clearly `spam-promotional` and stale, especially `updated_on` older than 30 days, it is reasonable to close automatically once other safety gates pass.
2. If a ticket has evidence that it was previously closed and later reopened by a new Helpdesk message, do **not** close it automatically, even if it matches spam.
3. Instead, escalate it for human attention: bump priority one level if possible and optionally set a due date 1–3 days out.
### Terminology
A **known spam profile** is a conservative, reviewed rule bundle that identifies tickets we are comfortable closing automatically, subject to safety gates.
Example profiles:
```yaml
profiles:
globalindustrial-promos:
senders:
- reply@globalindustrial.com
subject_patterns:
- "(?i)\\b(clearance|sale|save|% off|bestsellers|fans|blowers|cooling|storage|power tools|kee safety)\\b"
category: spam-promotional
confidence: high
notes: "Repeated promotional vendor emails; reviewed in LAN + prod broom passes."
vistaprint-promos:
senders:
- noreply@mail.vistaprint.com
subject_patterns:
- "(?i)\\b(sale|save|% off|email exclusive|ends|qualifying orders|favorite sale|customer faves)\\b"
category: spam-promotional
confidence: high
qnap-newsletters:
senders:
- enews-noreply@qnap.com
subject_patterns:
- "(?i)\\b(qnap|photodirector|nas|security advisory|product of the year|jc-star|file backup)\\b"
category: spam-promotional
confidence: high
business-journals-promos:
senders:
- replycincinnati@engaged.bizjournals.com
- replycincinnati@partner.bizjournals.com
- acollett@bizjournals.com
subject_patterns:
- "(?i)\\b(events|nominations|business summit|brand next to key business lists|chambercomp)\\b"
category: spam-promotional
confidence: high
```
### Safety gates always applied before close
Fast path must still apply these universal gates:
1. Ticket must be open.
2. Ticket must be in `customer-service` unless explicitly overridden.
3. Ticket Helpdesk contact must be fetched.
4. Contact with tag `Customer` must be skipped.
5. Optional: contact with tag `maybe-customer` should be skipped by default unless `--allow-maybe-customer` is explicitly passed.
6. Ticket must not already have local `known-spam-close` / `close-if-spam` marker.
7. If the ticket was previously closed and then reopened by a new Helpdesk message, skip spam-close and escalate instead.
8. Apply writes must impersonate `rebot`.
9. Apply must produce a verification report.
### Reopened-ticket escalation behavior
Add a helper such as `ticket_was_reopened_after_close(issue)` that inspects journals/details for evidence of a transition from Closed/Resolved into `New Message` or another open status after a Helpdesk reply.
When true, do not close as spam. Instead, planned behavior should be:
```json
{
"priority_id": "one level higher if known/mappable",
"due_date": "today + 1 to 3 days",
"notes": "Matched spam profile, but ticket appears to have been reopened after closure. Escalating for human review instead of auto-closing."
}
```
This should be its own idempotent action marker, e.g. `spam-reopen-escalation`, so the ticket is not bumped repeatedly.
---
## Files to Modify
- Modify: `/home/jason/projects/redmine/redmine_tickets.py`
- Modify: `/home/jason/projects/redmine/tests/test_redmine_tickets.py`
- Create: `/home/jason/projects/redmine/profiles/known-spam/customer-service.json`
- Create: `/home/jason/projects/redmine/docs/rebot_legacy_spam_rules.md`
- Create/Update: `/home/jason/projects/redmine/docs/known_spam_fast_close_plan.md`
- Update: `/home/jason/projects/redmine/HANDOFF.md`
- Update: `/home/jason/projects/redmine/docs/ticket_spam_cleanup_handoff_2026-07-09.md`
- Optional later: `/home/jason/projects/redmine/docs/known_spam_profile_registry.md`
---
## Proposed CLI
### Dry-run known-spam scan
```bash
cd /home/jason/projects/redmine
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir .cache/redmine_tickets_prod_known_spam_$(date +%Y%m%dT%H%M%SZ) \
known-spam-scan \
--profile profiles/known-spam/customer-service.yaml \
--status-id open \
--max-tickets 2000
```
Expected output:
```text
Known-spam scan complete: seen=1732 matched=214 skipped_customer=3 skipped_maybe_customer=5 planned=206 apply=False
profile=globalindustrial-promos issue=... sender=... subject=...
...
Dry run only. Re-run known-spam-close --apply using this cache/profile after review.
```
### Apply known-spam close
```bash
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir .cache/redmine_tickets_prod_known_spam_<timestamp> \
known-spam-close \
--profile profiles/known-spam/customer-service.yaml \
--close-status-id 5 \
--impersonate-user rebot \
--apply
```
Expected output:
```text
Known-spam close complete: planned=206 closed=206 skipped_customer=0 skipped_existing_marker=0 apply=True
```
### Verify apply
```bash
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir .cache/redmine_tickets_prod_known_spam_<timestamp> \
verify-known-spam-close
```
Expected output:
```text
Verify known-spam close: checked=206 closed=206 author_harry_b=206 failures=0
```
---
## Implementation Tasks
### Task 1: Add profile loader
**Objective:** Load known-spam YAML profiles safely.
**Files:**
- Modify: `redmine_tickets.py`
- Test: `tests/test_redmine_tickets.py`
- Create: `profiles/known-spam/customer-service.yaml`
**Implementation notes:**
- Use stdlib if PyYAML is not guaranteed? Existing environment may support YAML via project deps, but avoid installing packages.
- Safer first version: store profile as JSON instead of YAML if dependency uncertainty matters.
- Recommended file if avoiding deps:
```text
profiles/known-spam/customer-service.json
```
**Test cases:**
- valid profile loads
- missing required fields fails
- invalid regex fails with helpful error
**Acceptance:**
```bash
python3 -m unittest discover -s tests -p 'test_redmine_tickets.py' -v
python3 -m py_compile redmine_tickets.py tests/test_redmine_tickets.py redmine_contacts.py
```
---
### Task 2: Implement known-profile matcher
**Objective:** Determine whether a cached ticket matches a known spam profile.
**Files:**
- Modify: `redmine_tickets.py`
- Test: `tests/test_redmine_tickets.py`
**Rules:**
A ticket matches when:
- sender matches one of profile senders/domains, and
- subject/body pattern matches if provided, and
- category is `spam-promotional`, and
- it does not match profile exclusions.
**Test examples:**
- `reply@globalindustrial.com` + `Clearance Sale: Top Steel Storage And Power Tools` matches `globalindustrial-promos`.
- `reply@globalindustrial.com` + a real invoice/order/customer thread does **not** match if exclusion pattern present.
- `enews-noreply@qnap.com` + `QNAP Security Advisory` matches only if we decide QNAP security advisories are clutter; this profile should be explicit so no one later acts surprised.
---
### Task 3: Add customer/maybe-customer universal skip gates
**Objective:** Ensure known-spam fast path has the same safety guard as slow path.
**Files:**
- Modify: `redmine_tickets.py`
- Test: `tests/test_redmine_tickets.py`
**Tests:**
- contact tagged `Customer` is skipped even if ticket matches known profile.
- contact tagged `maybe-customer` is skipped by default.
- `--allow-maybe-customer` permits maybe-customer if explicitly requested.
- absence of tags does not block known spam profile matching.
**Acceptance:**
Fast path must never bypass customer safety.
---
### Task 3B: Detect reopened-after-close tickets and escalate instead of closing
**Objective:** Prevent the cleaner from repeatedly closing a ticket after the other party has reopened it with a new message.
**Files:**
- Modify: `redmine_tickets.py`
- Test: `tests/test_redmine_tickets.py`
**Implementation notes:**
- Add `ticket_was_reopened_after_close(issue)`.
- Inspect `issue['journals']` and their `details` for status changes.
- Treat evidence of Closed/Resolved → New Message/open status as reopened-after-close.
- If exact status IDs are easier than names, map known status names/IDs from existing Redmine data before hardcoding.
- Do not rely on current status alone; the point is historical reopening.
**Tests:**
- Journal with status change Closed → New Message returns true.
- Journal with only New → Closed returns false.
- Known-spam close skips reopened-after-close tickets.
- Apply path records `spam-reopen-escalation` instead of `known-spam-close`.
**Escalation behavior:**
- Dry-run reports the escalation plan.
- Apply should update priority/due date only when explicit `--apply-escalations` or equivalent is provided; do not mix escalation writes into spam-close apply without a clear flag.
---
### Task 4: Add `known-spam-scan` command
**Objective:** Sync/search open tickets and report matches without closing.
**Files:**
- Modify: `redmine_tickets.py`
- Test: `tests/test_redmine_tickets.py`
**Behavior:**
- Fetch open `customer-service` tickets with `include=journals,helpdesk`.
- Cache ticket payloads.
- Evaluate known spam profiles.
- Fetch contacts for matched tickets only.
- Report:
- matched profile
- issue ID
- sender
- subject
- contact ID
- age / `updated_on`
- skipped reason if skipped
- Mark tickets older than 30 days and matching a verified `spam-promotional` profile as strong fast-close candidates.
- Newer tickets may still be candidates, but the report should separate them from stale 30+ day candidates.
- Record local action marker:
- `known-spam-scan = matched|matched-stale|skipped-customer|skipped-maybe-customer|skipped-reopened|not-matched`
**Dry-run only:** This command should never write Redmine.
---
### Task 5: Add `known-spam-close` command
**Objective:** Close the previously scanned/planned known-spam tickets.
**Files:**
- Modify: `redmine_tickets.py`
- Test: `tests/test_redmine_tickets.py`
**Behavior:**
- Reads cache + scan markers.
- Only closes tickets with marker `known-spam-scan = matched`.
- Re-checks contact tags before close. Do not rely only on stale scan state.
- Updates ticket with:
```json
{
"status_id": 5,
"notes": "Closed as known promotional spam by redmine_tickets.py known-spam-close. Profile: <profile-name>."
}
```
- Writes as `rebot`.
- Records:
- `known-spam-close = closed`
- profile name
- close payload
- verification pending
**Tests:**
- apply closes only matched markers
- second apply closes zero
- customer tag recheck blocks closure even if scan marker was matched
- impersonation defaults to `rebot`
---
### Task 6: Add verification command/report
**Objective:** Verify production writes after apply.
**Files:**
- Modify: `redmine_tickets.py`
- Test: `tests/test_redmine_tickets.py`
**Behavior:**
For every `known-spam-close = closed` marker:
- fetch issue with `include=journals,helpdesk`
- verify `status.name == Closed`
- verify close note exists
- verify note author is `Harry B.`
- print failures clearly
**Output:**
```text
Verify known-spam close: checked=206 closed=206 author_harry_b=206 failures=0
```
---
### Task 7: Seed first known-spam profile registry from verified production data
**Objective:** Convert already-validated spam senders into conservative profiles.
**Initial profile groups:**
- Global Industrial promos
- Vistaprint promos
- QNAP newsletters/promos
- Business Journals / BizJournals promo/events
- TikTok Shop seller promos
- Popmenu restaurant promos
- Samsung promo newsletters
- Cintas mop/product promos
- Wings and Rings restaurant promos
- SellerEssentials promos
**Do not include yet without explicit decision:**
- `sales@supermarketdistribution.com` / stock/MOQ updates — may be vendor-ish; review first.
- QNAP security advisories — likely clutter, but security language can look operational; make it explicit in profile if included.
- Any sender with `Customer` or `maybe-customer` contacts.
---
### Task 8: Production rollout procedure
**Objective:** Define exact operator sequence for today.
**Step A: Commit or checkpoint local code**
Before production bulk close, ensure current code/tests/docs are checkpointed in git or at least `git diff` saved.
```bash
cd /home/jason/projects/redmine
git status --short
python3 -m unittest discover -s tests -p 'test_redmine_tickets.py' -v
python3 -m py_compile redmine_tickets.py tests/test_redmine_tickets.py redmine_contacts.py
```
**Step B: First fast-path dry run**
```bash
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir .cache/redmine_tickets_prod_known_spam_$(date +%Y%m%dT%H%M%SZ) \
known-spam-scan \
--profile profiles/known-spam/customer-service.json \
--status-id open \
--max-tickets 2000
```
**Step C: Human review**
Review summary counts and sample candidate rows. If any candidate looks like customer/prospect/operational context, do not apply; add exclusion/profile refinement.
**Step D: Apply**
```bash
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir <reviewed-cache> \
known-spam-close \
--profile profiles/known-spam/customer-service.json \
--close-status-id 5 \
--impersonate-user rebot \
--apply
```
**Step E: Verify**
```bash
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir <reviewed-cache> \
verify-known-spam-close
```
**Step F: Handoff update**
Update:
- `HANDOFF.md`
- `docs/ticket_spam_cleanup_handoff_2026-07-09.md`
- optionally `docs/known_spam_profile_registry.md`
---
## Acceptance Criteria
The implementation is acceptable when:
1. Slow path still works and tests pass.
2. Fast path can dry-run all open production customer-service tickets without writes.
3. Fast path can report exact match profile per candidate.
4. Fast path skips contacts tagged `Customer`.
5. Fast path skips contacts tagged `maybe-customer` by default.
6. Fast path writes only when `--apply` is passed.
7. Fast path writes as `rebot`.
8. Second apply is idempotent.
9. Verification command proves status closed + Harry B. note author.
10. Profile registry is conservative and versioned in the repo.
---
## Short-Term Recommendation for Today
Before building the full fast-profile command, there is still an immediate safe action available:
1. Apply the currently reviewed 27-ticket production close set from:
```text
.cache/redmine_tickets_prod_200_promotional_eval_1783612885
```
2. Verify closed / Harry B. / idempotency.
3. Then implement the fast known-spam profile path above.
This keeps the broom moving while the turbo-broom gets brakes installed. Very advanced broom science.
---
## Legacy Rebot Rule Extraction Status
Completed source inspection of old Rebot spam/routing logic and captured it in:
```text
docs/rebot_legacy_spam_rules.md
profiles/known-spam/customer-service.json
```
Validated profile seed:
```text
JSON_OK
profiles 7
globalindustrial-promos spam-promotional 1 sender regex / 1 subject regex
vistaprint-promos spam-promotional 1 / 1
qnap-newsletters spam-promotional 1 / 1
business-journals-promos spam-promotional 3 / 1
legacy-rebot-promotional-senders spam-promotional 16 / 0
legacy-rebot-operational-clutter automated-operational 4 / 10
legacy-rebot-receipts-routing-reference routing-reference 4 / 1
```
Important split:
- `legacy-rebot-promotional-senders` can be considered for fast close after dry-run review.
- `legacy-rebot-operational-clutter` preserves old Rebot close rules but should remain report-only until Jason explicitly approves operational clutter closing.
- `legacy-rebot-receipts-routing-reference` should not be closed as spam; it is future routing/reference material.
+33
View File
@@ -0,0 +1,33 @@
@everyone I think we should do some tune-ups and checks on the "advanced" Redmine CLI located in ~/projects/redmine/ that is pointed to by ~/projects/redmine/bin/rtl
We kind of had a history with this tool:
- Initially, it kicked ass and was very fast and powerful, but it lacked a nice TUI
- We attempted to build a TUI using curses, and found that had severe limitations on the UI; the functionality was powerful, but the UI/UX sucked ass.
- We tried rebuilding a TUI using more modern libraries and apps as reference points, and somehow totally lost the plot. Not only did we end up with incomplete features agents swore they had completed, we ended up regressing the codebase terribly, to the point even the original CLI features became unreliable, didn't do what the docs/specs said they did, and all kinds of problems. Nobody involved was able to identify a straight path to fixing that.
- We only recently seem to have restored the basic CLI functionality of the tool. From what I understand, the majority of the problem(s) turned out to be:
a) Unreachable code in CLI argument parsing
b) Something to do with order of arguments supplied before or after others would cause silent failures / weird behavior
c) Unverified, but we've seen evidence there is a bug that can leave the CACHE in an unusable state if the script is terminated before it finishes a sync, and the code is seems to be unable to recover from or _recognize_ that problem. On subsequent runs, it will act like there are no problems, it just doesn't give correct output if this happens.
### As far as I know, at this point, the following things works correctly:
- `jason@onyx > ./bin/rtl sync --remote-status-id '*' --remote-updated-since 30d --run-log --run-log-keep 20` run every 5-10 minutes to keep the local cache updated
- the `walk` verb, except it does not have a help menu for what commands it supports (n, p, q, r)
- view verb, but unclear if it displays local cache or fetches live
- `materialize-queues` seems to work
- `contacts-sync`
- Need to make sure this _does not_ delete old contacts if run, for instance, with something like `--nax0contacts 50`
-
### Minor issues
- `draft` verb:
- doesn't have mechanism to _show_ a drafted change
- should have simple way to clear old drafts or something
-
### Definitely not working:
- triage verb
-
@@ -0,0 +1,332 @@
# Features / Roadmap: power-user Redmine client (`redmine_tickets` plane)
> [!todo] Keep this file as the **index**: status, priority, and short summaries.
> Long implementation detail lives in `docs/raw-notes/features/*` and is linked from here.
> Update checkboxes when work lands; do not re-expand full specs into this file.
**Last status refresh:** 2026-07-15 (post browse-loop + attachments + entities + contacts bridge)
---
## Product intent (what this is)
A **scriptable power-user Redmine client** for humans and agents:
- durable local ticket (+ contact) cache as the **ops workbench**
- property-based local search, saved filters, filesystem queues
- CLI / TUI-lite read path (Markdown + `glow`/`bat`/`fzf`) then **queued** writes
- place to invent/test filters, tools, and agent loops without living in the web UI
**Not** the system of record (Redmine is).
**Not** the vault/wiki (curated memory).
**Not** Qdrant (optional later semantic layer).
First project focus: **`customer-service`**.
| Env | Redmine | Cache | Wrapper |
|-----|---------|--------|---------|
| Prod | `https://rm.ldrprep.com` | `/home/jason/projects/redmine/cache/redmine_tickets` | `bin/rtl` |
| Dev / mutation testing | `http://192.168.50.170` | `/home/jason/projects/redmine/cache-dev/redmine_tickets` | `bin/rtd` |
**Safety defaults**
- Writes/applies prefer LAN; prod needs parent `--allow-prod-writes`
- `note` never emails; `customer-email` always emails (ugly confirm flag)
- Browse/actions **queue drafts** by default (apply later)
- Dual tools OK for now: tickets workbench vs full `redmine_contacts.py` CRM CLI, with bridges
---
## Priority stack (current)
| Priority | Area | Status |
|----------|------|--------|
| P0 | Durable incremental ticket sync + remote filters | **Done** |
| P0 | Cron for CS refresh (+ queue rematerialize) | **Running** (6h window) |
| P1 | Property-based local search (`--where`) | **Done** |
| P1 | Markdown view + walk | **Done** |
| P1 | Browse loop (fzf → pager → action menu) | **MVP done** (polish open) |
| P2 | Saved filters + symlink queues | **Done** |
| P2 | Queued notes / customer-email / set-fields | **Done** |
| P2 | Config wrappers (`--config`, `rtl`/`rtd`) | **Minimal done** |
| P3 | Local control tags + suggest + JSONL export | **Done** (LLM refine open) |
| P3 | Contacts mirror + contact-set + ticket-contact | **Done** (cache unify optional) |
| P4 | Attachments list/download/upload | **Done** |
| P4 | Entity extract (patterns) + NMI bridge CLI | **MVP done** |
| Later | Full curses TUI / nested config DSL / master layout | Deferred |
| Adjacent | Semantic index / vault decompose / OCR rename skill | Separate tracks |
---
## Capability map (what you can run today)
| Need | Command(s) |
|------|------------|
| Refresh tickets | `sync` · cron: `cron-scripts/sync-live-customer-service.sh` |
| Search cache | `search --where '…'` · `@saved` · `--control-tag` · `--has-attachments` |
| Read ticket | `view ID` · `walk` · **`browse`** (loop) |
| Internal note | `note` (draft → `draft apply`) |
| Customer email | `customer-email` + confirm flag |
| Fields | `set-fields` (`--status Closed`, etc.) · `meta-refresh` |
| Local labels | `control-tag add|suggest|export|…` |
| Contacts | `contacts-sync` · `contacts-search` · `contact-show` · `contact-set` · `ticket-contact --fetch` |
| Files | `attachments list|download|upload` |
| Tracking/order hints | `entities show|scan` · `entities nmi` · browse `m` |
| OCR scan rename | `ocr-rename --issue-id` / `--where '@scanned-document'` |
| Categories | `categories list [--refresh]` |
| Queues on disk | `materialize-queues --config queues.example.json` |
Cookbook: **`docs/cli_power_user_client.md`**
---
## P0 — Sync foundation
**Detail:** [[features/sync-foundation]]
- [x] Stable cache path (not per-run timestamped dirs)
- [x] Incremental full-fetch skip when `updated_on` unchanged
- [x] Server-side `updated_on` + `status_id` (remote flags)
- [x] Local post-filters retained
- [x] Sync progress + `--quiet` for cron
- [x] Cache never auto-deletes old ticket JSON
- [x] Run-stats ring buffer (`--run-log`)
- [x] Cron: live + dev CS scripts; **6h** remote window; rematerialize queues after sync
- [x] Optional contacts cron: `cron-scripts/sync-live-contacts.sh`
- [ ] Confirm long-term cadence (currently ~every few minutes) is still desired
- [x] Full fetch includes `attachments` metadata (not just journals/helpdesk)
```bash
./bin/rtl sync --remote-status-id '*' --remote-updated-since 2h --run-log --quiet
```
---
## P1 — Local search
**Detail:** [[features/local-search-and-cli-client]] · `redmine_ticket_query.py`
**Historical thin search:** [[../2026-07-14_localsearch_before_sanity]]
- [x] JSON path filters + `includes()` / `since()` / AND/OR / parens
- [x] Nested helpdesk/contact paths
- [x] `--ids-only`, ordering, max caps
- [x] Table columns: assignee, contact, control tags
- [x] `--has-attachments` (cache metadata)
- [x] **No default status filter** — closed tickets appear if cached
- [ ] Optional default “open-ish” profile (if desired later)
---
## P1 — Read path + browse loop
**Detail:** [[features/local-search-and-cli-client]] · `redmine_ticket_view.py` · `redmine_ticket_browse.py`
- [x] Ticket → Markdown (`view`) + MD cache under `markdown/`
- [x] Pagers: glow / bat / batcat / cat
- [x] `walk` (n/p/q)
- [x] **`browse` loop:** fzf (or numbered) → pager → action menu → list
- Actions: view again, queue note/private note, queue fields, control-tag, contact handoff, entities, print id
- Mutations **queue only**
- `--once`, `--pager`, filters same family as search
- [ ] Browse polish (user-noted “minor things”) — collect below under **Open polish**
- [ ] Full curses/TUI (linear-tui style) still **later**, not blocking ops
```bash
./bin/rtl browse --where 'assigned_to IS NULL' --max-tickets 80 --pager glow
```
---
## P2 — Filters, queues, mutations
**Detail:** [[features/local-search-and-cli-client]] · drafts in `redmine_ticket_drafts.py`
- [x] `saved-filters/*.expr` + `@name`
- [x] `materialize-queues` symlink folders
- [x] `note` vs `customer-email` (hard separation; Helpdesk `email_note` path)
- [x] Draft list/cancel/apply; draft binds `base_url`; obvious `draft_id=`
- [x] `set-fields` + name→id (`meta-refresh`)
- [x] Parent `--allow-prod-writes` (not subparser-shadowed)
- [x] Minimal `--config` + `bin/rtl` / `bin/rtd` / `bin/qaa`
- [ ] Nested config defaults (`search.max-tickets` etc.) — [[features/config-files]]
- [ ] Note body from file + optional pandoc→Textile (steering list)
```bash
./bin/rtl note --issue-id 41934 --body 'staff context' --private
./bin/rtl customer-email --issue-id 41934 --body '…' --i-understand-this-emails-the-customer
./bin/rtl --allow-prod-writes draft apply <id>
```
---
## P3 — Control tags
**Detail:** [[features/control-tags]] · export: `redmine_ticket_control_export.py`
- [x] Local wipeable store under `control-tags/tags.json`
- [x] add / remove / list / show / for-issue / wipe
- [x] suggest (`--where` rules, optional `--apply` local)
- [x] `export` → JSONL for training / refine
- [ ] LLM refine loop (consume export, propose tags, human correct/incorrect)
---
## P3 — Contacts
**Detail:** [[features/contacts-sync-integration]] · `redmine_ticket_contacts.py`
**Full CRM still:** `redmine_contacts.py` (separate cache/CLI by design for now)
- [x] Durable mirror: `<cache>/contacts/`
- [x] `contacts-sync` / `contacts-search` / `contact-show`
- [x] `contact-set` draft/apply (tags, company, names, `--set`)
- [x] `ticket-contact` bridge; **`--fetch` implies show+refresh** (bugfix)
- [x] Contact column in ticket search
- [ ] Optional unify on-disk layout with `redmine_contacts.py` (only if dual-cache pain grows)
---
## P4 — Attachments
**Detail:** [[features/attachments]] · `redmine_ticket_attachments.py`
- [x] `attachments list` (cache-first, `--refresh`)
- [x] `attachments download` (one / `--all`)
- [x] `attachments upload --apply` (POST `/uploads.json` + issue `uploads`; no email)
- [x] OCR rename workflow CLI (`ocr-rename`) — [[features/ocr-rename]]
---
## P4 — Entities + NMI
**Detail:** [[features/entity-type-mapper]] · `redmine_ticket_entities.py` · `redmine_ticket_nmi.py`
- [x] Pattern extract: UPS/USPS, order/SKU/account + LDR `item_no`
- [x] Shown in Markdown view
- [x] `entities show|scan`
- [x] `entities nmi` bridge to `mrscClient-php bin/nmi` (cs-brief / resolve)
- [x] Noise filters on medium-confidence `order_id` hits
- [ ] Richer NMI membership index (known order/item windows) if CLI alone isn’t enough
- [x] One-key NMI from browse menu (`m` → private note draft)
---
## Open polish / small bugs (browse + ops)
- [x] Browse: fzf header/keybinding discoverability
- [x] Browse: optional preview window in fzf (`--no-preview` to disable)
- [x] Browse: louder draft queue banner (`announce_draft`)
- [x] Browse: `m` → entities nmi brief → private note draft
- [x] Entity noise filters + LDR 9-digit `item_no`
- [x] Note `--body-file` (already) + `--to-textile` (pandoc)
- [x] `search`/`browse`/`walk` `--open-only`
- [x] OCR attachment → rename (`ocr-rename`) MVP
- [ ] OCR cron pack for HR “Scanned document” inboxes
- [ ] categories create/update (list only for now)
---
## Steering / next decisions (pick a lane)
### A — Daily-driver polish — **done for now**
Reopen if browse UX still nags.
### B — CS frontage / NMI / OCR — **MVP+ done**
Optional: OCR cron for HR; NMI membership index.
### C — CRM / spam tooling **← good next lane**
1. Deeper `contact-set` (custom fields) or keep routing to `redmine_contacts.py`
2. Control-tag LLM refine from JSONL export
3. Issue categories create/update for triage taxonomy
### D — Bigger product bets (defer unless intentional)
- Full curses TUI
- Unified contact cache
- Composable remote/local filter DSL
- Redmine project/version/custom-field admin
---
## Explicitly out of this index (or later)
| Item | Notes |
|------|--------|
| Semantic index v2 | `semantic_index/` — feed from this cache |
| Vault decompose | separate track |
| Redmine admin: versions / custom field *definitions* / project modules | not ticket ops |
| Master global JSON + symlink org | nice-to-have |
---
## Related docs
- `docs/cli_power_user_client.md` — **usage cookbook**
- `docs/ticket_sync_tool_plan.md` — original sync plan
- `docs/2026-07-14_localsearch_before_sanity.md` — historical
- `docs/ticket_spam_cleanup_handoff_2026-07-09.md` — spam consumers
- `docs/raw-notes/features/*.md` — detail files
- `configs/live.json`, `configs/dev.json` · `bin/rtl` · `bin/rtd`
### Feature detail files
| File | Topic |
|------|--------|
| [[features/sync-foundation]] | Sync / cache / remote filters |
| [[features/local-search-and-cli-client]] | Search, view, walk, queues, writes |
| [[features/control-tags]] | Local control tags |
| [[features/contacts-sync-integration]] | Contacts mirror + bridge |
| [[features/attachments]] | File attachments |
| [[features/entity-type-mapper]] | Entities + NMI |
| [[features/nmi-bridge]] | NMI env + browse `m` |
| [[features/ocr-rename]] | OCR subject rename |
| [[features/browse-loop]] | fzf browse loop |
| [[features/config-files]] | Config / aliases |
---
## Maintenance rule
1. Put full detail in `docs/raw-notes/features/<name>.md`
2. Leave status + short bullets + link here
3. Prefer updating the feature file over re-pasting long prose into this index
4. When fixing a bug from **Bug reports**, check the box and leave the minimal repro snippet
---
## Bug reports and QOL (resolved log)
### --allow-prod-writes unrecognized / ignored
- [x] Parent flag only; subparser copies removed (child defaults overwrote parent)
### Draft id not obvious
- [x] `draft_id=` / `apply_hint=` key=value output
### Drafts ignore create-time base-url
- [x] Apply prefers draft `base_url`; conflict needs `--allow-base-url-override`
### Configs / short aliases
- [x] Minimal `--config` + `bin/rtl|rtd|qaa`
### `ticket-contact --fetch` alone did nothing
- [x] `--fetch` implies show + live refresh into contacts cache
---
## Modules (code map)
| Module | Role |
|--------|------|
| `redmine_tickets.py` | CLI entry + sync + routing |
| `redmine_ticket_query.py` | Local AST filters |
| `redmine_ticket_view.py` | Markdown render / pager |
| `redmine_ticket_browse.py` | fzf loop + action menu |
| `redmine_ticket_filters.py` | Saved filters / queues |
| `redmine_ticket_drafts.py` | Draft queue + write safety hosts |
| `redmine_ticket_meta.py` | Name→id meta cache |
| `redmine_ticket_control_tags.py` | Local tags store |
| `redmine_ticket_control_export.py` | Tag JSONL export |
| `redmine_ticket_contacts.py` | Contact mirror |
| `redmine_ticket_attachments.py` | Upload/download helpers |
| `redmine_ticket_entities.py` | Pattern extract |
| `redmine_ticket_nmi.py` | NMI CLI bridge |
| `redmine_ticket_ocr.py` | Attachment OCR + subject suggest |
| `redmine_contacts.py` | Full CRM CLI (sibling tool) |
+28
View File
@@ -0,0 +1,28 @@
# Feature detail: Ticket attachments
**Parent roadmap:** [[../features-for-redmine_tickets-sync]]
**Status:** MVP 2026-07-15
**Module:** `redmine_ticket_attachments.py`
## Commands
```bash
./bin/rtl attachments list --issue-id 39305 --refresh
./bin/rtl attachments download --issue-id 39305 --attachment-id 55917
./bin/rtl attachments download --issue-id 39305 --all
./bin/rtd attachments upload --issue-id 39305 --file ./scan.pdf # dry-run
./bin/rtd attachments upload --issue-id 39305 --file ./scan.pdf --apply --notes 'scan attached'
```
| Op | Emails customer? | Notes |
|----|------------------|--------|
| list | no | cache-first; `--refresh` pulls `include=attachments` |
| download | no | needs API key; default dir `<cache>/<project>/attachments/<issue_id>/` |
| upload | **no** | Redmine `POST /uploads.json` + issue `uploads` token; prod needs `--allow-prod-writes` |
Sync now also requests `attachments` on full issue fetch so metadata lands in cache over time.
## Still open
- OCR rename workflow (see [[../ocr-ticket-attachment-to-rewrite-update-ticket]])
- Draft-queue binary uploads (paths) if desired
+44
View File
@@ -0,0 +1,44 @@
# Feature detail: Interactive browse loop
**Parent roadmap:** [[../features-for-redmine_tickets-sync]]
**Status:** MVP+ polish 2026-07-15
**Module:** `redmine_ticket_browse.py`
## Flow
1. Filtered ticket list via **fzf** (preview pane) or numbered pick
2. **Enter** → Markdown pager (`glow` / `bat` / `batcat` / `cat`)
3. After pager → **action menu**
4. Back to list until Esc/q
## Actions (queue-safe)
| Key | Behavior |
|-----|----------|
| v / Enter | View again |
| n / p | Queue internal / private note |
| f | Queue field update (names) |
| t | Local control-tag |
| c | ticket-contact handoff |
| e | Pattern entities |
| **m** | NMI cs-brief → confirm → **private** note draft |
| i | Print id |
| l / q | List / quit |
## Flags
- `--where`, `--status`, `--control-tag`, `--has-attachments`, **`--open-only`**
- `--max-tickets`, `--order`, `--pager`, `--once`, **`--no-preview`**
## Done polish
- [x] fzf header lists actions
- [x] fzf preview pane
- [x] loud draft banner
- [x] NMI menu action
## Non-goals
- Full curses app (separate spike)
- Customer-email from browse (CLI-only by design)
- Applying prod writes from browse without draft step
+41
View File
@@ -0,0 +1,41 @@
# Config files / short aliases (minimal)
**Parent:** [[../features-for-redmine_tickets-sync]]
**Status:** minimal MVP (global defaults + bin wrappers). Nested per-command defaults deferred.
## Runtime config
```bash
./redmine_tickets.py --config configs/live.json search --max-tickets 10
./redmine_tickets.py --config configs/dev.json note --issue-id 39305 --body 'x' --apply
```
JSON (always works) or YAML if PyYAML is installed.
Supported top-level keys (also under `redmine_tickets:`):
- `base-url` / `base_url`
- `project`
- `cache-dir` / `cache_dir`
- `allow-prod-writes` (bool)
CLI flags already present win over config.
## Short wrappers (`bin/`)
| Command | Maps to |
|---------|---------|
| `bin/rtl` | live/prod CS (`configs/live.json`) |
| `bin/rtd` | LAN/dev CS (`configs/dev.json`) |
| `bin/qaa` | alias → `rtl` (muscle-memory example) |
```bash
./bin/rtl search --where 'assigned_to IS NULL' --max-tickets 5
./bin/rtl --allow-prod-writes draft apply <id>
./bin/rtd note --issue-id 39305 --body 'LAN note' --apply
```
## Not yet
- Nested `search.max-tickets` / command-specific defaults from YAML namespaces
- Auto-discovery of many named profiles beyond live/dev
@@ -0,0 +1,5 @@
## Contact Triage / Fixing
- [ ] Properly link "call transcript" and "voicemail" tickets to the correct person by searching the contact database with the phone number involved
- [ ] Ingest call transcripts into our timeline
- [ ] Fix contact company is set to their email address, when the name of their business is easily found through context in tickets
@@ -0,0 +1,38 @@
# Feature detail: Contacts sync + mutation (tie-in)
**Parent roadmap:** [[../features-for-redmine_tickets-sync]]
**Status:** Sync/search/show + draftable `contact-set` + `ticket-contact` bridge (2026-07-15)
**Module:** `redmine_ticket_contacts.py`
**Still separate from:** full `redmine_contacts.py` CRM CLI (batch/loops/custom-field tooling)
## Why not one tool
- Tickets workbench wants co-located per-id JSON + draft/apply safety next to tickets.
- `redmine_contacts.py` already owns heavy CRM workflows and a different cache layout.
- Do not rewrite/delete the CRM CLI; add bridges so a ticket-started workflow can end on contacts.
## Implemented
```bash
./bin/rtl contacts-sync --max-contacts 100
./bin/rtl contacts-search --query ada
./bin/rtl contact-show --contact-id 14 --fetch
# Bridge ticket → contact id
./bin/rtl ticket-contact --issue-id 41934
./bin/rtl ticket-contact --issue-id 41934 --show --fetch
# CRM mutation (draft by default; same prod/LAN gates as ticket writes)
./bin/rtl contact-set --contact-id 4515 --add-tag maybe-customer
./bin/rtd contact-set --contact-id 2284 --add-tag maybe-customer --apply --refresh-cache
```
- Durable path: `<cache-dir>/contacts/<id>.json` + `manifest.json`
- Draft kind: `contact_fields` (apply via `draft apply`)
- Handoff hints print `redmine_contacts.py show …` for full CRM when needed
## Still open
- Richer custom-field helpers (use `redmine_contacts.py` or extend `--set`)
- Optional cron for contacts next to ticket sync
- Shared library / single on-disk layout if dual-cache friction becomes painful
+31
View File
@@ -0,0 +1,31 @@
# Feature detail: Local control tags (filter-assist labeling)
**Parent roadmap:** [[../features-for-redmine_tickets-sync]]
**Status:** MVP implemented 2026-07-15 (`control-tag` CLI + `search --control-tag`)
**Module:** `redmine_ticket_control_tags.py`
**Critical distinction:** these tags are **local-only**. They are **not** Redmine issue tags, **not** contact CRM tags.
## Storage
`<cache-dir>/control-tags/tags.json` — wipe without touching Redmine or ticket JSON.
## CLI
```bash
./redmine_tickets.py ... control-tag add --tag bavaria --issue-id 41934 \
--property subject --property helpdesk_ticket.contact.company \
--annotation 'BavariaTrachten shape'
./redmine_tickets.py ... control-tag list
./redmine_tickets.py ... control-tag show --tag bavaria
./redmine_tickets.py ... control-tag for-issue --issue-id 41934
./redmine_tickets.py ... control-tag remove --tag bavaria --issue-id 41934
./redmine_tickets.py ... control-tag wipe --i-understand-this-wipes-local-control-tags
./redmine_tickets.py ... search --control-tag bavaria
```
## Still open
- Suggest/refine loop (LLM or rules + human correct/incorrect)
- Training-export format for classifier experiments
@@ -0,0 +1,25 @@
# Feature detail: External entity type mapper / identifier
**Parent roadmap:** [[../features-for-redmine_tickets-sync]]
**Status:** pattern MVP + NMI CLI bridge + browse `m` (2026-07-15)
**Modules:** `redmine_ticket_entities.py`, `redmine_ticket_nmi.py`
**NMI env docs:** [[nmi-bridge]]
## Commands
```bash
./bin/rtl entities show --issue-id 41394
./bin/rtl entities scan --where 'subject.includes("1Z")' --max-tickets 20
./bin/rtl entities nmi --issue-id 41394
```
## Done
- Pattern types: UPS/USPS, FedEx candidates, Amazon order, order_id, item_or_sku, **item_no** (LDR 9-digit)
- Markdown view section: **Detected entities**
- NMI bridge + browse **`m`** → private note draft
## Open
- [ ] Optional membership index of recent NMI order/item ids
- [ ] Postal addresses / phones / social (much later)
@@ -0,0 +1,39 @@
# Feature detail: Local search + CLI Redmine client
**Parent roadmap:** [[../features-for-redmine_tickets-sync]]
**Status:** MVP+ (2026-07-14 … 2026-07-15) — ops-usable
**Cookbook:** `docs/cli_power_user_client.md`
## Modules
| Module | Role |
|--------|------|
| `redmine_ticket_query.py` | Property AST (`--where`) |
| `redmine_ticket_view.py` | Markdown + pager |
| `redmine_ticket_browse.py` | fzf → pager → action menu loop |
| `redmine_ticket_filters.py` | Saved filters + symlink queues |
| `redmine_ticket_drafts.py` | Draft queue + host safety |
| `redmine_ticket_meta.py` | Name→id for set-fields |
| `redmine_ticket_ocr.py` | Attachment text extract + subject suggest |
## Done
- [x] Local search / `@saved` filters / queues
- [x] view / walk (`--open-only` on search/browse/walk)
- [x] note vs customer-email vs set-fields (draft/apply)
- [x] `--body-file` + `--to-textile` (pandoc)
- [x] browse **loop** with action menu (queue-only mutations + NMI `m`)
- [x] configs: `bin/rtl`, `bin/rtd`, `--config`
- [x] `ocr-rename` + `@scanned-document`
- [x] `categories list`
## Open
- [ ] Nested config defaults
- [ ] Full curses TUI (optional later)
- [ ] categories create/update
## Non-goals
- Replacing Redmine web UI for everything
- Silent customer email from browse
+41
View File
@@ -0,0 +1,41 @@
# NMI bridge env + usage
**Parent:** [[entity-type-mapper]] · module `redmine_ticket_nmi.py`
## Environment
| Variable | Default | Purpose |
|----------|---------|---------|
| `NMI_CLI` | `/home/jason/projects/mrscClient-php/bin/nmi` | Path to NMI PHP CLI |
| `NMI_ENV_FILE` | `/home/jason/projects/mrscClient-php/examples/modern/.env` | Env file for credentials/endpoint |
| `NMI_MODE` | `dev` | CLI `--mode` (dev/prod as supported by nmi) |
Requires `php` on PATH.
## CLI
```bash
# Pattern extract only
./bin/rtl entities show --issue-id 41394
./bin/rtl entities scan --type tracking_ups --max-tickets 20
# Resolve / cs-brief via NMI CLI
./bin/rtl entities nmi --issue-id 41394
./bin/rtl entities nmi --issue-id 41394 --nmi-mode dev
./bin/rtl entities nmi --issue-id 41394 --as-private-note-draft # if wired; else use browse `m`
```
## Browse
After opening a ticket in `browse`, press **`m`**:
1. Runs NMI brief from extracted entities
2. Shows brief text
3. Confirms → queues **private** internal note draft
Never emails the customer.
## Safety
- NMI is read-side ops context, not customer mail
- Private note drafts still need `draft apply` (+ prod flags) to land in Redmine
+46
View File
@@ -0,0 +1,46 @@
# Feature detail: OCR attachment → subject rename
**Parent roadmap:** [[../features-for-redmine_tickets-sync]]
**Status:** MVP 2026-07-15
**Module:** `redmine_ticket_ocr.py` · CLI: `ocr-rename`
## Intent
Scanned docs land in Helpdesk as tickets titled “Scanned document…”.
Ops need: open attachment → understand contents → rename subject (+ optional notes).
## Commands
```bash
# One ticket: download OCR-able attachments, propose subject, queue private note (+ subject draft)
./bin/rtl ocr-rename --issue-id 12345
# Scan cache for generic scan subjects
./bin/rtl ocr-rename --where '@scanned-document' --only-generic-subjects --max-tickets 20
# Apply subject now (LAN/prod safety gates); still queues private OCR note by default
./bin/rtd ocr-rename --issue-id 12345 --apply-subject
# Extract only (no drafts)
./bin/rtl ocr-rename --issue-id 12345 --no-queue-subject --no-queue-note --json
```
## Pipeline
1. Load ticket + attachments (`include=attachments` refresh if needed)
2. Download PDF/image candidates (multi-URL fallback)
3. Extract text: `pdftotext` → else `pdftoppm` + `tesseract`
4. Heuristic `suggest_subject_from_text` (doc type, person/company, date)
5. Queue drafts:
- `fields` subject change (unless `--no-queue-subject`)
- private `internal_note` with OCR snippet (unless `--no-queue-note`)
## Saved filter
`saved-filters/scanned-document.expr` → `@scanned-document`
## Still open
- [ ] Stronger LLM subject summarization (optional; keep heuristics default)
- [ ] Cron wrapper that only processes new generic-scan tickets
- [ ] HR-project default project config example
@@ -0,0 +1,3 @@
- [ ] Download voicemails and call recordings from voip.ms automatically
- [ ] helper PHP client library https://git.int.ldrprep.com/dusk/php-voip.ms
@@ -0,0 +1,91 @@
# Feature detail: Ticket sync foundation
**Parent roadmap:** [[../features-for-redmine_tickets-sync]]
**Status:** largely implemented (2026-07-14); cron wiring still open
**Primary script:** `redmine_tickets.py`
**Target project (first):** `customer-service`
## Intent
Durable, incremental local mirror of Redmine issues so humans and agents can work offline-first, cron-refresh frequently, and not re-pull full payloads for unchanged tickets.
## Requirements
- Sync tickets updated within a window (e.g. past 30 days) into a **stable** cache path (not timestamped per run).
- Skip full fetch when cached `updated_on` still matches the list stub.
- Support frequent refresh (every few minutes / couple times per hour) using a tight remote window after the initial warm-up.
- Never delete old ticket JSON unless explicitly asked.
- Optional small run-stats ring buffer for cron observability.
## Cache locations
| Environment | Path |
|-------------|------|
| Production (`https://rm.ldrprep.com`) | `/home/jason/projects/redmine/cache/redmine_tickets` |
| Dev / LAN (`http://192.168.50.170`) | `/home/jason/projects/redmine/cache-dev/redmine_tickets` |
Layout:
```text
<cache-dir>/
manifest.json
run-stats.json # optional, with --run-log
<project>/
<issue-id>.json
```
## Server-side vs local filters (sync)
| Flag | Side | Meaning |
|------|------|---------|
| `--status-id` / `--remote-status-id` | **remote** | Redmine API `status_id` (`*`, `open`, `closed`, numeric) |
| `--remote-updated-on` | **remote** | Raw Redmine syntax: `>=2026-06-14`, `>=2014-01-02T08:12:32Z`, `><a\|b` |
| `--remote-updated-since` | **remote** | Convenience → `>=...` (YYYY-MM-DD, ISO, `30d`, `2 days ago`, …) |
| `--remote-updated-before` | **remote** | Convenience → `<=...`; with since → `><since\|before` |
| `--updated-since` / `--updated-before` / `--status` / `--category` | **local** | Post-filters after API list only |
Implementation notes:
- List stubs intentionally omit heavy `include=journals,helpdesk`; full include is only on per-ticket fetch when `should_fetch` is true.
- Progress on by default; `--quiet` for cron.
- Unit tests: `tests/test_redmine_tickets_remote_filters.py`
- Run log: `--run-log` → default `<cache-dir>/run-stats.json`, keep last N (`--run-log-keep`, default 3).
## Recommended commands
```bash
cd /home/jason/projects/redmine
# Initial 30-day window (server-side)
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir /home/jason/projects/redmine/cache/redmine_tickets \
sync \
--remote-status-id '*' \
--remote-updated-since 30d \
--run-log
# Frequent refresh
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir /home/jason/projects/redmine/cache/redmine_tickets \
sync \
--remote-status-id '*' \
--remote-updated-since 2h \
--run-log \
--quiet
```
Raw Redmine form:
```bash
./redmine_tickets.py ... sync --remote-updated-on '>=2026-06-14' --status-id '*'
```
## Open
- [x] Scripts under `cron-scripts/` installed + crontab running (live + dev)
- [ ] **ADJUST LATER (do not forget):** cron still uses `--remote-updated-since 30d` on a few-minute cadence. Fine while validating (~3–4s/run after warm cache) but should tighten to `2h`/`6h` (or marker-based since-last-success) so list pages stay cheap long-term. `run-log-keep 20` is already set.
- [ ] Optional: master storage + symlink organization of JSON (see parent roadmap)
@@ -0,0 +1,31 @@
I want a skill (or set of skills) that accomplishes the following:
## Context
We have found that using networked printers like HPs to scan various documents have them emailed to inboxes wired up to the Redmine Helpdesk plugin a very useful way to avoid losing paperwork and speed up finding it later. That has been instrumental on several occassions, where we've had the printer in my office configured to email scanned files to hr@ldrprep.com, where they get imported as tickets with the scanned document as a file attachment.
Even with that, to make it truly useful, somebody still has to typically do this:
1. Log into redmine and go to the hr project's issues.
2. Identify scanned document emails (usually the subject like says something like "Scanned document...")
3. Open the attachment to see what it is.
4. Edit the ticket and change the subject line so if we search later on, we'll most likely be able to find the ticket with the attached file.
5. Optionally, transcribe key points or info from the document and add them to the ticket.
## Goal: automate this process
- [ ] **Convert target ticket**: When passed a redmine issue id and asked to peform this action, the following should happen:
- Fetch the ticket and check if it has any PDF files attached to it.
- Download the PDFs and use OCR or similar technology to extract the main topics and entities mentioned within the document(s).
- Update the ticket by changing it's subject line to reflect what the attached documents are. If they're mainly about one particular person or company, the subject line should include those.
- [ ] **Scan project**: When asked, search/scan a specified Redmine project to locate tickets where this hasn't been done yet, and then do it for all of the tickets found.
- [ ] **Cron-usable skill**: Once this is done, I want to have it as a skill I could easily add to an agentic cronjob, what will enable it to scan/check a specified list of Redmine projects whenever it runs, and then automatically perform this action.
## Testing parameters
- The test instance of redmine located at `192.168.50.170` can be used to test functionality. It supports the same authentication info as the live redmine.
- The email aspect of this is not important at all. It would be fine to simulate it as follows:
- Create a new ticket simulating what an ideal target would look like
- Upload a pdf to it
- Then, run the relevant workflow against the test ticket to check results
+154
View File
@@ -0,0 +1,154 @@
# Rebot Legacy Known-Spam Rules
**Source inspected:**
- `/home/jason/projects/rebot/rebot/RebotSlick.php`
- `/home/jason/projects/rebot/rebot/RebotSlick.php.save`
- `/home/jason/projects/rebot/rebot/RebotSlick.php.save.1`
- `/home/jason/projects/rebot/rebot/Classify/Receipts.php`
## Summary
The old Rebot spam cleanup used `RebotSlick::CleanSpam()` which iterated open customer-service issues and called `KnownSpam($issue)`. When `KnownSpam()` returned true, Rebot updated the issue to Closed and set category `Spam`.
Current source behavior:
```php
if ( $this->KnownSpam($issue)) {
$this->client->issue->update($issue['id'], [
"status_id" => $this->statusByName['Closed'],
"category_id" => $this->categoriesByName['Spam']
]);
}
```
The saved older copies also used `KnownSpam()` and closed tickets, sometimes with note `Rebot identified as spam`.
## Exact KnownSpam source logic
### Direct sender equality
```php
if ($from_address == 'noreply@rush-delivery.com') return true;
```
### Amazon package special case
```php
if ($from_address == 'no-reply@amazon.com') {
if (strpos($subject, "Your Amazon package") !== false) return true;
}
```
### Current `badEmails` regex list
```php
"/^.*@indeedemail.com/i"
"/^no-reply@indeed.com/i"
"/^.*@.*roku.com/i"
"/^no.reply@masterclass.com/i"
"/^donotreply@walmart.com/i"
"/^.*@uber.com/i"
"/^no-reply@inhome.walmart.com/i"
"/^.*wingsandrings.com/i"
"/mailnull@amazon.com/i"
"/^.*@tryfinally.co/i"
"/^.*crackerbarrel.com/i"
"/^.*shop.tiktok.com/i"
"/^noreply_samsung@sea.samsung.com/i"
"/^noreply@email.crunchbase.com/i"
"/^reply@globalindustrial.com/i"
"/^.*webstaurantstore.com/i"
"/^info@emailbiz.dukeenergy.com/i"
"/^support@helium10.com/i"
"/^noreply@popmenu.com/i"
"/^info@go.cbts.com/i"
```
### Older saved `badEmails` list
The `.save` and `.save.1` variants had the earlier subset:
```php
"/^.*@indeedemail.com/i"
"/^no-reply@indeed.com/i"
"/^.*@.*roku.com/i"
"/^no.reply@masterclass.com/i"
"/^donotreply@walmart.com/i"
"/^.*@uber.com/i"
"/^no-reply@inhome.walmart.com/i"
"/^.*@wingsandrings.com/i"
"/mailnull@amazon.com/i"
"/^.*@tryfinally.co/i"
"/^.*crackerbarrel.com/i"
"/^.*shop.tiktok.com/i"
```
The current source adds Samsung, Crunchbase, Global Industrial, Webstaurant Store, Duke Energy newsletters, Helium10, Popmenu, and CBTS.
### `badSubjects` regex list
```php
"/Your Amazon.com order.*has shipped/i"
"/^Your Duke energy statement is ready/i"
"/^Your Amazon.com order.*/i"
"/^UPS Billing: Payment Received.*/i"
"/^UPS Billing:.*new invoice.*/i"
"/^.applied on indeed.*/i"
"/^See results for the assessments you sent to*/i"
"/^Reminder: Leave feedback on your interview with.*/i"
"/^Pekin AutoPay Payment.*/i"
"/^Notification of Active Listings Report availability/i"
```
## Important interpretation for modern tooling
The old Rebot called all of the above `spam`, but several of these are better understood today as **operational clutter** rather than promotional spam:
- Amazon order/shipping/package notifications
- UPS billing/payment/invoice notices
- Duke Energy statements/newsletters
- Pekin AutoPay payments
- Indeed applicant/assessment/interview mail
- notification/report availability emails
For the new fast path, preserve the rules but do **not** treat every legacy rule as `spam-promotional`. Recommended split:
1. `legacy-rebot-known-spam-promotional`
- promotional/vendor/newsletter/sales senders
- safe for fast close if contact is not `Customer` / `maybe-customer`
2. `legacy-rebot-operational-clutter`
- Amazon order/shipping, UPS billing, Duke/Pekin billing, Indeed workflow mail
- can be fast-closed only if Jason explicitly approves closing operational clutter as a separate lane
3. `legacy-rebot-receipts-routing`
- from `Classify/Receipts.php`; these were routed to Receipts / Accounting / IT, not generic spam close
## Receipts classifier reference
`rebot/Classify/Receipts.php` used `GenericMail` to classify:
### Email regexes
```php
/culliganbillingoh@culliganwaterco.com/i
/accounting@amshippingsupplies.com/i
/bookkeeperasl@gmail.com/i
/bounces-noreply@mandrillapp.com/i
```
### Subject regexes
```php
/You have a new bill from altafiber/i
```
### Actions
- Default: status `Resolved`, category `Receipts`
- AMShipping invoices: category `Bill Needs Paid`, assigned Accounting
- JoeLevine/bookkeeper: category `Bill Needs Paid`, assigned Accounting
- Mandrill bounces: status `Resolved`, category `IT Issue`, assigned Technical Services
These should not automatically enter the promotional spam fast-close registry, but they are useful future operational-clutter/routing profiles.
@@ -0,0 +1,69 @@
# Redmine Contacts duplicate-merge feasibility (API vs script)
## Scope
Assess whether duplicate contact merge (web flow: `/contacts/<contact_id>/duplicates?project_id=<project_slug>` with `duplicate_id=<target_id>`) can be safely automated from `redmine_contacts.py` or shell tooling.
## Findings from local plugin source
### 1) Merge flow exists as a controller action, not a JSON API endpoint
- Controller: `plugins/redmine_contacts/app/controllers/contacts_duplicates_controller.rb`
- Routes: `plugins/redmine_contacts/config/routes.rb`
- `match 'contacts/:contact_id/duplicates' => 'contacts_duplicates#index', :via => [:get, :post]`
- `match 'contacts_duplicates/:action' => 'contacts_duplicates', :via => [:get, :post]`
`ContactsDuplicatesController` does **not** declare `accept_api_auth` and does **not** implement `format.api` responses.
### 2) Merge behavior is server-side and destructive
Inside `merge` action:
- combines notes, deals, related_deals, issues, projects from source contact into duplicate target,
- unions email/phone/tag sets,
- saves target,
- destroys source contact.
This matches the intended business behavior (source contact removed, linked records moved).
### 3) Web form posts to merge action with `duplicate_id`
- View: `plugins/redmine_contacts/app/views/contacts_duplicates/index.html.erb`
- Uses Rails `form_tag` to `contacts_duplicates#merge` with `project_id`, `contact_id`, and posted `duplicate_id`.
## Practical feasibility
## A) Native Redmine REST API with API key
**Not currently feasible** for this merge operation.
Reason: no API auth route/serializer for duplicate merge in this plugin path.
## B) Scripted browser-equivalent flow (`curl`/requests with session cookies)
**Feasible with caution**.
Required mechanics:
1. Authenticate as a normal Redmine user (session/cookie auth).
2. GET duplicate page for the source contact to obtain valid session + CSRF authenticity token.
3. POST to merge action with:
- `project_id`
- `contact_id` (source)
- `duplicate_id` (target)
- authenticity token
4. Verify redirect/result contact and audit that source contact no longer exists.
## Risk notes
- This is a destructive operation (deletes source contact).
- Needs stronger guardrails than normal update operations:
- explicit `--apply` style confirmation,
- dry-run mode that only validates both contacts and project scope,
- optional `--test-instance-only` safety default,
- write operation log with source/target IDs and timestamp.
## Recommendation for `redmine_contacts.py`
1. **Do not** add duplicate merge via API-key-only path unless plugin gains a dedicated JSON API action.
2. If we implement now, add a **separate command** using session-auth web flow (not mixed into normal contact update path), e.g.:
- `merge-duplicate --source-contact <id> --target-contact <id> --project <slug> [--apply]`
3. Keep it disabled-by-default for production base URL, with explicit override flag.
## Suggested next technical step
Prototype a standalone script against `http://192.168.50.170` that:
- logs in,
- resolves CSRF token,
- performs merge POST,
- verifies resulting contact IDs/issue linkage,
- emits structured JSON result for later CLI integration.
+145
View File
@@ -0,0 +1,145 @@
# Contact search (`redmine_contacts.py`)
`./redmine_contacts.py search --help` prints three short examples. This page is the situation list. Gaps are in [redmine_contacts_search_proposals.md](redmine_contacts_search_proposals.md).
This CLI was not changed to write this page. Commands below match the current `search` / `show` / `helpdesk-issues` behavior.
## What `search` actually does
1. Load the contact cache, or reload it with `--refresh`.
2. Apply exact filters: `--with-tag`, `--without-tag`, `--no-tags`, `--created-after`, `--created-before`, `--updated-after`, `--updated-before`.
3. Fuzzy-score the remaining contacts against the required positional query.
4. Print text lines, capped by `--limit` (default 20).
It does not search tickets. It does not print JSON. A normal search still requires `REDMINE_API_KEY` even when it only reads the cache. `--offline-cache` is the exception.
Default host is the LAN copy, `http://192.168.50.170`. Production:
```bash
cd /home/jason/projects/redmine
./redmine_contacts.py --base-url https://rm.ldrprep.com search "Sahar Daod"
```
Cache file: `.cache/redmine_contacts/<host>_<project>.json`, relative to the current directory. A cache built for one host is rejected for another. This is not `bin/rtl contacts-search`.
A hit is a substring of a scored field after lowercasing, or a fuzzy score of at least `0.68`. Scored fields: name, email, phone, company, job title, background, website, skype, tags, address, and custom-field values. A phone query is also matched on digits only, so `847-313-1342` hits `8473131342`.
Output shape: `id: name | company | email | phone | score=1.00 via email`. Use `show <id>` for the record.
`--refresh` pages through every project contact before scoring. Without it, a tag or field written since the last fetch is invisible.
## Supported
### 1. Find a person by name, email, or phone
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com search "Sahar Daod"
./redmine_contacts.py --base-url https://rm.ldrprep.com search sahardaod4@groovinfulfillment.com
./redmine_contacts.py --base-url https://rm.ldrprep.com search 847-313-1342
```
Then the record, including `tag_list`:
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com show 4589
```
`show` prints JSON. `--source api` skips a stale cache. `--source cache` does not call Redmine.
### 2. Find people at a company, or everyone on one email domain
Company and email are scored fields, not dedicated filters. A domain that appears in the address scores `1.00`.
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com search gatsbylady.com --refresh
./redmine_contacts.py --base-url https://rm.ldrprep.com search "Cyberstorm" --min-score 0.9
```
Raise `--min-score` when a short company name also fuzzy-matches unrelated contacts. There is no `--company` or `--email-domain` flag. An exact company filter is proposal 1.
### 3. Exact tag
`--with-tag` is exact and case-insensitive. The positional query is still required and still fuzzy, so repeat the tag as the query. Otherwise a contact with the tag can be filtered in and then dropped because the name did not fuzzy-match.
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com \
search promotional-spam --with-tag promotional-spam --refresh --limit 50
```
Offline, against the cache already on disk for that host:
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com \
search promotional-spam --with-tag promotional-spam --offline-cache
```
Repeat `--with-tag` to require every tag. Repeat `--without-tag` to exclude any of those tags.
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com \
search customer --with-tag customer --without-tag promotional-spam --limit 20
```
`--no-tags` cannot be combined with `--with-tag`. Listing every untagged contact is not this command. See proposal 2.
### 4. A tag, narrowed by when the contact was created or updated
Date flags are exact. The query is still required, so this finds "this text, inside this window," not "everyone touched this week."
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com \
search customer --with-tag customer --updated-after 2026-09-01 --refresh
```
Dates are `YYYY-MM-DD` or a full ISO datetime. A date-only `--updated-before` includes that whole UTC day. Listing by date with no text query is proposal 2.
### 5. A known account number or other custom-field value
Custom-field values are scored if they are already on the cached contact. Account Number is custom field 70.
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com search 7385Y6 --refresh
```
This finds a value you already know. It cannot list customers whose Account Number is empty. That is proposal 3.
### 6. Their tickets, once you have the contact id
`search` stops at the contact. These follow-ups are live reads, not the contact cache.
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com helpdesk-issues 4589 --limit 20
./redmine_contacts.py --base-url https://rm.ldrprep.com helpdesk-timeline 4589 --limit 20
```
Both print JSON from `/helpdesk_search/contacts/:id/...`.
Cached Customer Service tickets, any status:
```bash
bin/rtl search --order newest-updated-first --max-tickets 20 \
--where 'helpdesk_ticket.contact_id == 4589 AND project.name == "Customer Service"'
```
Open New only:
```bash
bin/rtl search --status New --open-only \
--where 'helpdesk_ticket.contact_id == 4589'
```
`bin/rtl search` cannot filter on `tag_list`. The ticket's contact stub is `id`, `name`, `email`, `company`. This returns nothing and does not error:
```bash
bin/rtl search --where 'helpdesk_ticket.contact.tag_list.includes("promotional-spam")'
```
One command that means "New tickets from contacts tagged promotional-spam" is proposal 4. `bin/rtl contacts-search --tag` is a different cache and can lag a live tag.
## Not a search
`fetch --with-tag customer` writes a cache and prints a count. It does not list contacts.
`batch-update` can select by the same tag and date filters, but it requires an update payload and exists to write. Do not use it as a lister.
`loop-run` is a triage worker, not a search.
+99
View File
@@ -0,0 +1,99 @@
# Contact search proposals
Situations `redmine_contacts.py search` does not cover, as of the current CLI. Do not treat these commands as existing. The working commands are in [redmine_contacts_search.md](redmine_contacts_search.md).
None of these require changing ticket search. They belong on the contacts CLI, with a dry-run default and no write path.
## 1. Exact company or email domain
**Need:** "everyone at gatsbylady.com" or "company is UPS" without fuzzy misses and without a short string scoring unrelated names.
**Today:** company and email are haystack fields. `search gatsbylady.com` works when that string is inside an email. `search UPS` also fuzzy-matches other fields above `0.68`. There is no `--company` or `--domain`.
**Proposal:**
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com \
search --domain gatsbylady.com --refresh
./redmine_contacts.py --base-url https://rm.ldrprep.com \
search --company UPS --company-exact --refresh
```
`--domain` matches the part after `@`, case-insensitive, across `emails[]`. `--company-exact` matches `company` after trim and case-fold, not a substring of the whole record. If either flag is set, the positional query becomes optional. Print the same text line `search` prints now, plus `--json` from proposal 5.
## 2. List by filter, with no fuzzy query
**Need:** every contact with no tags, or every contact updated since a date, or every `customer` tag, without inventing a query that happens to score.
**Today:** `search` always requires `query`, then drops anyone under `--min-score`. `--no-tags` and the date flags only narrow that fuzzy set. `fetch --no-tags` caches matches and prints a count, not rows.
**Proposal:** a `list` subcommand that reuses `filter_contacts` and does not call `search_contacts`.
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com \
list --no-tags --updated-after 2026-09-01 --refresh --limit 50
./redmine_contacts.py --base-url https://rm.ldrprep.com \
list --with-tag promotional-spam --refresh
```
Require at least one filter, same guard as `batch-update`, so `list` cannot dump the whole CRM by accident. Default `--limit 50`. Order by `updated_on` descending. Text lines should include id, name, email, and `tag_list`, because a bare fuzzy score is meaningless here.
Do not overload `search` by making `query` optional. Operators already have to know that `search` is fuzzy.
## 3. Tagged customers with an empty Account Number
**Need:** billed or tagged customers who have no NMI account id in custom field 70 (`Account Number`), so the jumpstart can see who is unmatched.
**Today:** a known account number can be fuzzy-searched if it is already stored. There is no empty-field filter. `show` is one id at a time.
**Proposal:**
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com \
list --with-tag customer --missing-custom-field account-number --refresh
```
Resolve `account-number` with the existing custom-field slug (`slugify_custom_field_name`). Missing means the field is absent, null, or whitespace. Do not treat `"0"` as empty. Read-only. This is the list command from proposal 2 plus one predicate, not a new writer.
## 4. Open tickets from contacts with a tag
**Need:** New Customer Service tickets whose sender is tagged `promotional-spam`, without hand-joining ids.
**Today:** ticket JSON does not store `tag_list`. `bin/rtl search --where 'helpdesk_ticket.contact.tag_list.includes(...)'` matches nothing. The working path is tag search, then `helpdesk_ticket.contact_id == N` per id.
**Proposal:** a read-only join, not a ticket-cache field.
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com \
tagged-issues --with-tag promotional-spam --issue-status New --open-only --refresh
```
Steps:
1. Resolve contact ids with the list filter from proposal 2, live or `--refresh`.
2. For each id, call the existing `helpdesk-issues` endpoint, or scan the ticket cache for `helpdesk_ticket.contact_id`.
3. Keep issues that are New, open, and in `customer-service`.
4. Print `issue_id contact_id email subject`, and stop after `--limit` issues (default 20).
Do not close anything. Do not add `tag_list` to the ticket cache in this change. A stale contacts cache must be called out in the header line (`cache_refreshed=yes|no`). Refuse to run if the contact cache host does not match `--base-url`, which the current cache reader already does.
## 5. Script output: ids and JSON
**Need:** feed contact ids into `bin/rtl` or a later join without scraping `score=1.00 via email`.
**Today:** `search` prints prose. `show` and `helpdesk-issues` print JSON for one id. There is no `--ids-only` on this CLI. `bin/rtl contacts-search --ids-only` is the other cache.
**Proposal:** `--json` and `--ids-only` on `search` and on `list`.
```bash
./redmine_contacts.py --base-url https://rm.ldrprep.com \
search promotional-spam --with-tag promotional-spam --refresh --ids-only
```
`--json` prints one object per line: `id`, `name`, `email`, `tag_list`, and `score` when the command was fuzzy. `--ids-only` prints one integer per line. Neither flag changes which rows match.
## Out of scope
Duplicate detection (same email or phone on two contacts) needs a separate scan and a human merge step. Do not fold it into `search`. The existing duplicate-merge note stays the place for that design.
Do not make `batch-update` printable as a search. It requires a mutation payload on purpose.
@@ -0,0 +1,788 @@
# Ticket Decomposition & Knowledge Ingestion Implementation Plan
**Date:** 2026-07-09
**Project repo:** `/home/jason/projects/redmine`
**Primary vault:** `/home/jason/lilaBuild/vaults/obsidian-ldr`
**Primary Redmine project to start:** `customer-service`
**Production Redmine:** `https://rm.ldrprep.com`
**Onyx vault URL prefix:** `https://onyx.int.ldrprep.com/projects/obsidian-ldr/`
## Goal
Build a ticket decomposition process that converts meaningful Redmine tickets into durable organizational memory: timeline event notes, entity/contact notes, Redmine contact enrichment, cross-links, tags, and gbrain-ingestable vault content.
The immediate wedge is Redmine tickets, especially `customer-service`, but the final interface should support:
```text
decompose <target>
```
where `<target>` may be:
- Redmine issue ID / Redmine project query
- email text
- PDF/document file
- pasted text
- cached ticket JSON
## Scope
This plan is about **knowledge ingestion and decomposition**, not spam closure. It should build on the ticket sync substrate, not replace it.
Existing substrate to reuse:
- `/home/jason/projects/redmine/redmine_tickets.py`
- `/home/jason/projects/redmine/redmine_contacts.py`
- Redmine issue API with `include=journals,helpdesk`
- Obsidian LDR vault
- Onyx browser links
- gbrain source/index tooling
---
## Ticket Buckets Worth Decomposing
A ticket should be eligible for decomposition if it appears to fit one or more of these buckets:
### 1. Definite customer ticket with meaningful event
Customer ticket where:
- a decision was made
- a complaint was involved
- a new project started
- a workflow/process implication was exposed
- an account/service/pricing change happened
Examples:
- customer complaint about missing photos, missing package, wrong condition, wrong item
- pricing/service inquiry that caused a meaningful answer or decision
- customer onboarding/project kickoff
- customer-specific exception or policy decision
### 2. Durable purchase receipt
Receipt about a durable item or business asset worth remembering.
Examples:
- equipment
- tools
- technology/hardware
- warehouse/office asset
- anything likely to matter for warranty, accounting, maintenance, or future context
### 3. Regular utility / recurring service receipt or invoice
Receipt/invoice/payment confirmation about a regular service.
Examples:
- utilities
- insurance
- phone/VoIP
- shipping/billing services
- cloud/SaaS subscriptions
- recurring vendors
This may not need rich event notes every time, but should enrich vendor/entity records and optionally create monthly financial/operations timeline entries.
### 4. Partnership / inbound opportunity / buy-from-us request
Not a customer and not a sales person trying to sell us something, but someone proposing a relationship or wanting to buy something **from us**.
Examples:
- buy Gaylords
- buy liquidation
- advertise/affiliate/blog partnership
- ask about partnership or marketplace cooperation
- use LDR as a source/supplier
These should become opportunity/entity notes, not spam.
### 5. Any meaningful operational event
Catch-all for tickets that refer to a meaningful event:
- policy change
- operational incident
- vendor problem
- staff/customer decision
- process drift or known-failure-method recurrence
- new obligation or future follow-up
---
## Non-Goals / Safety Boundaries
1. Do not ingest every Redmine ticket into the vault as a polished note. Use cache/archive for raw data; decompose only meaningful tickets.
2. Do not overwrite existing entity pages destructively.
3. Do not invent facts. Use evidence blocks and confidence levels.
4. Do not send customer-visible Helpdesk replies.
5. Do not update Redmine contacts until the proposed changes are explicit and reviewable.
6. Do not let spam-cleanup closures trigger decomposition unless the ticket also has meaningful non-spam content.
---
## Vault Information Architecture
### Timeline root
Create a new timeline tree:
```text
/home/jason/lilaBuild/vaults/obsidian-ldr/Timeline/
```
Monthly folders:
```text
Timeline/2026-07/
Timeline/2026-08/
```
Monthly index:
```text
Timeline/2026-07/index.md
```
Event notes:
```text
Timeline/2026-07/2026-07-09_customer-name-short-subject.md
```
Filename rules:
- kebab-case subject slug
- no spaces
- prefix `YYYY-MM-DD_`
- keep short but human-readable
Example:
```text
Timeline/2026-07/2026-07-09_repackify-gaylord-inquiry.md
Timeline/2026-07/2026-07-09_u2wear-returns-provider-followup.md
Timeline/2026-07/2026-07-09_cyberstorm-order-return-confirmation.md
```
### Monthly index format
`Timeline/YYYY-MM/index.md` should contain:
```markdown
# YYYY-MM Timeline Index
## Key events
- [[2026-07-09_repackify-gaylord-inquiry]] — Partnership / buy-from-us inquiry. Redmine #41424.
- [[2026-07-09_u2wear-returns-provider-followup]] — Existing/prospective customer follow-up. Redmine #41448.
## Decisions
- ...
## Complaints / service failures
- ...
## Purchases / receipts
- ...
## Partnerships / opportunities
- ...
```
### Entity notes
Use the existing vault `entities/` tree as the default entity home.
Proposed paths:
```text
entities/people/<person-slug>.md
entities/organizations/<organization-slug>.md
entities/concepts/<concept-slug>.md
entities/places/<place-slug>.md
```
If the repo prefers flat `entities/<slug>.md`, the first implementation can create flat notes and later migrate. But the decomposition code should centralize path decisions in one function, e.g. `entity_note_path(entity)`.
### Entity note frontmatter
Minimum frontmatter for Redmine contacts:
```yaml
---
type: organization
name: RePackify
redmine_contact_id: 4470
redmine_contact_url: https://rm.ldrprep.com/contacts/4470
onyx_url: https://onyx.int.ldrprep.com/projects/obsidian-ldr/entities/organizations/repackify.md
tags:
- redmine-contact
- maybe-customer
- partnership-opportunity
aliases:
- RePackify
---
```
For a person:
```yaml
---
type: person
name: Sarah Johnson
organization: [[repackify]]
redmine_contact_id: 4470
redmine_contact_url: https://rm.ldrprep.com/contacts/4470
onyx_url: https://onyx.int.ldrprep.com/projects/obsidian-ldr/entities/people/sarah-johnson.md
tags:
- redmine-contact
- contact-person
---
```
### Entity body format
```markdown
# Entity Name
## Summary
Brief factual summary with confidence level.
## Redmine links
- Contact: https://rm.ldrprep.com/contacts/4470
- Related tickets:
- https://rm.ldrprep.com/issues/41424
## Timeline
- [[Timeline/2026-07/2026-07-09_repackify-gaylord-inquiry]]
## Known facts
- Fact with source link.
## Open questions
- Question that needs human/agent follow-up.
```
---
## Event Note Format
Each event note should be evidence-first and dialectic.
```markdown
---
type: timeline-event
date: 2026-07-09
source_type: redmine-ticket
redmine_issue_id: 41424
redmine_url: https://rm.ldrprep.com/issues/41424
helpdesk_contact_id: 4470
entities:
- [[repackify]]
tags:
- timeline
- redmine
- partnership-opportunity
- buy-from-us
confidence: medium
---
# 2026-07-09 — RePackify Gaylord Inquiry
## One-line summary
RePackify asked whether LDR has used Gaylord boxes available to purchase.
## Source evidence
- Redmine: https://rm.ldrprep.com/issues/41424
- From: support@repackify.com
- Subject: Gaylord inquiry-RePackify
- Key excerpt:
> We’re interested in purchasing used Gaylord boxes and wanted to see if you have any available.
## Classification
- Bucket: partnership / buy-from-us request
- Customer status: not confirmed customer
- Suggested contact tag: maybe-customer or partnership-opportunity
## Dialectic analysis
### Claim
This is not spam; it is a potential inbound business/opportunity ticket.
### Supporting evidence
- The sender wants to buy something from LDR, not sell LDR a generic service.
- The request references a concrete asset class: used Gaylord boxes.
### Counterpoints / uncertainty
- It may be a one-off opportunistic buyer, not a durable customer relationship.
- Needs human judgment on whether LDR wants to sell Gaylords.
### Decision / outcome
- No final business decision captured yet.
- Needs follow-up if LDR wants a liquidation/Gaylord sale process.
## Entities to update
- [[repackify]]
## Redmine contact enrichment proposal
```json
{
"contact_id": 4470,
"add_tags": ["maybe-customer", "partnership-opportunity", "buy-from-us"],
"obsidian_article_url": "https://onyx.int.ldrprep.com/projects/obsidian-ldr/entities/organizations/repackify.md"
}
```
## Follow-up candidates
- Decide whether LDR sells used Gaylords.
- If yes, define a standard response/process.
```
---
## Dialectic Reasoning Requirements
Each decomposition should include:
1. **Claim** — what seems to be true or meaningful.
2. **Evidence** — exact ticket excerpts, sender, date, Redmine link.
3. **Counterpoints** — why the interpretation might be wrong.
4. **Decision/outcome** — what was decided or what remains undecided.
5. **Recommended graph updates** — timeline note, entity notes, contact updates, tags.
6. **Confidence** — low/medium/high.
This prevents the vault from becoming a pile of confident hallucinated summaries. A small mercy in a cruel world.
---
## Redmine Contact Cross-Linking
### Vault → Redmine
Entity frontmatter should include:
```yaml
redmine_contact_id: 4470
redmine_contact_url: https://rm.ldrprep.com/contacts/4470
```
### Redmine → Vault
The Redmine contact should be updated with an Onyx URL, not an `obsidian://` link.
Preferred URL format:
```text
https://onyx.int.ldrprep.com/projects/obsidian-ldr/<vault-relative-path>.md
```
Example:
```text
https://onyx.int.ldrprep.com/projects/obsidian-ldr/entities/organizations/repackify.md
```
Implementation detail:
- If a Redmine contact custom field exists for `Obsidian Article`, use `redmine_contacts.py --set-custom-field`.
- If no suitable custom field exists, append a clearly delimited line to contact background/notes, e.g.:
```text
Obsidian article: https://onyx.int.ldrprep.com/projects/obsidian-ldr/entities/organizations/repackify.md
```
Do not guess custom field IDs. Discover available custom fields from `get_contact()` payloads first.
---
## `decompose <target>` Architecture
### KarpathyWiki plugin reference
Jason identified the Obsidian `karpathywiki` plugin as a close match for the desired behavior. Inspection notes are saved at:
```text
/home/jason/projects/redmine/docs/karpathywiki_plugin_inspection.md
```
Finding: the plugin does not expose a true standalone CLI. It registers Obsidian commands and its `WikiEngine` depends on Obsidian `App/Vault/TFile/metadataCache` APIs. However, its logic is highly relevant and portable as a design reference:
- source analysis prompt / JSON schema
- entity vs concept decision tree
- alias handling
- duplicate/semantic entity resolution
- entity/concept/source page generation prompts
- contradiction and related-page fields
- graph-based query/retrieval ideas
Recommended implementation path for Redmine decomposition: build a Python headless mirror integrated with `redmine_tickets.py`, using KarpathyWiki's prompts/schema as reference. Consider a TypeScript adapter later only if maintaining a Python mirror becomes too costly.
### Compatibility contract with the Obsidian KarpathyWiki plugin
The LDR vault already contains a plugin-managed wiki tree:
```text
wiki/entities/
wiki/concepts/
wiki/sources/
wiki/index.md
wiki/log.md
```
The headless Redmine decomposer must be a **companion**, not a competing owner of that tree.
Default rules:
1. Do **not** write into `wiki/` by default.
2. Do **not** edit `wiki/index.md` or `wiki/log.md`; those are plugin-owned.
3. Do **not** overwrite or reformat plugin-generated `wiki/entities/*`, `wiki/concepts/*`, or `wiki/sources/*` pages.
4. Write Redmine-derived timeline/event artifacts into LDR-owned paths:
- `Timeline/YYYY-MM/...`
- `entities/organizations/...`
- `entities/people/...`
- `Processes/Redmine/...`
5. Use Obsidian-compatible wikilinks so plugin-generated pages and headless-generated pages can link to each other.
6. If linking to plugin pages, use explicit paths such as `[[wiki/concepts/returns-management|returns management]]` rather than ambiguous bare names.
7. Use plugin-compatible frontmatter concepts where practical (`type`, `created`, `updated`, `tags`, `sources`, `confidence`, `contradictions`) but allow LDR-specific types such as `timeline-event` outside `wiki/`.
8. If we ever want the plugin itself to ingest Redmine-derived material, create a deliberate handoff/export file outside `wiki/` first and let the plugin ingest it through Obsidian; do not silently write into plugin-owned `sources/`.
This keeps Obsidian’s KarpathyWiki plugin useful in the desktop vault while allowing Redmine decomposition to run headlessly/cron/agent-side without fighting plugin state.
Concrete upstream files to mirror first:
- `src/wiki/prompts/ingestion.ts` — extraction prompt / JSON schema
- `src/wiki/prompts/generation.ts` — page generation prompts
- `src/wiki/prompts/merge.ts` — merge/update triage prompts
- `src/wiki/source-analyzer.ts` — source analysis normalization/provenance behavior
- `src/wiki/page-factory.ts` — slug/alias/page merge behavior
The first Python implementation should not attempt full parity. It should clone only the useful minimum:
1. analyze source into JSON entities/concepts/key points/contradictions;
2. resolve entity slugs against existing vault files;
3. generate or update entity/event notes;
4. produce a reviewable contact-update proposal;
5. leave graph retrieval to Obsidian/gbrain for now.
### Input abstraction
Create a common normalized input model:
```python
@dataclass
class DecompositionSource:
source_type: str # redmine-ticket, email, pdf, text, file
source_id: str | None
title: str
body: str
date: str | None
sender: str | None
redmine_issue_id: int | None
redmine_contact_id: int | None
attachments: list[Path]
metadata: dict[str, Any]
```
Adapters:
- `from_redmine_issue(issue_json)`
- `from_email_text(raw_text)`
- `from_pdf(path)`
- `from_plain_text(text)`
- `from_file(path)`
### Decomposition output model
```python
@dataclass
class DecompositionPlan:
source: DecompositionSource
buckets: list[str]
timeline_events: list[TimelineEvent]
entities: list[EntityUpdate]
redmine_contact_updates: list[ContactUpdate]
tags: list[str]
gbrain_actions: list[GBrainAction]
confidence: str
review_required: bool
```
### Apply phases
1. `decompose --dry-run` — produce JSON/Markdown plan only.
2. `decompose --write-vault` — write timeline/entity notes.
3. `decompose --update-redmine-contact` — update Redmine contact with Onyx article link/tags.
4. `decompose --gbrain-sync` — run/queue gbrain sync/index.
Each phase should be separately idempotent.
---
## CLI Proposal
### Decompose a Redmine ticket
```bash
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
decompose \
--issue-id 41424 \
--dry-run
```
### Decompose from cached tickets matching a bucket
```bash
./redmine_tickets.py \
--cache-dir .cache/redmine_tickets_prod_meaningful_20260709 \
--project customer-service \
decompose-cached \
--bucket partnership-opportunity \
--dry-run
```
### Decompose arbitrary text
```bash
./decompose.py text \
--title "Supplier proposal" \
--input /tmp/message.txt \
--dry-run
```
### Write vault notes after review
```bash
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
decompose \
--issue-id 41424 \
--write-vault
```
---
## gbrain Integration
Initial approach should be simple and explicit:
1. Write/update Obsidian notes.
2. Run or queue gbrain sync for the LDR vault source.
3. Verify that the new notes are searchable.
Commands need source discovery first:
```bash
gbrain sources list --json
gbrain stats
```
Then likely:
```bash
gbrain sync --source obsidian-ldr --no-embed --json
gbrain embed --stale
gbrain extract --stale
gbrain search "RePackify Gaylord inquiry"
```
Do not assume the exact source ID is `obsidian-ldr` until verified by `gbrain sources list --json`.
---
## Implementation Phases
### Phase 0 — Immediate schema/docs checkpoint
**Goal:** Preserve this architecture and make future implementation straightforward.
Tasks:
1. Save this plan.
2. Link it from Redmine `HANDOFF.md`.
3. Optionally create an Obsidian process note at:
```text
/home/jason/lilaBuild/vaults/obsidian-ldr/Processes/Redmine/ticket-decomposition-and-knowledge-ingestion.md
```
Definition of done:
- Plan exists.
- Handoff links it.
- Next worker can start implementation without transcript context.
### Phase 1 — Manual pilot decomposition for 3 tickets
**Goal:** Prove note structure and cross-linking manually before automating.
Suggested pilot tickets:
- RePackify Gaylord inquiry (`#41424`)
- u2wear returns-provider follow-up (`#41448`)
- Snuggle Puppy / 3PL & FBA Returns (`#41504`)
Tasks:
1. Fetch each ticket with `include=journals,helpdesk`.
2. Write one timeline event note per ticket.
3. Create/update entity notes.
4. Prepare Redmine contact update payloads, dry-run first.
5. Review Onyx links.
6. Optionally run gbrain sync/search smoke test.
Definition of done:
- 3 timeline notes exist.
- Monthly index links them.
- Entity notes exist or are updated.
- Redmine contact update payloads are reviewed.
- No customer-visible Redmine replies are sent.
### Phase 2 — Implement decomposition dry-run engine
**Goal:** Generate decomposition plans from cached Redmine tickets without writing vault/Redmine.
**Status — 2026-07-10:** First deterministic slice implemented in `redmine_tickets.py decompose`. It supports `--issue-id`, `--input-json`, and `--format json|markdown`. It produces dry-run plans for the three pilot fixtures and respects the KarpathyWiki compatibility boundary by planning writes outside `wiki/`. A KarpathyWiki-style structured extraction layer now cleans Helpdesk/forwarded-message noise and emits `analysis.summary`, `analysis.entities`, `analysis.concepts`, `analysis.key_points`, `analysis.contradictions`, `analysis.related_pages`, and `analysis.clean_text`. Tests pass (`23` tests). Current limitation: extraction is still deterministic/rule-backed rather than LLM-backed; next slice should add prompt-driven JSON extraction for broader ticket types before vault-write mode.
Files:
- Modify: `/home/jason/projects/redmine/redmine_tickets.py`
- Add tests: `/home/jason/projects/redmine/tests/test_ticket_decomposition.py` or extend `test_redmine_tickets.py`
Tasks:
1. Add `DecompositionSource` and `DecompositionPlan` models.
2. Add `source_from_redmine_issue(issue)`.
3. Add bucket classifier:
- customer-decision
- customer-complaint
- customer-new-project
- durable-purchase-receipt
- recurring-utility-receipt
- partnership-buy-from-us
- meaningful-event
4. Add dialectic plan generator.
5. Add JSON/Markdown dry-run output.
6. Add KarpathyWiki-inspired source cleanup and LLM JSON extraction for higher-quality summaries/entities.
Definition of done:
- Unit tests cover each bucket.
- Dry-run output includes source evidence, entities, proposed notes, and proposed contact updates.
- Output quality is comparable to the hand-written pilot notes for the first three fixtures.
### Phase 3 — Implement vault writer
**Goal:** Write idempotent timeline/entity notes.
Tasks:
1. Add path builder for `Timeline/YYYY-MM/YYYY-MM-DD_slug.md`.
2. Add monthly index updater.
3. Add entity path resolver.
4. Add frontmatter merge/update helper.
5. Add idempotency marker using source Redmine issue ID in frontmatter.
Definition of done:
- Re-running same decomposition does not duplicate notes or index bullets.
- Existing entity notes are updated, not overwritten.
### Phase 4 — Implement Redmine contact updater
**Goal:** Update Redmine contacts with Onyx article links and useful tags.
Tasks:
1. Discover contact custom fields.
2. Decide field for Obsidian/Onyx article URL.
3. Add dry-run contact update report.
4. Add `--apply-contact-updates` using `rebot` only when approved.
5. Verify contact after update.
Definition of done:
- Contact has vault article URL.
- Vault article has `redmine_contact_id`.
- Writes are impersonated appropriately.
### Phase 5 — gbrain sync/index integration
**Goal:** Make decomposed notes searchable in gbrain.
Tasks:
1. Discover LDR vault gbrain source ID.
2. Add optional command wrapper or instruction to run sync/embed/extract.
3. Verify search for a newly created entity/event.
Definition of done:
- gbrain search finds the decomposed event/entity.
### Phase 6 — Generalize `decompose <target>`
**Goal:** Support non-ticket inputs with the same decomposition outputs.
Tasks:
1. Add plain-text adapter.
2. Add email adapter.
3. Add PDF adapter using existing OCR/document pipeline if needed.
4. Keep output model the same.
Definition of done:
- Same event/entity/timeline/contact note logic works for non-ticket sources.
---
## Recommended First Execution Step
Do **Phase 1 manual pilot** before writing more automation.
Use the 3 known “meaningful but not spam” tickets from the recent spam-cleanup work:
```text
#41424 RePackify Gaylord inquiry
#41448 u2wear returns-provider follow-up
#41504 Snuggle Puppy / 3PL & FBA Returns
```
That gives us immediate signal on:
- note format quality
- entity matching
- Onyx link usefulness
- Redmine contact update shape
- gbrain ingestion friction
Only after that should we automate the decomposition engine. Otherwise we risk building a very elegant machine for generating annoying notes. We have enough of those already.
@@ -0,0 +1,824 @@
# Ticket-Centric Spam Cleanup Handoff — 2026-07-09
## Purpose
This handoff captures the current state of the new ticket-centric Redmine maintenance substrate and the spam cleanup workflow for the `customer-service` project.
The immediate business goal is to reduce visible spam clutter in `customer-service` without disrupting existing NMI production scripts or doing unsafe bulk Redmine writes.
## Important Safety / Operating Rules
1. **Do not modify NMI scripts right now.** Existing NMI cron scripts are reference material only.
2. **Dry-run first.** Redmine mutations require explicit `--apply`.
3. **Reads do not impersonate.** Ordinary sync/search/dry-run planning should use normal API access.
4. **Writes impersonate `rebot`.** Spam cleanup / ticket maintenance writes should use `X-Redmine-Switch-User: rebot` (Harry Botter), because Redmine activity logs should show automated cleanup under that account, not Jason.
5. **Treat impersonation like sudo.** Use it only where the write is supposed to happen under that agent account.
6. **LAN first.** The validated target so far is the LAN Redmine copy at `http://192.168.50.170`, project `customer-service`.
7. **Confirm status IDs before applying.** Existing evidence suggests `status_id=5` is closed, but verify before first real apply if uncertain.
## Relevant Files
- Tool: `/home/jason/projects/redmine/redmine_tickets.py`
- Tests: `/home/jason/projects/redmine/tests/test_redmine_tickets.py`
- Plan: `/home/jason/projects/redmine/docs/ticket_sync_tool_plan.md`
- General project handoff: `/home/jason/projects/redmine/HANDOFF.md`
- Helpdesk API include docs: `/home/jason/projects/redmine/docs/redmine_issue_api_helpdesk_include.md`
- Existing contact tool: `/home/jason/projects/redmine/redmine_contacts.py`
## Why `redmine_tickets.py` Exists
The earlier `redmine_contacts.py` contact conveyor is useful for contact enrichment and contact-level routing, but it is not the best first tool for clearing visible spam from `customer-service` because the UI pain is ticket-level: the top/open tickets themselves are clutter.
`redmine_tickets.py` is the new ticket-centric substrate. It syncs enriched issue payloads, searches cached tickets, and runs idempotent ticket actions.
## Modern Helpdesk Access
Do **not** recreate RebotSlick `PullHelpdeskInfo` behavior. The old separate helpdesk pull/cache pattern is obsolete because the local Redmine Helpdesk plugin now supports:
```text
GET /issues/:id.json?include=journals,helpdesk
```
This returns normal Redmine issue data plus embedded `helpdesk_ticket` metadata, including contact/email/source fields.
## Current Architecture
### Ticket payload cache
Per-ticket JSON files are stored under a configurable cache directory, e.g.:
```text
.cache/redmine_tickets_lan/customer-service/41788.json
.cache/redmine_tickets_lan/customer-service/41789.json
```
### Ticket/action state
State is currently manifest-backed:
```text
.cache/redmine_tickets_lan/manifest.json
```
But the code now separates payload cache from state storage:
- `TicketCache` handles ticket JSON cache + delegates state operations.
- `ManifestTicketStateStore` is the current state backend.
- The abstraction is intentionally small so SQLite can replace the manifest later for thousands of tickets.
Required state-store methods:
```python
get_ticket_state(issue_id)
upsert_ticket_state(issue_id, fields)
has_action(issue_id, action_name)
record_action(issue_id, action_name, result, metadata=None)
```
## Implemented Commands
### Sync enriched tickets
```bash
cd /home/jason/projects/redmine
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan \
sync \
--status-id open \
--max-tickets 10 \
--order newest-updated-first
```
Behavior:
- fetches issue stubs/pages from Redmine
- fetches full issue payloads with `include=journals,helpdesk`
- writes only missing/updated tickets to cache
- second identical run skips unchanged tickets
Verified output:
```text
Sync complete: seen=10 fetched=10 skipped_cached=0 written=10
Sync complete: seen=10 fetched=0 skipped_cached=10 written=0
```
### Search cached tickets
```bash
./redmine_tickets.py \
--cache-dir .cache/redmine_tickets_lan \
--project customer-service \
search \
--max-tickets 10 \
--order newest-updated-first
```
Validated that this matches the UI top-open-ticket pattern and includes spam-looking tickets such as:
```text
#41788 Up to 20% Off Select Fans & Blowers
#41789 Up to 20% Off Select Fans & Blowers
```
### Spam check cached tickets
```bash
./redmine_tickets.py \
--cache-dir .cache/redmine_tickets_lan \
--project customer-service \
spam-check
```
Validated result:
```text
#41788 SPAM score=2 from=reply@globalindustrial.com Up to 20% Off Select Fans & Blowers
#41789 SPAM score=2 from=reply@globalindustrial.com Up to 20% Off Select Fans & Blowers
```
Record local idempotency only (no Redmine mutation):
```bash
./redmine_tickets.py \
--cache-dir .cache/redmine_tickets_lan \
--project customer-service \
spam-check \
--apply
```
After this, a second `spam-check` checks 0 tickets because `spam-check` action markers are recorded.
### Plan closure for spam tickets
Dry-run:
```bash
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10
```
Validated output:
```text
#41788 close_payload={"notes": "Closed as likely spam by redmine_tickets.py close-if-spam.", "status_id": 5} contact_id=621 contact_spam=4/10 likely_spammer=True Up to 20% Off Select Fans & Blowers
#41789 close_payload={"notes": "Closed as likely spam by redmine_tickets.py close-if-spam.", "status_id": 5} contact_id=621 contact_spam=4/10 likely_spammer=True Up to 20% Off Select Fans & Blowers
Close-if-spam complete: planned=2 apply=False
Dry run only. Re-run with --apply to close Redmine issues and record idempotency markers.
```
No Redmine mutation has been applied yet.
## Actual Closure Command (Pending Approval)
After confirming `status_id=5` is the correct closed status on the LAN copy, this is the command that would close the two planned tickets and record idempotency markers:
```bash
cd /home/jason/projects/redmine
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10 \
--impersonate-user rebot \
--apply
```
Expected write behavior:
- sends `PUT /issues/:id.json`
- payload:
```json
{
"issue": {
"status_id": 5,
"notes": "Closed as likely spam by redmine_tickets.py close-if-spam."
}
}
```
- includes header:
```text
X-Redmine-Switch-User: rebot
```
- records local actions:
- `close-if-spam = closed`
- `contact-spam-review = likely-spammer` or `single-ticket-spam`
## Tests / Verification
Current test command:
```bash
python3 -m unittest discover -s tests -p 'test_redmine_tickets.py' -v
```
Last verified:
```text
Ran 11 tests
OK
```
Compile command:
```bash
python3 -m py_compile redmine_tickets.py tests/test_redmine_tickets.py redmine_contacts.py
```
Last verified: OK.
## 25-Ticket LAN Evaluation — 2026-07-09
Jason confirmed `status_id=5` is Closed. A fresh throwaway cache was used so old action markers would not hide results:
```text
.cache/redmine_tickets_lan_25_eval
```
Commands run:
```bash
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan_25_eval \
sync \
--status-id open \
--max-tickets 25 \
--order newest-updated-first
./redmine_tickets.py \
--cache-dir .cache/redmine_tickets_lan_25_eval \
--project customer-service \
spam-check \
--apply
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan_25_eval \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10
```
Verified output summary:
```text
Sync complete: seen=25 fetched=25 skipped_cached=0 written=25
Spam check complete: checked=25 apply=True
Close-if-spam complete: planned=5 apply=False
SUMMARY cached_tickets=25
SUMMARY spam=5 not_spam=20
```
Closure candidates produced by dry-run:
```text
#41753 Automated unfulfillable FBA inventory removal notification — from fba-noreply@amazon.com — contact_id=51 — contact_spam=10/10 — likely_spammer=True
#41760 Celebrate America's 250th: Save Up To 40% Off — from reply@globalindustrial.com — contact_id=621 — contact_spam=4/10 — likely_spammer=True
#41761 Celebrate America's 250th: Save Up To 40% Off — from reply@globalindustrial.com — contact_id=621 — contact_spam=4/10 — likely_spammer=True
#41767 — — from enews-noreply@qnap.com — contact_id=3162 — contact_spam=10/10 — likely_spammer=True
#41776 Your favorite sale ENDS THURSDAY ⏳ — from noreply@mail.vistaprint.com — contact_id=3795 — contact_spam=10/10 — likely_spammer=True
```
Important finding from first run: the scorer conflated **spam/promotional clutter** with **automated operational notifications**. Example: `#41753` from `fba-noreply@amazon.com` is likely an automated Amazon/FBA operational notification, not normal spam.
No Redmine close mutation was applied during this 25-ticket evaluation.
## Refined Promotional-Junk Evaluation — 2026-07-09
The scorer was refined to target Jason's immediate goal: quickly and reliably close **obvious unsolicited promotional/sales junk**, such as Walmart/Uline/GlobalIndustrial/Vistaprint/QNAP-style promotional mail and lead-gen/sales outreach from people trying to sell LDR something without an established relationship.
False-positive protections added:
- `automated-operational`: Amazon/FBA/order/API/payment-style notifications are excluded from the default auto-close class.
- Customer/vendor operational threads are protected from staff-reply contamination: LDR staff-authored journal notes are no longer scored as spam evidence.
- Standalone `SEO` in customer operational context is not treated as a sales trigger.
Regression tests now cover:
- promotional sale email => `spam-promotional`
- cleaning-service sales pitch => `spam-promotional`
- Amazon/FBA notification => `automated-operational`, not spam
- real FBA returns inquiry with LDR pricing reply => not spam
- customer operational context mentioning SEO => not spam
Fresh final cache:
```text
.cache/redmine_tickets_lan_25_promotional_eval_final_1783600119
```
Final verified summary:
```text
Sync complete: seen=25 fetched=25 skipped_cached=0 written=25
Close-if-spam complete: planned=6 apply=False
SUMMARY categories={'unknown-review': 17, 'automated-operational': 2, 'spam-promotional': 6}
```
Current safe close candidates from the final dry-run:
```text
#41767 — from enews-noreply@qnap.com — QNAP newsletter/promo
#41776 Your favorite sale ENDS THURSDAY ⏳ — from noreply@mail.vistaprint.com
#41788 Up to 20% Off Select Fans & Blowers — from reply@globalindustrial.com
#41789 Up to 20% Off Select Fans & Blowers — from reply@globalindustrial.com
#41804 Do you serve e-commerce brands? — from d.elliott@fulfillmentviewpro.com — 3PL lead-gen/sales pitch
#41834 Japan’s “JC-STAR Level 1” — from enews-noreply@qnap.com — QNAP promotional/newsletter item
```
No Redmine close mutation was applied during this final evaluation.
### LAN apply verification — 2026-07-09
Jason approved moving toward closing the obvious junk. The following command was run against the final 25-ticket cache:
```bash
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan_25_promotional_eval_final_1783600119 \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10 \
--impersonate-user rebot \
--apply
```
Applied closures:
```text
#41767
#41776
#41788
#41789
#41804
#41834
```
Idempotency check:
```text
Close-if-spam complete: planned=0 apply=True
```
API verification after apply:
```text
41767 status=Closed close_note_author=Harry B. close_note_found=True
41776 status=Closed close_note_author=Harry B. close_note_found=True
41788 status=Closed close_note_author=Harry B. close_note_found=True
41789 status=Closed close_note_author=Harry B. close_note_found=True
41804 status=Closed close_note_author=Harry B. close_note_found=True
41834 status=Closed close_note_author=Harry B. close_note_found=True
```
## Current Limitations / Known Next Improvements
1. `manifest.json` is acceptable for the current tracer, but SQLite should be considered once caches/actions cover thousands of tickets.
2. Spam scoring is still intentionally simple and reviewable. It now distinguishes `spam-promotional`, `automated-operational`, and `unknown-review`; future classes can be added as patterns emerge.
3. Contact-level spammer handling is only advisory right now. The tool reports `likely_spammer=True`, but does not yet tag the contact.
4. First real close apply for each newly expanded batch should happen on LAN only, then immediately re-sync/verify status and activity author.
5. The production Redmine path should remain out of scope until LAN write behavior is proven and reviewed.
## 50-Ticket Refined Promotional-Junk Dry Run — 2026-07-09
Fresh cache:
```text
.cache/redmine_tickets_lan_50_promotional_eval_refined_1783600628
```
Verified summary:
```text
Sync complete: seen=50 fetched=50 skipped_cached=0 written=50
Close-if-spam complete: planned=9 apply=False
SUMMARY categories={'unknown-review': 32, 'spam-promotional': 9, 'automated-operational': 9}
```
Promotional close candidates:
```text
#41685 Samsung promo — noreply_samsung@sea.samsung.com — "Jason, we’ve missed you. We just can't stop innovating."
#41687 QNAP promo — enews-noreply@qnap.com — "Qsync Now Supports File Backup on Mac"
#41698 Vistaprint promo — noreply@mail.vistaprint.com — "Life’s best moments, treasured forever"
#41705 Popmenu/restaurant promo — noreply@popmenu.com — "Take a break from cooking!"
#41722 3PL lead-gen/sales pitch — hello@offer.search3pl.com — "Live fulfilment leads waiting for you to connect"
#41738 Vistaprint promo — noreply@mail.vistaprint.com — "Up to 15% off top business products that’ll draw them in"
#41746 Business Journals events/nominations promo — replycincinnati@engaged.bizjournals.com — "Hi LDR, here are our upcoming events and nominations"
#41760 Global Industrial sale — reply@globalindustrial.com — "Celebrate America's 250th: Save Up To 40% Off"
#41761 Global Industrial sale — reply@globalindustrial.com — "Celebrate America's 250th: Save Up To 40% Off"
```
Operational/system notifications excluded from auto-close:
```text
#41694 USPS eReceipt
#41704 2024 UNPAID INVOICES $3,413.55
#41714 Undelivered Mail Returned to Sender
#41716 Critical security alert
#41726 UPS Invoice Now Available
#41753 Automated unfulfillable FBA inventory removal notification
#41765 Amazon Listings API changes
#41772 Cintas Payment Status
#41823 Amazon order confirmation
```
No Redmine mutation was applied during this 50-ticket dry run.
### 50-ticket LAN apply verification — 2026-07-09
Jason approved applying the 9-candidate promotional set. The following command was run against the exact reviewed cache:
```bash
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan_50_promotional_eval_refined_1783600628 \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10 \
--impersonate-user rebot \
--apply
```
Applied closures:
```text
#41685
#41687
#41698
#41705
#41722
#41738
#41746
#41760
#41761
```
Idempotency check:
```text
Close-if-spam complete: planned=0 apply=True
```
API verification after apply:
```text
41685 status=Closed close_note_author=Harry B. close_note_found=True
41687 status=Closed close_note_author=Harry B. close_note_found=True
41698 status=Closed close_note_author=Harry B. close_note_found=True
41705 status=Closed close_note_author=Harry B. close_note_found=True
41722 status=Closed close_note_author=Harry B. close_note_found=True
41738 status=Closed close_note_author=Harry B. close_note_found=True
41746 status=Closed close_note_author=Harry B. close_note_found=True
41760 status=Closed close_note_author=Harry B. close_note_found=True
41761 status=Closed close_note_author=Harry B. close_note_found=True
```
## Recommended Next Session Steps
1. If Jason approves the current 27-candidate production 200-ticket set, apply closures on production using `--impersonate-user rebot` and the exact cache `.cache/redmine_tickets_prod_200_promotional_eval_1783612885`.
2. Optionally apply the 3 `maybe-customer` contact tags from the same cache.
3. Re-sync/inspect via Redmine API/UI and verify closed status, close-note author `Harry B.`, second-apply idempotency, and local action markers.
4. Continue with another bounded production dry-run batch.
## Customer identification update — 2026-07-09
Jason confirmed the following contacts/patterns are customers and should be tagged `Customer`:
```text
koki-takeda@shinkiya-inc.com
hello@bohoflow.co
hi5@bavariatrachten.com
maia@solride.org
mike@solride.org
info@u2wear.com
sales@u2wear.com
any Cyberstorm email
Esire-family emails
```
Important correction: do **not** treat incidental substrings such as `desiree` in Indeed/applicant email aliases as Esire-family customer matches.
Production contact tagging was applied with `impersonate_user='rebot'`:
```text
MATCHED_COUNT 31
UPDATED_COUNT 25
SKIPPED_ALREADY_CUSTOMER 6
```
Two accidental applicant matches were corrected immediately:
```text
4253 conversation-desireethrasher-customerreturnsinspect-0n3@indeedemail.com: removed Customer, left applicant
3934 conversation-desireethrasher-customerreturnsinspect-9nv@indeedemail.com: removed Customer, left applicant
```
After customer tagging, re-running the production 200 close plan still produced 27 candidates and kept `#41238` removed from the plan.
## Customer-tag safety gate — 2026-07-09
New hard rule: before closing any ticket as spam, `redmine_tickets.py` fetches the Helpdesk contact and checks `tag_list`. If the contact has tag `customer`, the ticket is **not** closed as spam. Absence of the `customer` tag does **not** imply spam.
Jason also clarified the closure risk model:
- Closed tickets are not deleted.
- If the other party replies to a closed Helpdesk ticket, Redmine reopens it as `New Message`.
- Therefore, stale 30+ day tickets that match a verified `spam-promotional` profile are reasonable fast-close candidates once contact safety gates pass.
- However, if a ticket was previously closed and then reopened because the contact messaged again, do **not** auto-close it again. Escalate instead: bump priority and optionally set a due date 1–3 days out.
A separate action now exists to tag contacts needing manual/customer review:
```bash
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir <cache> \
tag-maybe-customer [--apply]
```
This adds `maybe-customer` to contacts with inbound/established-business inquiry evidence, using `--impersonate-user rebot` for writes.
Tests now cover 20 cases, including the customer-tag skip and `maybe-customer` tag action.
## Production 200-Ticket Dry Run With Customer Gate — 2026-07-09
Production cache:
```text
.cache/redmine_tickets_prod_200_promotional_eval_1783612885
```
Re-running the close plan after adding the contact `customer` safety gate produced:
```text
Close-if-spam complete: planned=27 apply=False
```
The customer-tag gate removed `#41238` from the close plan, which was the suspicious `manager@sonisha.com` / `Account Queries` candidate.
Current 27 close candidates:
```text
#41098
#41114
#41121
#41122
#41127
#41128
#41153
#41164
#41167
#41168
#41201
#41222
#41226
#41227
#41242
#41248
#41269
#41293
#41297
#41315
#41331
#41340
#41341
#41347
#41368
#41373
#41850
```
Maybe-customer dry-run produced 3 contact tag candidates:
```text
contact_id=4470 source_issue_id=41424 tag_list=["maybe-customer"]
contact_id=3919 source_issue_id=41448 tag_list=["maybe-customer"]
contact_id=4476 source_issue_id=41504 tag_list=["maybe-customer"]
```
No production mutation was applied after adding this guard.
## Production 100-Ticket Promotional-Junk Dry Run — 2026-07-09
Production URL:
```text
https://rm.ldrprep.com
```
Fresh cache:
```text
.cache/redmine_tickets_prod_100_promotional_eval_1783609390
```
Verified summary:
```text
Sync complete: seen=100 fetched=100 skipped_cached=0 written=100
Close-if-spam complete: planned=20 apply=False
SUMMARY categories={'unknown-review': 59, 'automated-operational': 21, 'spam-promotional': 20}
```
Promotional close candidates:
```text
#41397 Business Journals events/nominations promo — replycincinnati@engaged.bizjournals.com
#41430 Vistaprint sale — noreply@mail.vistaprint.com
#41431 Business Summit promo — replycincinnati@partner.bizjournals.com
#41657 Global Industrial Kee Safety promo — reply@globalindustrial.com
#41658 Global Industrial Kee Safety promo — reply@globalindustrial.com
#41685 Samsung promo — noreply_samsung@sea.samsung.com
#41687 QNAP promo — enews-noreply@qnap.com
#41698 Vistaprint promo — noreply@mail.vistaprint.com
#41705 Popmenu restaurant promo — noreply@popmenu.com
#41722 3PL lead-gen sales pitch — hello@offer.search3pl.com
#41738 Vistaprint promo — noreply@mail.vistaprint.com
#41746 Business Journals events/nominations promo — replycincinnati@engaged.bizjournals.com
#41760 Global Industrial sale — reply@globalindustrial.com
#41761 Global Industrial sale — reply@globalindustrial.com
#41767 QNAP newsletter/promo — enews-noreply@qnap.com
#41776 Vistaprint sale — noreply@mail.vistaprint.com
#41788 Global Industrial sale — reply@globalindustrial.com
#41789 Global Industrial sale — reply@globalindustrial.com
#41804 3PL lead-gen sales pitch — d.elliott@fulfillmentviewpro.com
#41834 QNAP promotional/newsletter item — enews-noreply@qnap.com
```
No production mutation was applied during this dry run.
### Production apply verification — 2026-07-09
Jason approved applying the 20-candidate promotional set. The following command was run against the exact reviewed production cache:
```bash
./redmine_tickets.py \
--base-url https://rm.ldrprep.com \
--project customer-service \
--cache-dir .cache/redmine_tickets_prod_100_promotional_eval_1783609390 \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10 \
--impersonate-user rebot \
--apply
```
Applied closures:
```text
#41397
#41430
#41431
#41657
#41658
#41685
#41687
#41698
#41705
#41722
#41738
#41746
#41760
#41761
#41767
#41776
#41788
#41789
#41804
#41834
```
Idempotency check:
```text
Close-if-spam complete: planned=0 apply=True
```
API verification after apply:
```text
41397 status=Closed close_note_author=Harry B. close_note_found=True
41430 status=Closed close_note_author=Harry B. close_note_found=True
41431 status=Closed close_note_author=Harry B. close_note_found=True
41657 status=Closed close_note_author=Harry B. close_note_found=True
41658 status=Closed close_note_author=Harry B. close_note_found=True
41685 status=Closed close_note_author=Harry B. close_note_found=True
41687 status=Closed close_note_author=Harry B. close_note_found=True
41698 status=Closed close_note_author=Harry B. close_note_found=True
41705 status=Closed close_note_author=Harry B. close_note_found=True
41722 status=Closed close_note_author=Harry B. close_note_found=True
41738 status=Closed close_note_author=Harry B. close_note_found=True
41746 status=Closed close_note_author=Harry B. close_note_found=True
41760 status=Closed close_note_author=Harry B. close_note_found=True
41761 status=Closed close_note_author=Harry B. close_note_found=True
41767 status=Closed close_note_author=Harry B. close_note_found=True
41776 status=Closed close_note_author=Harry B. close_note_found=True
41788 status=Closed close_note_author=Harry B. close_note_found=True
41789 status=Closed close_note_author=Harry B. close_note_found=True
41804 status=Closed close_note_author=Harry B. close_note_found=True
41834 status=Closed close_note_author=Harry B. close_note_found=True
```
## 100-Ticket Refined Promotional-Junk Dry Run — 2026-07-09
Fresh cache:
```text
.cache/redmine_tickets_lan_100_promotional_eval_refined_1783601115
```
Verified summary:
```text
Sync complete: seen=100 fetched=100 skipped_cached=0 written=100
Close-if-spam complete: planned=9 apply=False
SUMMARY categories={'unknown-review': 68, 'spam-promotional': 9, 'automated-operational': 23}
```
Promotional close candidates:
```text
#41227 Global Industrial clearance — reply@globalindustrial.com — "Clearance: Bestsellers At Best Prices"
#41297 Vistaprint sale — noreply@mail.vistaprint.com — "Up to 30% off select customer faves, like mugs, postcards & more"
#41340 Global Industrial AC/fans — reply@globalindustrial.com — "Advanced AC, Wall Fans, And Cooling Units"
#41373 TikTok Shop promo — taylor@shop.tiktok.com — "The simplest way to start selling this June"
#41397 Business Journals events/nominations promo — replycincinnati@engaged.bizjournals.com — "Hi LDR, here are our upcoming events and nominations"
#41430 Vistaprint sale — noreply@mail.vistaprint.com — "Email exclusive! Up to 40% off select top products ENDS TOMORROW"
#41431 Business Summit promo — replycincinnati@partner.bizjournals.com — "Regional Business Summit Returns August 5"
#41657 Global Industrial Kee Safety promo — reply@globalindustrial.com — "'Kee Safety' Modular & Durable Fall Prevention Equipment"
#41658 Global Industrial Kee Safety promo — reply@globalindustrial.com — "'Kee Safety' Modular & Durable Fall Prevention Equipment"
```
False-positive protections added/verified during this 100-ticket run:
- VoIP voicemail / call transcription tickets are `automated-operational`, not promotional spam.
- Inbound buying/relationship inquiries like RePackify Gaylord purchase interest and u2wear returns-provider follow-up remain `unknown-review`, not auto-close.
- Receipts, bounces, security alerts, invoices, Amazon/FBA/order notices remain `automated-operational`, not auto-close.
No Redmine mutation was applied during this 100-ticket dry run.
### 100-ticket LAN apply verification — 2026-07-09
Jason approved applying the 9-candidate promotional set. The following command was run against the exact reviewed cache:
```bash
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
--cache-dir .cache/redmine_tickets_lan_100_promotional_eval_refined_1783601115 \
close-if-spam \
--close-status-id 5 \
--contact-ticket-limit 10 \
--impersonate-user rebot \
--apply
```
Applied closures:
```text
#41227
#41297
#41340
#41373
#41397
#41430
#41431
#41657
#41658
```
Idempotency check:
```text
Close-if-spam complete: planned=0 apply=True
```
API verification after apply:
```text
41227 status=Closed close_note_author=Harry B. close_note_found=True
41297 status=Closed close_note_author=Harry B. close_note_found=True
41340 status=Closed close_note_author=Harry B. close_note_found=True
41373 status=Closed close_note_author=Harry B. close_note_found=True
41397 status=Closed close_note_author=Harry B. close_note_found=True
41430 status=Closed close_note_author=Harry B. close_note_found=True
41431 status=Closed close_note_author=Harry B. close_note_found=True
41657 status=Closed close_note_author=Harry B. close_note_found=True
41658 status=Closed close_note_author=Harry B. close_note_found=True
```
+191
View File
@@ -0,0 +1,191 @@
# Ticket Sync Tool — Architecture & Implementation Plan
**Status**: Phase 1–3 tracer implemented and LAN-smoke-tested (2026-07-09)
**Goal**: Build a reliable, incremental ticket sync + query foundation (`redmine_tickets.py`) that replaces the obsolete RebotSlick `PullHelpdeskInfo` pattern with the modern `include=helpdesk` capability. This becomes the core data layer for all future agentic ticket workflows (spam cleanup, enrichment, dropped-ball detection, etc.).
**Primary target**: `customer-service` project (currently >1700 tickets). Tool must work for any project.
**Key constraints**
- Do **not** modify any NMI components.
- Reuse existing `RedmineClient` and helpdesk methods from `redmine_contacts.py`.
- Leverage the `include=helpdesk` (and `include=journals,helpdesk`) patch already deployed.
- Cache must be idempotent and cheap to re-run.
- Tool must support “do nothing but sync what’s new” when run with no action.
---
## 1. Core Requirements (from user spec)
1. **Incremental sync**
- Recursively fetch tickets with optional criteria (`--updated-since`, `--category`, `--status`, `--project`, `--with-tag`, etc.).
- Store full enriched payload (issue + journals + helpdesk_ticket + messages) as local JSON.
- Only re-fetch when the ticket’s `updated_on` is newer than the cached file mtime (or use a small manifest).
2. **No-op default behavior**
- Running with no filters/action does a safe, incremental sync of everything (or a sensible default window).
3. **Filter/search mode**
- When filters are supplied but no action is chosen, behave as a powerful search/report tool (list matching tickets, optionally write JSONL).
4. **Idempotent action layer (future)**
- Higher-level workers (spam-check, close, enrich, etc.) must be able to mark a ticket as “already processed” so repeated runs skip it.
- Suggested mechanism: sidecar manifest or lightweight SQLite table (`ticket_id`, `action_name`, `last_run`, `result`).
5. **Helpdesk enrichment**
- Always request `include=journals,helpdesk` (or configurable) so every cached ticket already contains the rich contact/source metadata.
---
## 2. Proposed Architecture
### 2.1 Tool location & reuse
- New script: `/home/jason/projects/redmine/redmine_tickets.py`
- Reuses the existing `RedmineClient` class (especially `issue_with_helpdesk`, `get_issue`, list methods).
- Same argument patterns, dry-run safety, `--apply`, error handling, and completion scripts as `redmine_contacts.py`.
### 2.2 Cache layout (local filesystem)
Recommended structure (under a configurable `--cache-dir`, default `/home/jason/projects/redmine/cache/tickets/` or `/tmp/redmine-tickets-cache/`):
```
cache/tickets/
├── manifest.json # { "ticket_id": { "updated_on": "...", "last_synced": "...", "actions": {...} } }
├── customer-service/
│ ├── 12345.json # full enriched payload
│ ├── 12346.json
│ └── ...
└── other-project/
└── ...
```
- One JSON file per ticket (stable, human-inspectable, easy to diff).
- `manifest.json` tracks `updated_on` + per-action idempotency state.
- Simple mtime + `updated_on` comparison is sufficient for v1; manifest makes it robust.
- Implementation note: ticket payload storage and ticket/action state storage are separated in code. `ManifestTicketStateStore` is the current implementation, but `TicketCache` only depends on a small state-store method set (`get_ticket_state`, `upsert_ticket_state`, `has_action`, `record_action`). If the manifest becomes cumbersome with thousands of tickets, replace it with SQLite or another backend without rewriting sync/search/action logic.
### 2.3 CLI structure (v1 scope)
```bash
./redmine_tickets.py \
--base-url http://192.168.50.170 \
--project customer-service \
[sync|search] \
[--updated-since YYYY-MM-DD] \
[--updated-before YYYY-MM-DD] \
[--status open|closed|*] \
[--category "Spam"] \
[--max-tickets N] \
[--order newest-updated-first] \
[--cache-dir ...] \
[--dry-run] [--apply] \
[--json] [--jsonl]
```
**Commands**
- `sync` (default if omitted): Perform incremental fetch + cache update.
- `search`: Same selection logic, but only output matching tickets (no cache write unless `--write-cache`).
**Safety**
- Dry-run by default for any write.
- `--apply` required to persist cache or perform mutations.
### 2.4 Idempotency & action hooks (v1 + v2)
- Manifest stores per-ticket action state:
```json
"actions": {
"spam-check": { "last_run": "2026-07-09", "result": "not-spam" },
"close-if-spam": { "last_run": "...", "result": "skipped" }
}
```
- Future workers read the manifest before acting and update it after.
---
## 3. Implementation Phases
### Phase 1 — Core sync + cache (implemented tracer)
- Implement `RedmineClient` enhancements if needed (list issues with `include=journals,helpdesk`, pagination).
- Basic `sync` command that:
- Accepts date/status/project filters.
- Fetches pages of issues.
- For each issue, writes `cache/<project>/<id>.json` only if newer than manifest entry.
- Updates manifest.
- `--max-tickets` safety limit.
- Dry-run shows what would be fetched/updated.
**Verified 2026-07-09**: `redmine_tickets.py sync` pulled 10 open `customer-service` tickets from LAN Redmine (`192.168.50.170`), wrote enriched per-ticket JSON, and a second identical run fetched 0 / skipped 10 unchanged cached tickets.
### Phase 2 — Search / filter-only mode (implemented tracer)
- `search` subcommand that reuses the same selection engine.
- Output options: human table, `--json`, `--jsonl` (for downstream agents).
- Ability to pipe results into later tools.
**Verified 2026-07-09**: cached search over the 10-ticket LAN cache listed the same newest open tickets seen in the UI, including promo candidates like `#41788` and `#41789`.
### Phase 3 — Idempotency manifest + action skeleton (implemented tracer)
- Add manifest read/write helpers.
- Skeleton `--action` interface (initially just records “action X run on ticket Y”).
- Example action: `spam-check` (calls the logic we started in `spam_promo_scorer.py` but at ticket level).
**Verified 2026-07-09**: `spam-check` identified `#41788` and `#41789` as SPAM based on promotional subject language, recorded local manifest idempotency with `--apply`, and a second `spam-check` run checked 0 tickets. `close-if-spam` dry-run planned closures for those two tickets and reviewed sender `contact_id=621`, finding 4/10 recent tickets scored as spam (`likely_spammer=True`). No Redmine close mutation has been applied yet.
### Phase 4 — Spam cleanup worker (next)
- Concrete `spam-check` + `close-if-spam` actions.
- Rules based on sender patterns, subject language, lack of human replies, existing contact tags, etc.
- Produces a report + optional `--apply` to close tickets and add `spam` tag to contact.
Preferred spam policy:
1. When a ticket is high-confidence spam, close that ticket.
2. Use `helpdesk_ticket.contact.id` to fetch/analyze the last 5–10 tickets from the same contact.
3. Decide whether the sender/contact appears to be a recurring spammer or whether only the specific ticket/email is spam.
4. Only mark/tag the contact as a spammer when the contact-level lookback supports that conclusion.
5. Record both `close-if-spam` and `contact-spam-review` idempotency markers so repeated runs do not re-close/re-review the same work.
6. Treat Redmine impersonation like `sudo`: do not use it for ordinary reads or dry-run planning, but all spam-cleanup write calls should impersonate `rebot` / “Harry Botter” so Redmine activity logs clearly attribute automated cleanup work.
---
## 4. Non-Goals (for this phase)
- Do not replace or modify any NMI cron scripts.
- Do not build full agent orchestration yet (this is the data layer).
- Do not implement complex NLP classifiers in v1 (keep scoring simple and reviewable).
- Cache does not need to be a database (filesystem + manifest is sufficient and inspectable).
---
## 5. Success Criteria
- Running the tool twice in a row with the same filters produces zero API calls on the second run (everything is cached and up-to-date).
- A filter-only search for “tickets updated in last 30 days with no human reply” returns results in < 5 seconds after initial sync.
- The same tool can be pointed at any project and produce usable enriched ticket JSON.
- Higher-level workers can be written as thin Python scripts that consume the cache/manifest without needing their own Redmine client logic.
---
## 6. Next Immediate Action
The implementation tracer is complete through dry-run `close-if-spam`.
Next safe execution sequence:
1. Confirm LAN issue `status_id=5` is the intended closed status.
2. Run `close-if-spam --apply` against the LAN copy only, impersonating `rebot` (the CLI default).
3. Re-sync or inspect the affected LAN tickets.
4. Verify:
- `#41788` and `#41789` are closed,
- Redmine activity author is `rebot` / Harry Botter,
- local state records `close-if-spam` and `contact-spam-review`,
- a second `close-if-spam --apply` does nothing.
5. Only then expand the batch size beyond the initial 10-ticket cache.
Detailed handoff: `docs/ticket_spam_cleanup_handoff_2026-07-09.md`.
---
**References**
- `docs/redmine_issue_api_helpdesk_include.md` (the key enabler)
- `redmine_contacts.py` (client patterns, filtering, dry-run UX)
- `docs/contact_triage_loop_*` (overall workflow philosophy)
- RebotSlick `RebotSlick.php` (historical cache idea — modernized)
This plan deliberately keeps scope tight while giving us a clean, extensible foundation for the spam-reduction work that is currently blocking the customer-service UI.
+11
View File
@@ -0,0 +1,11 @@
![[message (1).eml]]## Helpdesk ticket rendering notes
**Ticket 41753** is used here as an example.
![rendered helpdesk ticket 41753 with circled areas for annotation](./rendered-helpdesk-ticket-41753.png)
## Ticket https://rm.ldrprep.com/issues/43025
The resulting subject is just "Let's", and it seems like this is caused by UTF-8 characters. The actual email text is as follows:
```text
Subject: =?UTF-8?B?TGV04oCZcw==?= update your password
```
@@ -0,0 +1,948 @@
Delivered-To: contact@ldrprep.com
Received: by 2002:a05:6918:5408:b0:476:29de:ede8 with SMTP id s8csp5332841ysl;
Mon, 6 Jul 2026 09:49:14 -0700 (PDT)
X-Received: by 2002:a05:622a:4015:b0:51b:feee:b08f with SMTP id d75a77b69052e-51c73f516b5mr19056341cf.1.1783356554502;
Mon, 06 Jul 2026 09:49:14 -0700 (PDT)
ARC-Seal: i=1; a=rsa-sha256; t=1783356554; cv=none;
d=google.com; s=arc-20260327;
b=jXuQyGgK9DIPjBYAbzAJ20wdcyzgO5pw27qvr4DQZcBYquyPpTbDvGYJNhRRYZeuce
vq4r5c/lCmDLRVaYF23nVaq+mcj9xttCKoWMphIJB2nW9EwsDSiPBKRCNBkeOGxxoG6F
uFFkrLi952oy+B7oc/pW1PeXINIoMqa4+6Ggp0aTfrCj6rYVopN8AmpR1KF/gsKxoDy6
G5q4nbmOH/QD9yNTHDgCRQKRbzFg8Qh1cXZJa0UQATPL+C91e/BbQyK4/TK4AGJrbvwx
9AxR1asvqr6TLk2WkyWEbDa7sr38cK8YDBtEyL3U/4kJcJJ4kIQ25snOwPRE0T9tZv65
LW1g==
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20260327;
h=feedback-id:bounces-to:mime-version:subject:message-id:to:reply-to
:from:date:dkim-signature:dkim-signature;
bh=R3n7y8rm5NjqL3jRteMIf7Tu+z0CzAY1J5Yk3jgKsq8=;
fh=EfWMslCv/1V8JdxM9Auar9N+FfH8Z6xRox18ZgotYNQ=;
b=gTQr3GOnpaPlTobB0OGtJ0D9XzXAIZOw6fckc5GbfhAx0YEUPO+WkAQA7Ikxh22emG
weiP4UEax/mOo0n+RryW3tH6lItdnKadqTYPhORRc++dHSeVpE32C/cPjuNgXnOwCtI5
mb+9qJr/pTzyr0kBJTSvB8xw4KaD5zpktbnJ7FMml9wySGxhhNPs3brN5MW9oWgHuiTk
xPooIa/Dt3iNOBtmLlRNEyTtcU2OAnZ6AsafDvkkY79S/Oxv+rZP8h+DdKp8xbtEtFy9
sLaNrAwyK47pT17yFm3f+AGOk4fG7KZNuJQqxj8mid3nwsN5GW9He7xoQUYDOxUo0ECX
XRUA==;
dara=google.com
ARC-Authentication-Results: i=1; mx.google.com;
dkim=pass header.i=@amazon.com header.s=pwdll6lpa66mxupg2aa3uosx2zizclvc header.b="06YN//A6";
dkim=pass header.i=@amazonses.com header.s=224i4yxa5dv7c2xz3womw6peuasteono header.b=D4Muravu;
spf=pass (google.com: domain of 0100019f38557b1a-ce103552-25f4-4d40-a76a-d8f8cf98fa1b-000000@amazonses.com designates 76.223.143.195 as permitted sender) smtp.mailfrom=0100019f38557b1a-ce103552-25f4-4d40-a76a-d8f8cf98fa1b-000000@amazonses.com;
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=amazon.com
Return-Path: <0100019f38557b1a-ce103552-25f4-4d40-a76a-d8f8cf98fa1b-000000@amazonses.com>
Received: from c143-195.smtp-out.amazonses.com (c143-195.smtp-out.amazonses.com. [76.223.143.195])
by mx.google.com with ESMTPS id d75a77b69052e-51c41b26f47si99447201cf.88.2026.07.06.09.49.14
for <contact@ldrprep.com>
(version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128);
Mon, 06 Jul 2026 09:49:14 -0700 (PDT)
Received-SPF: pass (google.com: domain of 0100019f38557b1a-ce103552-25f4-4d40-a76a-d8f8cf98fa1b-000000@amazonses.com designates 76.223.143.195 as permitted sender) client-ip=76.223.143.195;
Authentication-Results: mx.google.com;
dkim=pass header.i=@amazon.com header.s=pwdll6lpa66mxupg2aa3uosx2zizclvc header.b="06YN//A6";
dkim=pass header.i=@amazonses.com header.s=224i4yxa5dv7c2xz3womw6peuasteono header.b=D4Muravu;
spf=pass (google.com: domain of 0100019f38557b1a-ce103552-25f4-4d40-a76a-d8f8cf98fa1b-000000@amazonses.com designates 76.223.143.195 as permitted sender) smtp.mailfrom=0100019f38557b1a-ce103552-25f4-4d40-a76a-d8f8cf98fa1b-000000@amazonses.com;
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=amazon.com
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/simple;
s=pwdll6lpa66mxupg2aa3uosx2zizclvc; d=amazon.com; t=1783356554;
h=Date:From:Reply-To:To:Message-ID:Subject:MIME-Version:Content-Type;
bh=+oIkeaNXB+80Md33rZGcSEaAg+nS01oDB42PknjIXIk=;
b=06YN//A69cb10ZreMsauMMCcF/nQHOCjBwAbnJFQAC6+ijGUmCmpkGXHtRnTidQ0
mQdNdy2Sx2E438pdWtouKSi5xuhUN+vNqit+YwMVd6a7IC0YuyLTAtRbBQinoYAr6Tl
UJKXEIVYvmanrUjMjMuGo2kf7pz9EpdosXCngYs7e3swBVESKnT8D109Ia+Kmpk7leD
J+VmxC3sBtQVzEvY5unzq027vIZdQZ0K2GAdpyAHWy0+BCcvesB9lowLj5nea/L8HOd
cJsPffRGUm2G6wQNQm6fo2QybowSdPCA18k/VCT+Iz2l2oyjzFkFmX1ycoKM7O/dXwT
iFfC9dkUGA==
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/simple;
s=224i4yxa5dv7c2xz3womw6peuasteono; d=amazonses.com; t=1783356554;
h=Date:From:Reply-To:To:Message-ID:Subject:MIME-Version:Content-Type:Feedback-ID;
bh=+oIkeaNXB+80Md33rZGcSEaAg+nS01oDB42PknjIXIk=;
b=D4MuravuFghRiehrC6vr6KgCKcqFQl99m0hY50EceMrsHg0fm+0TeZof3DJINLbn
HlVVLcr30EjkLgVPGuN4/zf5jlTqmFNQQLrMNITJguluM4N712Ih3dsGN31M06m1rBw
G5a4Wy34Ss11oIvUH7Bba+sanCQb8m2vsSO5m8as=
Date: Mon, 6 Jul 2026 16:49:14 +0000
From: Fulfillment by Amazon Operations <fba-noreply@amazon.com>
Reply-To: Fulfillment by Amazon Operations <fba-noreply@amazon.com>
To: contact@ldrprep.com
Message-ID: <0100019f38557b1a-ce103552-25f4-4d40-a76a-d8f8cf98fa1b-000000@email.amazonses.com>
Subject: Automated unfulfillable FBA inventory removal notification
MIME-Version: 1.0
Content-Type: multipart/alternative;
boundary="----=_Part_196911_132258034.1783356554001"
Bounces-to: RTE+NE-null-de34cA07089053HVB1V8PA3SYZ@sellernotifications.amazon.com
X-Space-Message-ID: 387030323632580
X-Marketplace-ID: ATVPDKIKX0DER
X-Space-Notification-Type: PRODUCT_REMOVAL_ORDER_CREATED_AUTO
Feedback-ID: ::1.us-east-1.apt6UHTqs3880kGtYdHRf9uSLRw5TOiel8eOJZxLSu8=:AmazonSES
X-SES-Outgoing: 2026.07.06-76.223.143.195
------=_Part_196911_132258034.1783356554001
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.=
w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns=3D"http://www.w3.org/1999/xhtml" xmlns:v=3D"urn:schemas-microso=
ft-com:vml" xmlns:o=3D"urn:schemas-microsoft-com:office:office">
<!-- Email Header -->
<head>
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3DUTF-8">=
=09
<!-- Included header file, can be cloned and/or replaced. -->
<!--[if gte mso 15]>
<xml>
=09<o:OfficeDocumentSettings>
=09<o:AllowPNG/>
=09<o:PixelsPerInch>96</o:PixelsPerInch>
=09</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta charset=3D"UTF-8">
<!--[if !mso]><!-->
=09=09<meta http-equiv=3D"X-UA-Compatible" content=3D"IE=3Dedge">
=09<!--<![endif]-->
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=3D1"=
>
<title></title>
<link href=3D"http://fonts.googleapis.com/css?family=3DLato:400,700" rel=3D=
"stylesheet" type=3D"text/css">
<style type=3D"text/css">
=09
=09.nbus-survey{color: #FFF;font-family:'Lato', Helvetica, Arial, sans-seri=
f;font-size:13px;line-height:150%;text-align:center}
=09.nbus-survey:visited{color: #FFF;font-family:'Lato', Helvetica, Arial, s=
ans-serif;font-size:13px;line-height:150%;text-align:center}
=09.nbus-survey:hover{color: #FFF;font-family:'Lato', Helvetica, Arial, san=
s-serif;font-size:13px;line-height:150%;text-align:center}
=09.nbus-survey:focus{color: #FFF;font-family:'Lato', Helvetica, Arial, san=
s-serif;font-size:13px;line-height:150%;text-align:center}
=09.nbus-survey:active{color: #FFF;font-family:'Lato', Helvetica, Arial, sa=
ns-serif;font-size:13px;line-height:150%;text-align:center}
=09
=09one-column{border-spacing:0px;background-color:#FFFFFF;border:0px;paddin=
g:0px;width:100%;column-count:1;}
=09endrImageBlock{padding:0px;border-spacing:0px;min-width:100%;border-coll=
apse:collapse;width:100%;border:0px;}
=09endrImageBlockInner{padding:0px;}
=09endrImageContentContainer{adding:0px;border-spacing:0px;min-width:100%;b=
order-collapse:collapse;width:100%;border:0px;}
=09endrTextContentContainer{min-width:100%;width:100%;border-collapse:colla=
pse;background-color:#FFFFFF;border:0px;padding:0px;border-spacing:0px;}
=09endrTextBlock{min-width:100%;border-collapse:collapse;background-color:#=
FFFFFF;width:100%padding:0px;border-spacing:0px;border:0px;}
=09preview-text{display:none;font-size:1px;line-height:1px;max-height:0px;m=
ax-width:0px;opacity:0;overflow:hidden;mso-hide:all;font-family: sans-serif=
;}
=09
=09p{
=09text-align:left;
=09margin-top:10px;
=09margin-bottom:10px;
=09margin-right:0;
=09margin-left:0;
=09padding-top:0;
=09padding-bottom:0;
=09padding-right:0;
=09padding-left:0;
=09line-height:185%;
=09}
=09table{
=09border-collapse:collapse;
=09}
=09h1,h2,h3,h4,h5,h6{
=09display:block;
=09margin:0;
=09padding:0;
=09}
=09img,a img{
=09border:0;
=09height:auto;
=09outline:none;
=09text-decoration:none;
=09}
=09pre{
=09height:100%;
=09margin:0px;
=09padding:0px;
=09width:100%;
=09font-family:'Lato',Helvetica, Arial, sans-serif;
=09min-width:100%;
=09white-space: pre-wrap; /* Since CSS 2.1 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
=09}
=09body,#bodyTable,#bodyCell{
=09height:100%;
=09margin:0px;
=09padding:0px;
=09width:100%;
=09background-color:#e4e3e4;
=09color:#999999
=09font-family:'Lato', Helvetica, Arial, sans-serif;
=09min-width:100%;
=09}
=09#outlook a{
=09padding:0;
=09}
=09img{
=09-ms-interpolation-mode:bicubic;
=09}
=09table{
=09mso-table-lspace:0pt;
=09mso-table-rspace:0pt;
=09}
=09.ReadMsgBody{
=09width:100%;
=09}
=09.ExternalClass{
=09width:100%;
=09}
=09p,a,li,td,blockquote{
=09mso-line-height-rule:exactly;
=09}
=09a[href^=3Dtel],a[href^=3Dsms]{
=09color:inherit;
=09cursor:default;
=09text-decoration:none;
=09}
=09p,a,li,td,body,table,blockquote{
=09-ms-text-size-adjust:100%;
=09-webkit-text-size-adjust:100%;
=09}
=09.ExternalClass,.ExternalClass p,.ExternalClass td,.ExternalClass div,.Ex=
ternalClass span,.ExternalClass font{
=09line-height:100%;
=09}
=09a[x-apple-data-detectors]{
=09color:inherit !important;
=09text-decoration:none !important;
=09font-size:inherit !important;
=09font-family:inherit !important;
=09font-weight:inherit !important;
=09line-height:inherit !important;
=09}
=09.templateContainer{
=09max-width:600px !important;
=09}
=09.endrImage{
=09vertical-align:bottom;
=09}
=09.endrTextContent{
=09word-break:break-word;
=09padding-top:15px;
=09padding-bottom:10px;
=09padding-right:18px;
=09padding-left:18px;
=09text-align:left;
=09}
=09.endrTextContent img{
=09height:auto !important;
=09}
=09.endrDividerBlock{
=09table-layout:fixed !important;
=09}
=09body { margin:0 !important; }
=09div[style*=3D"margin: 16px 0"] { margin:0 !important; }
=09body,#bodyTable{
=09background-color:#e4e3e4;
=09color:#999999;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09}
=09
=09.templateBlocks{
=09background-color:#FFFFFF;
=09border-top-width:0;
=09border-bottom-width:0;
=09padding-top:0;
=09padding-bottom:0;
=09font-size:15px;
=09line-height:185%;
=09text-align:left;
=09background-color:#FFFFFF;
=09}
=09
=09.templateQuoteBlocks{
=09background-color:#F04D44;
=09}
=09
=09#bodyCell{
=09border-top:0;
=09}
=09h1{
=09color:#455c64;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09font-size:30px;
=09font-style:normal;
=09font-weight:normal;
=09line-height:120%;
=09letter-spacing:normal;
=09padding-top:2px;
=09padding-bottom:2px;
=09}
=09a{
=09color:#e74c3c;
=09font-weight:normal;
=09text-decoration:underline;
=09}
=09h2{
=09color:#848484;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09font-size:15px;
=09font-style:normal;
=09font-weight:normal;
=09line-height:145%;
=09letter-spacing:1px;
=09padding-top:5px;
=09padding-bottom:4px;
=09}
=09h3{
=09color:#455c64;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09font-size:20px;
=09font-style:normal;
=09font-weight:normal;
=09line-height:140%;
=09letter-spacing:normal;
=09text-align:left;
=09padding-top:2px;
=09padding-bottom:2px;
=09}
=09h4{
=09color:#666666;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09font-size:16px;
=09font-style:normal;
=09font-weight:normal;
=09line-height:125%;
=09letter-spacing:normal;
=09text-align:left;
=09padding-top:11px;
=09padding-bottom:4px;
=09}
=09h5{
=09color:#ffffff;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09font-size:20px;
=09font-style:normal;
=09font-weight:normal;
=09line-height:135%;
=09letter-spacing:normal;
=09text-align:left;
=09padding-top:11px;
=09padding-right:20px;
=09padding-bottom:8px;
=09padding-left:20px;
=09}
=09h6{
=09color:#ffffff;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09font-size:26px;
=09font-style:normal;
=09font-weight:normal;
=09line-height:135%;
=09letter-spacing:normal;
=09text-align:right;
=09padding-top:11px;
=09padding-right:20px;
=09padding-bottom:8px;
=09padding-left:20px;
=09}
=09#templatePreheader{
=09border-top:0;
=09border-bottom:0;
=09padding-top:4px;
=09padding-bottom:12px;
=09}
=09#templatePreheader .endrTextContent,#templatePreheader .endrTextContent =
p{
=09color:#fbfbfb;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09font-size:12px;
=09line-height:150%;
=09text-align:center;
=09}
=09#templatePreheader .endrTextContent a,#templatePreheader .endrTextConten=
t p a{
=09color:#fbfbfb;
=09font-weight:normal;
=09text-decoration:underline;
=09}
=09#templateHeader{
=09background-color:#303942;
=09border-top:0px solid #e4e3e4;
=09border-bottom:0;
=09padding-top:0px;
=09padding-bottom:0px;
=09}
=09#templateHeader .endrTextContent,#templateHeader .endrTextContent p{
=09color:#ffffff;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09font-size:13px;
=09line-height:100%;
=09text-align:right;
=09}
=09#templateHeader .endrTextContent,#templateHeader .endrTextContent h1{
=09color:#ffffff;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09font-size:20px;
=09line-height:100%;
=09text-align:right;
=09}
=09#templateHeader .endrTextContent a,#templateHeader .endrTextContent p a{
=09color:#ffffff;
=09font-weight:normal;
=09text-decoration:none;
=09}
=09#templateSeparator{
=09padding-top:8px;
=09padding-bottom:8px;
=09}
=09.templateLowerBody{
=09background-color:#455C64;
=09border-bottom:0;
=09padding-top:1px;
=09padding-bottom:1px;
=09}
=09.templateLowerBody .endrTextContent,.templateLowerBody .endrTextContent =
p{
=09color:#ffffff;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09font-size:13px;
=09line-height:150%;
=09text-align:left;
=09}
=09.templateLowerBody .endrTextContent a,.templateLowerBody .endrTextConten=
t p a{
=09color:#ffffff;
=09font-weight:normal;
=09text-decoration:underline;
=09}
=09.templateLowerBody .endrTextContent h1 {
=09color:#ffffff;
=09font-weight:700;
=09font-size:18px;
=09}
=09.templateSocial{
=09background-color:#e4e3e4;
=09padding-top:13px;
=09padding-bottom:3px;
=09}
=09#templateFooter{
=09border-top:0;
=09border-bottom:0;
=09padding-top:5px;
=09padding-bottom:5px;
=09}
=09#templateFooter .endrTextContent,#templateFooter .endrTextContent p{
=09color:#fbfbfb;
=09font-family: 'Lato', Helvetica, Arial, sans-serif;
=09font-size:12px;
=09line-height:150%;
=09text-align:center;
=09}
=09#templateFooter .endrTextContent a,#templateFooter .endrTextContent p a{
=09color:#fbfbfb;
=09font-weight:normal;
=09text-decoration:underline;
=09}
=09
=09@media only screen and (min-width:768px){
=09.templateContainer{
=09width:600px !important;
=09}
=09}=09
=09
=09@media only screen and (max-width: 480px){
=09
=09.templateHeader{
=09=09display: none;
=09}
=09=09
=09.bigimage .endrImageContent{
=09padding-top:0px !important;
=09}
=09.templateContainer{
=09width:100% !important;
=09max-width:600px;
=09}=09@media only screen and (max-width: 480px){
=09body,table,td,p,a,li,blockquote{
=09-webkit-text-size-adjust:none !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09body{
=09width:100% !important;
=09min-width:100% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09#bodyCell{
=09padding-top:10px !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.columnWrapper{
=09max-width:100% !important;
=09width:100% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.endrImage{
=09width:100% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.endrCaptionTopContent,.endrCaptionBottomContent,.endrTextContentContain=
er,.endrBoxedTextContentContainer,.endrImageGroupContentContainer,.endrCapt=
ionLeftTextContentContainer,.endrCaptionRightTextContentContainer,.endrCapt=
ionLeftImageContentContainer,.endrCaptionRightImageContentContainer,.endrIm=
ageCardLeftTextContentContainer,.endrImageCardRightTextContentContainer{
=09max-width:100% !important;
=09width:100% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.endrBoxedTextContentContainer{
=09min-width:100% !important;
=09}
=09} @media only screen and (max-width: 480px){
=09.column{
=09width:100% !important;
=09max-width:100% !important;
=09}
=09} @media only screen and (max-width: 480px){
=09.endrImageGroupContent{
=09padding:9px !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.endrCaptionLeftContentOuter .endrTextContent,.endrCaptionRightContentOu=
ter .endrTextContent{
=09padding-top:9px !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.endrImageCardTopImageContent,.endrCaptionBlockInner .endrCaptionTopCont=
ent:last-child .endrTextContent{
=09padding-top:18px !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.endrImageCardBottomImageContent{
=09padding-bottom:9px !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.endrImageGroupBlockInner{
=09padding-top:0 !important;
=09padding-bottom:0 !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.endrImageGroupBlockOuter{
=09padding-top:9px !important;
=09padding-bottom:9px !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.endrTextContent,.endrBoxedTextContentColumn{
=09padding-right:18px !important;
=09padding-left:18px !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.endrImageCardLeftImageContent,.endrImageCardRightImageContent{
=09padding-right:18px !important;
=09padding-bottom:0 !important;
=09padding-left:18px !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.mcpreview-image-uploader{
=09display:none !important;
=09width:100% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09h1{
=09font-size:22px !important;
=09line-height:125% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09h2{
=09font-size:20px !important;
=09line-height:125% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09h3{
=09font-size:18px !important;
=09line-height:125% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09h4{
=09font-size:16px !important;
=09line-height:150% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09
=09.endrBoxedTextContentContainer .endrTextContent,.endrBoxedTextContentCon=
tainer .endrTextContent p{
=09font-size:14px !important;
=09line-height:150% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09#templatePreheader{
=09display:block !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09#templatePreheader .endrTextContent,#templatePreheader .endrTextContent =
p{
=09font-size:12px !important;
=09line-height:150% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09#templateHeader .endrTextContent,#templateHeader .endrTextContent p{
=09font-size:16px !important;
=09line-height:100% !important;
=09text-align:center !important;
=09}
=09#templateHeader .endrTextContent, #templateHeader .endrTextContent h1{
=09font-size:20px !important;
=09line-height:100% !important;
=09padding-bottom:10px !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09#templateUpperBody .endrTextContent,#templateUpperBody .endrTextContent =
p{
=09font-size:16px !important;
=09line-height:150% !important;
=09}
=09
=09}=09@media only screen and (max-width: 480px){
=09#templateColumns .columnContainer .endrTextContent,#templateColumns .col=
umnContainer .endrTextContent p{
=09font-size:16px !important;
=09line-height:150% !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09.templateLowerBody .endrTextContent,.templateLowerBody .endrTextContent =
p{
=09font-size:16px !important;
=09line-height:150% !important;
=09text-align:center !important;
=09}
=09}=09@media only screen and (max-width: 480px){
=09#templateFooter .endrTextContent,#templateFooter .endrTextContent p{
=09font-size:12px !important;
=09line-height:150% !important;
=09}
=09}
</style>
<!--[if mso]>
<style type=3D"text/css">
body, table, td {font-family: Arial, Helvetica, sans-serif !important;}
h1 {font-family: Arial, Helvetica, sans-serif !important;}
h2 {font-family: Arial, Helvetica, sans-serif !important;}
h3 {font-family: Arial, Helvetica, sans-serif !important;}
h4 {font-family: Arial, Helvetica, sans-serif !important;}
h5 {font-family: Arial, Helvetica, sans-serif !important;}
h6 {font-family: Arial, Helvetica, sans-serif !important;}
h7 {font-family: Arial, Helvetica, sans-serif !important;}
p {font-family: Arial, Helvetica, sans-serif !important;}
</style>
<![endif]-->
<!--[if gt mso 15]>
<style type=3D"text/css" media=3D"all">
/* Outlook 2016 Height Fix */
table, tr, td {border-collapse: collapse;}
tr {border-collapse: collapse; }
body {background-color:#ffffff;}
</style>
<![endif]-->
<!-- Title - Optional content - The title tag shows in email notifications =
on mobile devices -->
<title>
</title>=20
</head>
<!-- Email Body -->
<body style=3D"body">
<center class=3D"wrapper" style=3D"width:100%;table-layout:fixed;background=
-color:#e4e3e4;">
<div class=3D"webkit" style=3D"max-width:600px;margin:0 auto;">
<table align=3D"center" border=3D"0" cellpadding=3D"0" cellspacing=3D=
"0" height=3D"100%" width=3D"100%" id=3D"bodyTable" style=3D"border-collaps=
e:collapse;height:100%;margin-top:0;margin-bottom:0;margin-right:0;margin-l=
eft:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:1=
00%;background-color:#e4e3e4;color:#5a5a5a;font-family:'Lato', Helvetica, A=
rial, sans-serif;">
<tbody><tr>
<td align=3D"center" valign=3D"top" id=3D"bodyCell" style=3D"he=
ight:100%;margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;width:1=
00%;padding-top:10px;padding-bottom:10px;border-top-width:0;">
<!-- BEGIN TEMPLATE // -->
<!--[if (gte mso 9)|(IE)]>
<table align=3D"center" border=3D"0" cellspacing=3D"0" cellpadding=3D"0" wi=
dth=3D"600" style=3D"width:600px;border-collapse:collapse;" >
=09<tr>
=09<td align=3D"center" valign=3D"top" width=3D"600" style=3D"width:600px;"=
>
=09=09<![endif]-->
=09=09<table border=3D"0" cellpadding=3D"0" cellspacing=3D"0" class=3D"temp=
lateContainer" style=3D"width:100%;max-width:600px;border-collapse:collapse=
;" data-space-header>
=09=09<tbody><tr>
=09=09<td>
<!-- Previewtext - Optional content - This text will appear in the inbox pr=
eview, but not the email body. div class =3D "preview-text" -->
<div style=3D"display:none;font-size:1px;line-height:1px;max-height:0px;max=
-width:0px;opacity:0;overflow:hidden;mso-hide:all;font-family: 'Amazon Embe=
r';">
</div>
<!-- Included header file(graphics) -->
<!-- BLOCK Logo Center -->
<table class=3D"one-column" border=3D"0" cellpadding=3D"0" cellspacing=3D"=
0" width=3D"100%" style=3D"border-spacing:0;" data-space-sc-header>
=09=09=09=09=09<tbody><tr valign=3D"top" style=3D"border-top-width:0;border=
-bottom-width:0;font-size:14px;line-height:185%;text-align:left;">
=09=09=09=09=09=09<td valign=3D"top" class=3D"templateLowerBody" style=3D"b=
ackground-color:#2a323a;">
=09<table border=3D"0" cellpadding=3D"0" cellspacin=
g=3D"0" width=3D"100%" class=3D"endrTextBlock" style=3D"min-width:100%;bord=
er-collapse:collapse;background-color:#2a323a;" bgcolor=3D"#2a323a">
=09=09=09=09=09=09=09=09<tbody class=3D"endrTextBlockOuter">
=09=09=09=09=09=09=09=09=09<tr>
=09=09=09=09=09=09=09=09=09=09<td valign=3D"top" class=3D"endrTextBlockInne=
r">
=09=09=09=09=09=09=09=09=09=09=09<table align=3D"left" border=3D"0" cellpad=
ding=3D"0" cellspacing=3D"0" width=3D"100%" class=3D"endrTextContentContain=
er" style=3D"min-width:100%;border-collapse:collapse;" bgcolor=3D"#2a323a">
=09=09=09=09=09=09=09=09=09=09=09<tbody>
=09=09=09=09=09=09=09=09=09=09=09<tr>
<td class=3D"templateHeader" valign=3D"top" style=3D"padding: 20p=
x 0; padding-left:40px">
<img align=3D"center" alt=3D"" src=3D"https://m.media-amazon.com/=
images/G/01/SPACE/logo-selling_coach.png" width=3D"200" style=3D"max-width:=
200px;padding-bottom:0;display:inline !important;vertical-align:bottom;bord=
er-width:0;height:auto;outline-style:none;text-decoration:none;-ms-interpol=
ation-mode:bicubic;">
</td>
=09=09=09=09=09</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
=09=09=09=09=09=09</td>
=09=09=09=09=09</tr>
=09=09=09=09=09</tbody></table>
<!-- ENDR Header -->
</td>
</tr>
</tbody></table>
<table border=3D"0" cellpadding=3D"0" cellspacing=3D"0" class=3D"templateCo=
ntainer" width=3D"100%" style=3D"width:100%;max-width:600px;border-collapse=
:collapse;background-color:#ffffff;" bgcolor=3D"#ffffff">
<tbody><tr>
<td>
<!-- General Block of text - modify/replace with your content -->
<table class=3D"one-column">
<tbody><tr valign=3D"top" class=3D"templateBlocks">
=09<td valign=3D"top">
=09=09<table class=3D"endrTextBlock">
=09=09=09<tbody class=3D"endrTextBlockOuter">
=09=09=09<tr>
=09=09=09=09<td valign=3D"top" class=3D"endrTextBlockInner">
=09=09=09=09<table align=3D"left" class=3D"endrTextContentContainer">
=09=09=09=09=09<tbody>
=09=09=09=09=09<tr>
<td valign=3D"top" class=3D"endrTextContent" align=3D"center">
<div style=3D"text-align:left;margin-top:10px;margin-bottom:10px;margin-rig=
ht:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-l=
eft:0;line-height:185%;">
=09Hello peleya,<br><br>The following automated unfulfillable FBA removal o=
rders have been created based on your automated settings.
=09<br>
=09=09<table>
=09=09=09=09<tbody><tr>
=09=09<td style=3D"text-align:left">Removal order ID kFAkH567aC</td>
=09=09</tr>
=09=09=09</tbody></table>
=09<br>
=09You can view the removals on <a href=3D"https://sellercentral.amazon.com=
/nms/sellermobile/redirect/3dde1230-92a2-374e-bda6-f268b74e11a3?nt=3DPRODUC=
T_REMOVAL_ORDER_CREATED_AUTO&amp;sk=3DJXuLdbiY_zOmIxz0DWMUglEOnStr_axYYt2V8=
M2pcfUvK_eGXXajeG-bzHxL6vSHE9LAQhUA6k95HvXgik59KQ&amp;n=3D1&amp;u=3DaHR0cHM=
6Ly9zZWxsZXJjZW50cmFsLmFtYXpvbi5jb20vZ3Avb3JkZXJzLXYyL2xpc3QvLi4vLi4vLi4vcm=
Vwb3J0Y2VudHJhbC9SRU1PVkFMX09SREVSX0RFVEFJTC8w">Removal Order Details</a> i=
n your seller account by using the Search tool.<br><br>The next removal ord=
er will be created as scheduled on July 20, 2026 in accordance with your se=
ttings if you have unfulfillable inventory at that time.<br><br>To make cha=
nges to the shipping address, frequency of your automated unfulfillable rem=
ovals, or other settings, go to <a href=3D"https://sellercentral.amazon.com=
/nms/sellermobile/redirect/f612c1d5-63e4-3fd4-ae67-e8598384b896?nt=3DPRODUC=
T_REMOVAL_ORDER_CREATED_AUTO&amp;sk=3DZBwUT-MEmjUHrMUqzMkgwTqOj8GU5wicW-4_3=
wvmNJfjKjY2SD2A7pmHFY6AEaQ-dCj5KzjuyUtTYzaZkr1S9Q&amp;n=3D1&amp;u=3DaHR0cHM=
6Ly9zZWxsZXJjZW50cmFsLmFtYXpvbi5jb20vZ3Avb3JkZXJzLXYyL2xpc3QvLi4vLi4vLi4vZm=
JhL3NldHRpbmdzL2luZGV4Lmh0bWwvcmVmPXh4X2ZiYXNldHRpbmdzX2RuYXZfeHgjLw">Autom=
ated unfulfillable removal settings</a> in your seller account.
=09<br><br>
=09Thank you. <br><br>
The Fulfillment by Amazon team <br><br>
Please note: This email was sent from a notification-only address that cann=
ot accept incoming email. Please do not reply to this email.
</div>
</td>
=09=09=09=09=09</tr>
=09=09=09=09=09</tbody>
=09=09=09=09</table>
=09=09=09=09</td>
=09=09=09</tr>
=09=09=09</tbody>
=09=09</table>
=09</td>
</tr>
</tbody></table>
<!-- Footer -->
<!-- Included footer file, can be cloned and/or replaced. -->
=09=09=09=09=09
=09=09=09=09=09<table border=3D"0" cellpadding=3D"0" cellspacing=3D"0" clas=
s=3D"templateContainer" width=3D"100%" style=3D"width:100%;max-width:600px;=
border-collapse:collapse;" data-space-footer data-space-sc-footer>
=09=09=09=09=09<tbody><tr>
=09=09=09=09=09<td>
=09=09=09=09
=09=09=09=09=09<!-- BLOCK Footer About Us -->
=09=09=09=09=09<table class=3D"one-column" border=3D"0" cellpadding=3D"0" c=
ellspacing=3D"0" width=3D"100%" style=3D"border-spacing:0;" dir=3D"auto">
=09=09=09=09=09<tbody><tr valign=3D"top" style=3D"border-top-width:0;border=
-bottom-width:0;font-size:14px;line-height:185%;text-align:left;">
=09=09=09=09=09=09<td valign=3D"top" class=3D"templateLowerBody" style=3D"b=
ackground-color:#2a323a;">
=09<table border=3D"0" cellpadding=3D"0" cellspacin=
g=3D"0" width=3D"100%" class=3D"endrTextBlock" style=3D"min-width:100%;bord=
er-collapse:collapse;background-color:#2a323a;" bgcolor=3D"#2a323a">
=09=09=09=09=09=09=09=09<tbody class=3D"endrTextBlockOuter">
=09=09=09=09=09=09=09=09=09<tr>
=09=09=09=09=09=09=09=09=09=09<td valign=3D"top" class=3D"endrTextBlockInne=
r">
=09=09=09=09=09=09=09=09=09=09=09<center style=3D"margin-top: 20px;">
=09<a style=3D"padding: 7px 24px; text-decoration: =
none; background: #fff; border: 1px solid #EC937C; border-radius: 5px; colo=
r: #7F1809; font-family: 'Amazon Ember'; font-size: 13px; font-weight: 600;=
display: inline-block; margin-bottom: 10px" href=3D"https://sellercentral.a=
mazon.com/notifications/feedback?deliveryId=3D387030323632580&amp;communica=
tionName=3DPRODUCT_REMOVAL_ORDER_CREATED_AUTO&amp;deliveryChannel=3DEMAIL">
<img src=3D"https://m.media-amazon.com/images/G/01/spac=
e/icon.png" style=3D"margin-right: 10px;vertical-align: middle;" width=3D"2=
0" height=3D"20">Report an issue with this email
</a>
</center><p style=3D"text-align:center !important;m=
argin-top:10px;margin-bottom:10px;margin-right:10px;margin-left:15px;paddin=
g-top:0;padding-bottom:0;padding-right:0;padding-left:0;color:#ffffff;font-=
family:'Amazon Ember';font-size:13px;line-height:150%;">If you have any que=
stions visit: <a href=3D"https://sellercentral.amazon.com/nms/sellermobile/=
redirect/4e2a53a7-bed2-3e52-a967-b56eaa782802?nt=3DPRODUCT_REMOVAL_ORDER_CR=
EATED_AUTO&amp;sk=3DNIjLb1NYcbLiLnOYE5cew8DSC1z0NHVj5wxU-_YkUXvhCAnENnYDu5_=
S0ejHUU0Lah0T--j33o_cMnp5yXQoFw&amp;n=3D1&amp;u=3DaHR0cHM6Ly9zZWxsZXJjZW50c=
mFsLmFtYXpvbi5jb20" metric=3D"help" style=3D"text-decoration: none; color: =
#ffffff;">Seller Central</a><br><br>
=09=09=09=09=09=09=09=09=09=09=09 To change your email preferences visit=
: <a href=3D"https://sellercentral.amazon.com/notifications/preferences/ref=
=3Did_notifpref_dnav_xx_" metric=3D"optout" style=3D"text-decoration: none;=
color: #ffffff;">Notification Preferences</a><br><br>
=09=09=09=09=09=09=09=09=09=09=09=09=09=09=09=09=09=09Copyright 2026 Amazo=
n, Inc, or its affiliates. All rights reserved.<br>=20
=09=09=09=09=09=09=09=09=09=09=09=09Amazon.com, 410 Terry Avenue North, Sea=
ttle, WA 98109-5210<br></p><table align=3D"left" border=3D"0" cellpadding=
=3D"0" cellspacing=3D"0" width=3D"100%" class=3D"endrTextContentContainer" =
style=3D"min-width:100%;border-collapse:collapse;" bgcolor=3D"#2a323a">
=09=09=09=09=09=09=09=09=09=09=09<tbody>
=09=09=09=09=09=09=09=09=09=09=09<tr>
=20
=20
=20
=09=09=09=09=09=09=09=09=09=09=09
=09=09=09=09=09</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
=09=09=09=09=09=09</td>
=09=09=09=09=09</tr>
=09=09=09=09=09</tbody></table>
=09=09=09=09=09<!-- BLOCK Footer About Us -->
=09=09=09=09=09</td>
=09=09=09=09=09</tr>
=09=09=09=09=09</tbody></table>
=09=09=09=09=09
=09=09=09=09=09<table border=3D"0" cellpadding=3D"0" cellspacing=3D"0" clas=
s=3D"templateContainer" style=3D"width:100%;max-width:600px;border-collapse=
:collapse;">
=09=09=09=09=09<tbody><tr>
=09=09=09=09=09<td>
=09=09=09=09
=09=09=09=09=09</td>
=09=09=09=09=09</tr>
=09=09=09=09=09</tbody></table>
=09=09=09=09=09<!--[if (gte mso 9)|(IE)]>
=09=09=09=09</td>
=09=09=09=09</tr>
=09=09=09</table>
=09=09=09<![endif]-->
=09=09=09<!-- // END TEMPLATE -->
=09=09=09</td>
=09=09</tr>
=09=09</tbody></table><!-- Footer -->
</td></tr></tbody></table></div></center><img src=3D"https://sellercentral.=
amazon.com/nms/img/1880a936-aac9-354d-9307-a42c98511ed9?sk=3D0zFq3o6e7YSXAf=
Y3L78KAvv8dglf5D-YNaNNqF2EVAWuYpaBKA65EbkSubw2VKlbXpxJHAPRK_wmJO25bt-jBw&am=
p;n=3D1">
<div id=3D"spc-watermark">
<p style=3D"font-size: 10px !important;padding-bottom: 10px !important;di=
splay: table !important;margin: 5px auto 10px !important;text-align: center=
!important;color: #a2a2a2 !important;font-family: 'Amazon Ember' !importan=
t;font-weight: 400 !important;">SPC-USAmazon-387030323632580</p>
</div></body></html>
------=_Part_196911_132258034.1783356554001--
Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB