Files
paliad/internal/services/paliadin_test.go
m 7b66c4d035 feat(t-paliad-146): Paliadin PoC — tmux-Claude in-app AI buddy
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.
2026-05-07 21:49:33 +02:00

150 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:])
}
}