Phase 0 of the Paliadin design (docs/design-paliadin-2026-05-07.md
§0.5). m-only laptop scope, gated behind PALIADIN_ENABLED=false on
prod. Lifts the goldi/mVoice tmux-Claude pattern (mVoice/server.py:
250-380) into a Go service: long-lived `claude` pane in a tmux
session, prompts in via `tmux send-keys -l`, responses out via a
per-turn file (/tmp/paliadin/{turn_id}.txt) the system prompt
instructs Claude to write.
What landed
-----------
- migration 058_paliadin_poc — paliad.paliadin_turns audit table
(full prompt + response stored at PoC scope; redaction returns
at production v1 per design §3.3). RLS: user sees own,
global_admin sees all.
- internal/services/paliadin.go — the orchestrator. ensurePane()
finds-or-creates the tagged tmux window, sendToPane sends the
framed [PALIADIN:turn_id] envelope, pollForResponse reads the
per-turn file, splitTrailer parses the [paliadin-meta] block
Claude appends to every reply (used_tools, rows_seen,
classifier_tag).
- internal/services/paliadin_prompt.go — the system prompt sent
once to a fresh Claude pane. Defines the response protocol
(Write-to-file + meta trailer), the action-chip marker syntax,
the visibility-gate rule (paliad.can_see_project required in
every project-scoped query), and 9 SQL recipes covering m's
paliad data + cross-schema youpc case-law lookup.
- internal/handlers/paliadin.go — POST /api/paliadin/turn kicks
off the work in a goroutine and returns an SSE URL; GET
/api/paliadin/stream/{id} relays per-turn channel events
(meta/content/end/error/ping) to EventSource. Routes register
ONLY when PaliadinService is wired — paliadinSvc nil → no
handlers exist, prod surface is clean.
- /admin/paliadin dashboard — global_admin-only. Shows total
turns, last-7-days, median/p90 duration, tool-use rate (the
load-bearing §0.5.7 metric), abandon rate, classifier
histogram, daily sparkline, top prompts, recent turn log.
Powered by PaliadinService.Stats() + ListRecentTurns().
- frontend: paliadin.tsx + client/paliadin.ts (chat panel with
starter prompts, EventSource consumer, typewriter render of
one-shot content blob, citation-chip parser, "Stop" + "New
conversation" buttons, localStorage history); admin-paliadin
pair (read-only stats dashboard).
- Sidebar: Paliadin entry under Übersicht (ICON_SPARKLE);
Paliadin Monitor under Admin.
- 36 i18n keys (DE+EN), CSS for chat panel + dashboard.
- main.go: PaliadinService wires only on PALIADIN_ENABLED=true,
with PALIADIN_TMUX_SESSION + PALIADIN_RESPONSE_DIR overrides.
Logs visibly so the operator can confirm at boot.
- CLAUDE.md: ANTHROPIC_API_KEY row updated (PoC doesn't need it
— Claude CLI uses m's subscription; key reserved for future
production-v1). New rows for the three PALIADIN_* env vars.
Tests
-----
- 7 unit tests on the trailer parser, chip counter, token approx,
and tmux-input sanitiser. All pass. The trailer parser is
load-bearing for monitoring; an unobserved parser bug = silent
dashboard rot.
What's NOT in v1 (stays deferred)
---------------------------------
- The Anthropic API client (production v1, gated on PoC success
per §0.5.7).
- BYO-AI / OpenAI adapter.
- Per-user rate limiting.
- Multi-replica SSE bus.
- Mascot / avatar SVG.
- Persistent threads (history is browser localStorage only).
How to use locally
------------------
$ export PALIADIN_ENABLED=true
$ ./paliad
# browse /paliadin → type a question → answers stream back
# /admin/paliadin shows the monitoring dashboard
Migration: 058 (skips fritz's t-147 on 057). Safe on prod
because PALIADIN_ENABLED defaults to false; the table is created
but no routes touch it until the env var flips.
150 lines
4.0 KiB
Go
150 lines
4.0 KiB
Go
package services
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// Tests for the PoC paliadin trailer parser. The parser is load-bearing:
|
||
// it's how the dashboard learns which tools Claude used, how many rows
|
||
// each returned, and how Claude classified the question. Wrong parsing
|
||
// = silently broken monitoring.
|
||
|
||
func TestSplitTrailer_HappyPath(t *testing.T) {
|
||
body := strings.TrimSpace(`
|
||
Diese Woche stehen 3 Fristen an:
|
||
|
||
- 16.05. Klageerwiderung [#deadline-OPEN:c47bd2-1]
|
||
- 17.05. Replik [#deadline-OPEN:e92a01-3]
|
||
- 20.05. Wiedereinsetzung [#deadline-OPEN:f31b09-7]
|
||
|
||
---
|
||
[paliadin-meta]
|
||
used_tools: search_my_deadlines, lookup_court
|
||
rows_seen: 3, 1
|
||
classifier_tag: data
|
||
[/paliadin-meta]
|
||
`)
|
||
clean, meta := splitTrailer(body)
|
||
|
||
if strings.Contains(clean, "[paliadin-meta]") {
|
||
t.Fatalf("trailer not stripped from body:\n%s", clean)
|
||
}
|
||
if !strings.HasPrefix(clean, "Diese Woche") {
|
||
t.Errorf("body lost prefix: %q", clean[:50])
|
||
}
|
||
wantTools := []string{"search_my_deadlines", "lookup_court"}
|
||
if len(meta.UsedTools) != len(wantTools) {
|
||
t.Fatalf("UsedTools len = %d; want %d", len(meta.UsedTools), len(wantTools))
|
||
}
|
||
for i, want := range wantTools {
|
||
if meta.UsedTools[i] != want {
|
||
t.Errorf("UsedTools[%d] = %q; want %q", i, meta.UsedTools[i], want)
|
||
}
|
||
}
|
||
wantRows := []int{3, 1}
|
||
if len(meta.RowsSeen) != len(wantRows) {
|
||
t.Fatalf("RowsSeen len = %d; want %d", len(meta.RowsSeen), len(wantRows))
|
||
}
|
||
for i, want := range wantRows {
|
||
if meta.RowsSeen[i] != want {
|
||
t.Errorf("RowsSeen[%d] = %d; want %d", i, meta.RowsSeen[i], want)
|
||
}
|
||
}
|
||
if meta.ClassifierTag != "data" {
|
||
t.Errorf("ClassifierTag = %q; want %q", meta.ClassifierTag, "data")
|
||
}
|
||
}
|
||
|
||
func TestSplitTrailer_NoTrailer(t *testing.T) {
|
||
body := "Just a response, no trailer."
|
||
clean, meta := splitTrailer(body)
|
||
if clean != body {
|
||
t.Errorf("body changed: %q vs %q", clean, body)
|
||
}
|
||
if len(meta.UsedTools) != 0 || len(meta.RowsSeen) != 0 || meta.ClassifierTag != "" {
|
||
t.Errorf("meta should be zero: %+v", meta)
|
||
}
|
||
}
|
||
|
||
func TestSplitTrailer_EmptyToolsList(t *testing.T) {
|
||
body := strings.TrimSpace(`
|
||
Klageerwiderung ist die Erwiderung auf die Klage.
|
||
|
||
---
|
||
[paliadin-meta]
|
||
used_tools:
|
||
rows_seen:
|
||
classifier_tag: concept
|
||
[/paliadin-meta]
|
||
`)
|
||
clean, meta := splitTrailer(body)
|
||
if strings.Contains(clean, "[paliadin-meta]") {
|
||
t.Errorf("trailer not stripped")
|
||
}
|
||
if len(meta.UsedTools) != 0 {
|
||
t.Errorf("UsedTools should be empty: %v", meta.UsedTools)
|
||
}
|
||
if meta.ClassifierTag != "concept" {
|
||
t.Errorf("ClassifierTag = %q; want concept", meta.ClassifierTag)
|
||
}
|
||
}
|
||
|
||
func TestCountChips(t *testing.T) {
|
||
cases := []struct {
|
||
body string
|
||
want int
|
||
}{
|
||
{"plain text", 0},
|
||
{"see [#deadline-OPEN:abc-123]", 1},
|
||
{"two [#deadline-OPEN:abc] and [#projekt-OPEN:slug]", 2},
|
||
{"chip nav [chip:nav:/projects/123]", 1},
|
||
{"chip filter [chip:filter:status=pending]", 1},
|
||
{"mixed [#frist-OPEN:x] and [chip:nav:/y]", 2},
|
||
// Hallucinated / malformed markers don't count.
|
||
{"[#deadline-OPEN:]", 0},
|
||
{"[#deadline-OPEN]", 0},
|
||
{"[chip:invalid]", 0},
|
||
}
|
||
for _, c := range cases {
|
||
got := countChips(c.body)
|
||
if got != c.want {
|
||
t.Errorf("countChips(%q) = %d; want %d", c.body, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestApproxTokenCount(t *testing.T) {
|
||
cases := []struct {
|
||
s string
|
||
want int
|
||
}{
|
||
{"", 0},
|
||
{"hello", 1}, // 1 word × 1.3 → 1
|
||
{"hello world", 2}, // 2 × 1.3 = 2.6 → 2
|
||
{"one two three four five six seven", 9}, // 7 × 1.3 = 9.1 → 9
|
||
}
|
||
for _, c := range cases {
|
||
got := approxTokenCount(c.s)
|
||
if got != c.want {
|
||
t.Errorf("approxTokenCount(%q) = %d; want %d", c.s, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSanitiseForTmux(t *testing.T) {
|
||
in := "first line\nsecond line\rthird"
|
||
got := sanitiseForTmux(in)
|
||
if strings.ContainsAny(got, "\n\r") {
|
||
t.Errorf("sanitiseForTmux did not strip newlines: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestSanitiseForTmux_TruncatesLong(t *testing.T) {
|
||
long := strings.Repeat("x", 10_000)
|
||
got := sanitiseForTmux(long)
|
||
if !strings.HasSuffix(got, "[…truncated]") {
|
||
t.Errorf("expected truncation marker, got tail: %q", got[len(got)-20:])
|
||
}
|
||
}
|