From dc7c8077254fc6cdfc904d8d3936ac5d7a271cef Mon Sep 17 00:00:00 2001 From: m Date: Thu, 7 May 2026 20:45:31 +0200 Subject: [PATCH 1/8] =?UTF-8?q?design(t-paliad-146):=20Paliadin=20?= =?UTF-8?q?=E2=80=94=20in-app=20AI=20buddy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inventor design pass for the Paliadin: a Claude-backed conversational assistant grounded in the user's own paliad data + paliad's static reference (courts, glossary, deadline rules, Fristenrechner concept tree). Long-lived in-process Go service that calls Anthropic's Messages API directly with tool use; every tool is a thin shim over an existing service (Dashboard / Project / Deadline / Appointment / Court / Glossary / DeadlineRule). RLS / visibility inherited from those services — Paliadin literally cannot see what the caller cannot. Five coordinated sub-designs answer the issue's 20 open questions: A. LLM architecture + tool-use + prompts (§2) B. Data access + RLS + PII (§3) C. UX (§4) D. Token budget + cost + audit (§5) E. Phasing (§7) Phase 1 v1: /paliadin full page + sidebar entry, SSE stream of Anthropic, 7 read-only tools, session-only history, 30/hour user cap + 1000/hour global cap, audit row per turn (metadata only — no transcript), 4k input + 2k output token caps, no avatar/mascot, no proactive onboarding. Migration 057 introduces paliadin_turns + paliadin_rate_limit. Single PR, ~3500-4500 LoC. mlex / /lex-* reuse: shape (system-prompt voice, tool-catalog idea, citation style) — NOT code. mLex is a workspace, not a Go/TS repo; the /lex-* skills drive Claude against youpc's MCP and cannot be embedded in a paliad service. Premise verifications surfaced one CLAUDE.md doc-bug (the ANTHROPIC_API_KEY "Reserved for Phase H — do not set" row needs to flip in the implementation PR — Paliadin un-defers it). 12 open questions for m in §8.5 — Anthropic key choice (personal vs HLC enterprise), default model (Sonnet vs Haiku), surface (/paliadin page vs drawer), mascot phase, 2-PA sanity check before locking scope, etc. Same adoption-risk concern that just parked t-paliad-145 — Paliadin's edge over open-Claude-in-another-tab is data grounding, which only works if v1 makes it visible (citation chips + tool-call evidence + tagline). STOP after design. Awaiting m go/no-go before coder shift. --- docs/design-paliadin-2026-05-07.md | 773 +++++++++++++++++++++++++++++ 1 file changed, 773 insertions(+) create mode 100644 docs/design-paliadin-2026-05-07.md diff --git a/docs/design-paliadin-2026-05-07.md b/docs/design-paliadin-2026-05-07.md new file mode 100644 index 0000000..eee6864 --- /dev/null +++ b/docs/design-paliadin-2026-05-07.md @@ -0,0 +1,773 @@ +# Design: Paliadin — in-app AI buddy / pet (t-paliad-146) + +**Status:** READY FOR REVIEW +**Author:** noether (inventor) +**Issue:** [m/paliad#9](https://mgit.msbls.de/m/paliad/issues/9) +**Date:** 2026-05-07 +**Branch:** `mai/noether/inventor-paliadin-in-app` + +--- + +## §0 TL;DR + +A new conversational surface inside paliad: **Paliadin**, a Claude‑backed assistant that answers questions grounded in the user's own paliad data and paliad's domain knowledge. The Paliadin is a long‑lived in‑process Go service, not a per‑session worker spawn — it talks to the Anthropic Messages API directly with **tool use**, where every tool is a thin shim over an existing paliad service (DashboardService, ProjectService, DeadlineService, CourtService, GlossaryService, DeadlineRuleService, AgendaService). RLS / visibility is enforced at the service layer, exactly as it is for the rest of the app, so Paliadin literally cannot see what the caller cannot see. + +Phase 1 surface: **dedicated `/paliadin` page + a sidebar entry under "Übersicht"**, server‑side SSE stream of Anthropic's response (same shape paliad's parked t‑145 chat design specced), session‑only conversation (no DB persistence in v1), 7 read‑only tools, ~30 turns/hour rate limit per user, hard token caps (4 k input + 2 k output per turn), per‑request audit row (no full transcript v1 — store a redacted hash + token counts + tool‑call list). + +**No avatar, no mascot SVG, no proactive onboarding pop‑up in v1.** Just a clean chat panel with the name "Paliadin" in the header. Mascot, drawer mode, persistent threads, write‑tools, and youpc.org case‑law lookup all deferred to Phase 2/3. + +**mlex / `/lex-*` reuse: pattern, not code.** mLex turns out to be a *workspace* (`extractions/`, `analysis/`, `docs/`) — there is no Go/TS code to fork. The `/lex-*` skills are Claude Code instruction docs that drive *Claude itself* against youpc's MCP tools; they cannot be embedded in a paliad Go service. What carries over is the **shape**: tool catalog (search → fetch → cite), system‑prompt voice (precise, citation‑backed, flag uncertainty honestly), and the "every legal claim needs a citation" guardrail. §2.4 maps the carry‑over precisely. + +**Trade‑off flagged up‑front (read §9.1 before approving):** the same adoption‑risk concern that just parked the local‑chat design (t‑paliad‑145, today 17:03) applies here. Paliadin's edge over "open ChatGPT in another tab" is *only* that it sees the user's own data — and that edge collapses if v1 doesn't make the data‑grounding visible (citation chips, tool‑call evidence) and explicit ("Paliadin sees only YOUR projects"). Without those, Paliadin is just a worse Claude. With them, it's the only Claude that can answer "welche Frist ist als nächstes auf dem Müller‑Verfahren?". + +--- + +## §1 Premises verified live (2026-05-07) + +Before designing on top, I checked each load‑bearing claim against the running system rather than CLAUDE.md / memory. + +| Claim | Source | Verification | +|---|---|---| +| **mLex is a workspace, not a code repo** | issue framing "mlex project we could partially reuse" | `~/dev/mLex/` contains only `extractions/`, `analysis/`, `docs/`, plus `CLAUDE.md` + `AGENTS.md`. No `*.go`, no `package.json`, no tools that aren't Claude skills. The "code" is the `/lex-*` skill family in `~/.claude/skills/`, which is instruction docs driving Claude against `mcp__youpc__*` MCP tools. **Carry‑over is shape (system prompt, tool catalog, citation style), not adapters.** | +| `/lex-*` skill family | brief reference | `~/.claude/skills/{lex-research,lex-extract,lex-classify,lex-classify-patent,mai-lexy}/SKILL.md`. All five inventoried in §2.4. | +| Paliad has no anthropic / claude code | CLAUDE.md `ANTHROPIC_API_KEY` "do not set" row | `grep -ri anthropic ~/dev/paliad/internal ~/dev/paliad/cmd` → only `internal/branding/firm.go` comment unrelated to AI. `go.mod` has no `anthropic-sdk-go` dep. **This task un‑defers the env var; CLAUDE.md row needs updating in the same PR.** | +| Paliad has no SSE pattern shipped | substrate scan | `grep -rn 'http.Flusher\|text/event-stream' internal/` returns only references inside the parked t‑145 chat design doc — no live code. We bring our own. | +| Paliad and youpc share the same physical Postgres | infra | Both run on `100.99.98.201:11833` (port 11833 = ydb). Paliad's schema is `paliad`; youpc's is `data`. **A future "search UPC case law" tool would be a same‑DB cross‑schema SELECT, not an HTTP hop** — but Phase 1 still excludes case‑law lookup (see §3). | +| Visibility is enforced at service layer (not via SET LOCAL auth.uid) | code | `internal/services/visibility.go` defines `visibilityPredicate(alias)` + `visibilityPredicatePositional(alias, idx)`; every project‑scoped query inlines it. Paliadin's tools call existing services, inheriting the predicate. | +| `paliad.can_see_project()` is the canonical visibility function in DB (RLS, t‑139) | t‑139 migration 055 | `internal/db/migrations/055_hierarchy_aggregation.up.sql:144` `CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)`. Same predicate echoed in `services/visibility.go`. | +| Migration tracker is at 56 (`056_user_views`) | t‑144 A1 | `paliad_schema_migrations` row. Next migration is **057**. (t‑145 was parked before its `057_chat` shipped, so 057 is open.) | +| t‑paliad‑145 (local chat) was parked today 2026-05-07 17:03 | memory + commit log | Commit `99f08e3` "Merge: t-paliad-145 design doc only — local chat feature PARKED per m's call". The chat SSE substrate that would have been shared is **not** built — Paliadin builds its own minimal stream. | +| Sidebar bell pattern (`sidebar-inbox-badge`) is reusable for a chat‑style entry | t‑138 | `frontend/src/components/Sidebar.tsx` — `navItem(href, icon, i18nKey, label, currentPath, badgeID?)` already takes an optional badge id. The same plumbing fits a Paliadin entry. | +| Sidebar `ICON_SPARKLE` already exists | UI scan | `frontend/src/components/Sidebar.tsx` defines `ICON_SPARKLE` (a star/sparkle SVG). Free icon for the Paliadin nav item. | +| `auth.UserIDFromContext(r.Context())` is the standard handler‑side user lookup | code | `internal/handlers/dashboard.go:31` is the canonical pattern. Paliadin handlers will use it. | +| `branding.Name` (default "HLC") is the firm‑name source | t‑paliad‑065 | `internal/branding/firm.go` reads `FIRM_NAME` once at boot. Paliadin's system prompt + greeting must use `branding.Name`, never hardcode "HLC". | +| Single web replica on Dokploy today | `docker-compose.yml` | One `web` service. SSE state in‑process is fine v1; multi‑replica migration deferred along with chat. | + +**Doc‑vs‑live conflicts encountered (must be fixed in the implementation PR):** + +1. **CLAUDE.md** still says `ANTHROPIC_API_KEY` is "Reserved for Phase H (AI Frist‑Extraktion) which is deferred per m's 2026-04-16 decision. Do not set." Paliadin un‑defers it. The CLAUDE.md row needs to flip to "Required for Paliadin (read‑only Claude assistant) — set on Dokploy." +2. The earlier "do not want anthropic API" decision (memory `b6a11b55…`, 2026-04-16) was specifically about *Frist extraction from documents*. Paliadin is a different surface (interactive read‑only Q&A over already‑structured data). It does not silently revive the parked extraction feature — t‑paliad‑011 stays blocked unless m explicitly un‑parks it too. + +--- + +## §2 Sub-design A — LLM architecture, prompt, tool use, mlex/lex reuse + +Answers Q1, Q2, Q3, Q4, Q17, Q18. + +### 2.1 LLM provider (Q1) + +**Recommendation: Anthropic Claude, single provider, accessed directly via the Messages API. Lock to Claude in v1; abstract behind a one‑function interface so future portability is cheap.** + +| Provider | v1? | Why | +|---|---|---| +| Anthropic Claude (Messages API + tool use) | ✅ | Matches m's "wire into my claude" framing. Tool‑use shape is mature. Streaming via SSE is native. Paliad already has `ANTHROPIC_API_KEY` reserved. | +| Mixed (Claude reasoning + smaller routing model) | ❌ | Premature optimisation; for ~30 turns/hour/user we don't need the routing layer. Single‑model latency is fine. | +| OpenAI / open weight | ❌ | No HLC compliance review for those vendors; m's Anthropic key is on file. | + +**Model selection within Anthropic:** default to **Claude Sonnet 4.6** (fast, tool‑use‑capable, cheap enough for chat use). Allow override via `PALIADIN_MODEL` env var so we can drop down to Haiku for cost or up to Opus for tricky onboarding sessions without redeploying. + +**Wire shape:** one Go HTTP client (`internal/services/paliadin/anthropic.go`) that POSTs `/v1/messages` with `stream: true`. We do not adopt `github.com/anthropics/anthropic-sdk-go` in v1 — the API surface we use (one streaming POST + tool‑use loop) is small enough that a hand‑rolled client is shorter than wiring the SDK and safer than depending on a Go SDK that has historically broken on minor version bumps in mAi's experience. Keep the option open for Phase 2 if the token‑accounting / structured tool‑use helpers in the SDK become attractive. + +```go +// internal/services/paliadin/anthropic.go +type AnthropicClient interface { + Stream(ctx context.Context, req MessagesRequest, w StreamWriter) (Usage, error) +} +``` + +The interface is the only swap‑point. Switching providers later means a new implementation, not a rewrite. + +### 2.2 System prompt + message shape (Q2) + +**Recommendation: single `system` prompt with paliad context + tool definitions; one persistent prompt across pages (no per‑route system prompts in v1).** + +#### 2.2.1 System prompt (locked, v1) + +The system prompt is computed at process start from `branding.Name`, the user's locale (DE/EN), the user's `display_name`, the current date, and the visible‑project count (a single count, not the project list — keeps the prompt small). Computed *per request*, not per process — but its template is a constant. + +``` +You are Paliadin, an AI assistant inside {{firm}}'s patent practice +platform "Paliad". You help {{display_name}} ({{office}}) answer +questions about their own work in Paliad and about UPC / EPO / DPMA +patent practice. + +Today is {{today}}. The user's display language is {{language}}; reply +in {{language}} unless the user switches mid‑conversation. + +You have read‑only access to the following tools: +- whats_on_my_plate — the user's dashboard (deadline / appointment / matter buckets) +- list_my_projects — every project the user can see +- get_project_detail — full detail of one project (deadlines, appointments, parties, partner units) +- search_my_deadlines — filter the user's deadlines by status / date / project +- list_my_appointments — the user's upcoming appointments (next 30 days by default) +- lookup_court — Paliad's catalog of patent courts (UPC LDs, German LGs/OLGs/BGH, EPO, DPMA, ...) +- lookup_glossary_term — Paliad's bilingual patent glossary +- lookup_deadline_rule — Paliad's Fristenrechner concept tree (named deadline rules + their triggers) + +Hard rules: +1. Never invent facts. If a tool returns nothing, say so. Do not guess + case numbers, deadline dates, court names, or party names. +2. Every concrete factual claim about the user's work MUST come from a + tool call in the current conversation. Cite using "[#deadline-XXXX]", + "[#projekt-XXXX]", "[court: Munich LD]", "[glossary: Klageerwiderung]" + so the UI can render citation chips. +3. You cannot mutate any data. If the user asks you to change something, + explain that v1 is read‑only and point them to the right page in + Paliad. +4. Visibility is enforced before tools return — if your tool call comes + back empty, the data either doesn't exist OR the user can't see it. + Never disclose the latter; just answer "I couldn't find anything + matching that". +5. You cannot answer questions about other users' projects, even if the + user names them. +6. Respect the user's role. If the user has global_role=standard, do not + speculate about admin‑only functions. + +Style: +- Direct, professional, slightly warm. Lawyer‑adjacent. +- Reply in Markdown. Use lists, code blocks, blockquotes. +- Cite specifically (case numbers, dates, court names) — never "around + the 14th". +- When uncertain, flag it. ("I don't see a deadline matching that + description on the projects you can access.") +- No emojis unless the user uses one first. + +You are NOT: +- A code‑writing assistant +- A replacement for legal advice +- A web search +``` + +This is ~250 input tokens — well under the budget. + +#### 2.2.2 Per‑message envelope + +The browser POSTs to `/api/paliadin/turn` with `{ session_id, user_message, history }`, where `history` is the prior turns *in the current session only* (session = browser tab; localStorage backs it). The server prepends the system prompt and runs the tool‑use loop. + +#### 2.2.3 Tool use vs RAG‑only (Q2 secondary) + +**Tool use, not RAG.** RAG (vector search over chunks of paliad content) is the wrong shape for this surface — paliad data is highly structured, the most useful answers come from filtered SQL queries (e.g. "all deadlines on my projects with `status='pending'` and `due_date<=now()+7d`"), and a vector store would just paraphrase what an SQL query returns more accurately. Tools give the model the same query power the user has, with hard visibility gates. Phase 2 may add RAG over a small static corpus (HL Patents Style guide, Paliadin docs) if onboarding queries don't get good answers from glossary lookups alone. + +### 2.3 Long‑lived service vs lexy‑style worker spawn (Q4) + +**Recommendation: long‑lived Go service (in‑process) — *not* a per‑session Claude Code worker.** + +| Option | Latency to first token | Cost / turn | Operational shape | +|---|---|---|---| +| In‑process Go service calling Anthropic API directly | < 1 s (just network + queueing) | Pay only for the model tokens we use | Single binary, single Postgres conn, scales with paliad | +| `mai hire paliadin` per session (Claude Code worker) | 5–15 s | Worker startup overhead × N concurrent sessions × Claude Code's own context overhead | Operational footprint of running a worker per active user — dozens of tmux panes, tasks, reports | + +The lexy / cassandra worker pattern works because it's *batch*: classify N judgments, emit JSON, exit. A chat surface needs sub‑second response times across dozens of HLC users in parallel. A Claude‑Code‑per‑session pattern would give each user their own Claude in the loop, with all the tooling and message‑bus scaffolding that implies — wrong scale of abstraction. + +**That said, two things from the worker pattern do carry over:** +1. **System‑prompt voice.** The lexy / mai-lexy SKILL.md persona ("Sharp, analytical, direct. Cites provisions and case law naturally. Flags uncertainty honestly.") is the right voice for Paliadin. We borrow it — see §2.2.1. +2. **Tool catalog shape.** The lex-research SKILL.md tool list (search → fetch full text → enrich → analyse → cite) maps cleanly onto Paliadin's read tools — see §3. + +### 2.4 mlex / `/lex-*` carry‑over map (Q3, Q18) + +**Inventory result, with the shape‑vs‑code split called out for each:** + +| Skill / asset | What it does | Carry‑over to Paliadin | +|---|---|---| +| `~/dev/mLex/` (workspace) | `extractions/` (per‑case JSON), `analysis/` (markdown reports), `docs/` (legal references), `extractions/queue.json` | **None as code.** Workspace artifacts are the *output* of the skills — they don't give us anything embeddable. | +| `lex-research` skill | UPC case law search → analysis report. Tool catalog: `mcp__supabase__execute_sql`, `mcp__youpc__*`, `mcp__youpc-memory__*`. Output format: structured markdown with citation tables. | **Voice + tool‑catalog shape.** "Search → enrich → analyse → cite" is the Paliadin flow. The skill's output‑format conventions (case number on first mention, division comparison tables) seed the system prompt's style guidance. | +| `lex-extract` skill | Read full judgment text → structured holdings / principles / interpretations JSON. | **Not v1.** Phase 2 candidate iff Paliadin gets a `extract_judgment(node_id)` write tool — orthogonal to read‑only v1. | +| `lex-classify` skill | Classify judgments against a 47‑leaf taxonomy. | **Not v1.** Same as above — write‑surface, batch‑shaped, irrelevant to interactive Q&A. | +| `lex-classify-patent` skill | Classify patents into IPC technology sectors via Anthropic. | **Pattern reference only.** It's already an Anthropic‑backed pipeline, so its prompt structure is a working example we can crib from for the system‑prompt template — but the actual classification target is paliad‑irrelevant. | +| `mai-lexy` skill | Lawyer persona that orchestrates the above. "Citation‑backed, flags uncertainty." | **Voice template.** The persona text is the closest thing to a working Paliadin system prompt; §2.2.1 borrows directly from it. | +| `claude-api` skill | Anthropic SDK / Messages API patterns + prompt caching guidance. | **Implementation reference for the Go client + caching strategy.** §6.4 picks up its prompt caching guidance. | + +**Anti‑reuse:** the `mcp__youpc__*` MCP tools that `lex-research` uses are designed for an interactive Claude Code session. Paliadin's tools must instead be Go service calls — same data shape, different transport. Don't try to embed an MCP client in a paliad Go process; rebuild the same SQL queries against the same Postgres directly. + +### 2.5 Tool catalog v1 (Q17) + +Seven read‑only tools. Each is a thin Go shim around an existing service; each enforces visibility through that service's existing `visibilityPredicate`. + +| Tool name | Backing service / method | Inputs | Output (truncated to fit budget) | +|---|---|---|---| +| `whats_on_my_plate` | `DashboardService.Get(userID)` | none | `{deadline_summary, appointment_summary, matter_summary, upcoming_deadlines[≤10], upcoming_appointments[≤10], recent_activity[≤10]}` | +| `list_my_projects` | `ProjectService.ListVisible(userID, filter)` | optional `{status, kind}` | `[{id, kind, label, status, parent_id, path}]` paged 25 | +| `get_project_detail` | `ProjectService.Get(userID, id) + DeadlineService.ListByProject + AppointmentService.ListByProject + PartyService.ListByProject + DerivationService.AttachedUnits` | `{project_id}` | `{project, deadlines[≤25], appointments[≤25], parties[≤10], partner_units[≤5]}` — 503 if user can't see it (LLM gets a clean "not found", same response as truly missing) | +| `search_my_deadlines` | new helper on `DeadlineService` (reuses `visibilityPredicate`) | `{q?, status?, project_id?, due_after?, due_before?, limit≤25}` | `[{id, title, due_date, status, project_label, court}]` | +| `list_my_appointments` | new helper on `AppointmentService` | `{from, to, project_id?}` | `[{id, title, start_at, end_at, location, project_label}]` | +| `lookup_court` | `CourtService.Search(q)` (firm‑wide; no visibility filter — courts are reference data) | `{q}` | `[{slug, name, country, kind, address, vacation_periods[≤4]}]` truncated 10 | +| `lookup_glossary_term` | static JSON loader (`internal/handlers/glossary.go` data) | `{q, lang?}` | `[{de, en, definition, category}]` top 5 | +| `lookup_deadline_rule` | `DeadlineRuleService.SearchConcept(q)` | `{q}` | `[{rule_code, concept_label, trigger_event, deadline_text, legal_source}]` top 5 | + +**Bumped out of v1 (Phase 2 candidates):** + +- `list_my_pending_approvals` (the inbox bell payload) — useful but adds RLS surface; let v1 stabilise first. +- `search_youpc_case_law` — m's framing example, but cross‑schema → bigger blast radius. Phase 2 once Paliadin proves its weight on paliad‑internal data. +- `search_my_audit_log` — high signal but PII heavy. +- `compute_frist` — would invoke the existing `DeadlineCalculator`. Useful but the user can already do this on `/tools/fristenrechner`; defer until we see queries that actually want it. +- All write tools (`create_deadline`, `attach_partner_unit`, etc.) — Phase 3 minimum, with hard confirmation gate (see §6). + +### 2.6 The tool‑use loop (Q2 tertiary) + +Standard Anthropic tool‑use loop: + +``` +1. Build messages = [system, ...history, user_message] +2. POST /v1/messages with tools=[...catalog] +3. Stream assistant reply chunks → relay to client SSE +4. If stop_reason == "tool_use": + for each tool_use block: + execute tool(input) on the matching Go service + emit tool_result block back into messages + goto 2 (with the same stream/SSE connection) +5. If stop_reason == "end_turn": close stream +``` + +**Hard cap on the loop:** ≤ 5 tool‑call rounds per turn. After 5 rounds without `end_turn`, force‑close with "Sorry, I got stuck — try rephrasing." Hitting the cap is a UI red flag we want to see in audit (see §6.3). + +--- + +## §3 Sub-design B — Data access, RLS, PII + +Answers Q5, Q6, Q7. + +### 3.1 Knowledge sources for v1 (Q5) + +**Recommendation: paliad‑internal data + paliad's static reference data ONLY. youpc.org case law deferred to Phase 2.** + +| Source | v1 | Reason | +|---|---|---| +| **Per‑user paliad data** (deadlines, appointments, projects, parties, partner units, attached units) | ✅ | The whole point of Paliadin. Visibility enforced via `visibilityPredicate` (every backing service already does this; tool inherits it). | +| **Static reference data** in paliad (court catalog t‑122, glossary, deadline rules, Fristenrechner concept tree) | ✅ | Firm‑wide, no per‑user gating, low blast radius. | +| **UPC case law** (youpc Postgres `data.judgments`, `data.judgment_markdown_content`) | ❌ Phase 2 | Cross‑schema SELECT is technically trivial (same Postgres) but: (a) inflates the v1 surface; (b) brings in 1700+ judgments → scaling RAG/full‑text question; (c) m's framing called out research as a *use case*, not a v1 must‑have. Ship paliad‑internal Q&A first; layer case‑law on once the substrate is proven. | +| **HL Patents Style guide / Paliad onboarding docs** | ❌ Phase 2 | No internal corpus exists yet; would need docs‑authoring + indexing. The `lookup_glossary_term` tool already covers the most common onboarding question shape ("was bedeutet X?"). | +| **External web search** | ❌ | Out of scope; Paliadin is a *grounded* assistant, not a web surfer. m can use the regular Claude for that. | + +**Ranking inside the v1 set (when Paliadin has to choose):** + +1. User‑data tools first when the question references "my", "the case", "the deadline", or names a project / case number that resolves. +2. Static reference next when the question is conceptual ("what's a Klageerwiderung?", "which court is the Munich LD?"). +3. Combine when both apply ("when is my Klageerwiderung due?" → `lookup_deadline_rule` for the rule + `search_my_deadlines` for the user's instance). + +The system prompt names tools in this priority order; the model's tool‑selection follows. + +### 3.2 Auth / visibility boundary (Q6) + +**The gate:** every backing service already runs `visibilityPredicate(alias)` against the caller's UUID. The Paliadin tool shim is a 5‑line wrapper that calls the service with `userID` derived from `auth.UserIDFromContext(r.Context())` at the SSE handler boundary. There is no service‑role escape — the shim simply has no other UUID to pass in. + +**Belt‑and‑braces:** every tool result is inspected for `project_id` columns; for each distinct `project_id`, the shim asserts `paliad.can_see_project(_project_id)` returns `true`. (Defence‑in‑depth: catches any future service‑layer regression where someone forgets the predicate. Costs one extra cheap function call per tool turn; cheap.) + +**The "tell, don't disclose" rule (§2.2.1 hard‑rule 4):** if the user names a project they cannot see, the tool returns `{error: "not found"}` — same response as a project that doesn't exist. The system prompt instructs the model to say "I couldn't find anything matching that" without distinguishing the two cases. This is the same rule the t‑144 ViewService already applies. + +**Cross‑user PII in tool outputs:** tool outputs may legitimately contain other users' display names (e.g. project teams, deadline assignees). These are visible to the caller through the regular UI already, so disclosing them through Paliadin is no worse. We do NOT redact them. + +**Approval / partner‑unit derivation:** `get_project_detail` returns the derived team (per t‑139 `DerivationService.AttachedUnits`). Same predicate as the rest of the app. + +### 3.3 PII handling, retention, encryption (Q7) + +**v1 stance: minimum viable persistence, maximum auditability of the access pattern.** + +| Data | Stored where | Retention | Encryption | Notes | +|---|---|---|---|---| +| Conversation history (the actual messages) | **Browser localStorage only.** Cleared on browser data wipe / reload‑with‑fresh‑session. | Session only | n/a | Phase 2: opt‑in DB persistence with retention controls. | +| Per‑request audit row | New `paliad.paliadin_turns` table | Forever (matches audit‑log pattern; soft‑delete only) | At‑rest by Postgres / Supabase volume encryption | Stores: `turn_id, user_id, started_at, finished_at, model, input_tokens, output_tokens, tool_calls (jsonb of tool names + arg hashes — NOT arg values), prompt_hash (sha256 of redacted user message), error_code`. **No prompt body, no completion body.** | +| Tool‑call inputs (e.g. project_id arguments) | Hashed (sha256) into the audit row's `tool_calls` jsonb | Forever | n/a | The hash is enough to detect "this user kept asking about project X" patterns without storing the readable id. | +| Anthropic API request/response bodies | **Not stored.** Streamed through the Go service straight to the SSE writer. | n/a | TLS in flight | Anthropic's own retention is governed by the org's API contract — pulling Paliad onto an existing HLC enterprise key would inherit that. | + +**Why this shape:** + +- **Compliance‑lite v1.** HLC's compliance team has not yet weighed in on AI‑mediated PII (memory says the Phase H decision was "we don't want anthropic API… for a while"). Storing the full transcript opens a retention/disclosure question we don't need to answer to ship Paliadin's MVP. The audit‑metadata row is enough to demonstrate: (a) who used it, (b) how often, (c) what tools they triggered, (d) cost. +- **Phase 2 transcript persistence** would add a `paliadin_messages` table (turn_id FK, role, content, redact_marks jsonb) and a per‑user setting "keep my history". Default off. +- **Why no PII redaction in the user prompt?** v1 is opt‑in (the user typed the prompt). Redacting client names / case numbers in the audit hash would defeat the point; we redact by *not storing the prompt*, only its hash. + +**The Anthropic side:** if HLC's enterprise contract forbids vendor‑side retention, the Go client must set `metadata: {user_id: ""}` and ensure the API call is on an org with zero‑retention guarantees. **Open question for m: which Anthropic key are we using — m's personal key (existing `ANTHROPIC_API_KEY` precedent in mAi/youpcms) or a new HLC enterprise key?** This is the single biggest compliance question; see §9.2. + +--- + +## §4 Sub-design C — UX + +Answers Q8, Q9, Q10, Q11, Q12. + +### 4.1 Surface placement (Q8) + +**Recommendation (counter to brief): start with a dedicated `/paliadin` full‑page route + a sidebar entry under the "Übersicht" group. Defer the right‑drawer to Phase 2.** + +| Option | v1? | Why | +|---|---|---| +| **`/paliadin` full page** + sidebar entry | ✅ | Lowest CSS risk; mobile‑responsive for free (paliad's existing breakpoints work); easy to test via Playwright; matches paliad's "every feature is a top‑level page" pattern; no z‑index / overlay debugging. | +| Right‑drawer slide‑out from any page | ❌ Phase 2 | Pretty, matches m's "panel docked into UI" framing — but adds: drawer toggle wiring on all 30 pages, scroll‑lock interaction, focus management, mobile small‑screen fallback. Not worth the v1 surface area. Phase 2 wraps the same `/paliadin` UI in a slide‑out container. | +| Floating bottom‑right bubble | ❌ | Clippy comparison is *visual*, not *positional*. A floating overlay on every page collides with the BottomNav on mobile (already 5/5 slots) and the inbox bell on desktop. | +| Page‑embedded panel on `/paliadin` only | — | This *is* the v1 recommendation, just framed differently. | + +**Sidebar entry:** + +``` +Übersicht + Start + Agenda + Inbox 🛎 + Paliadin ✨ ← new, ICON_SPARKLE +``` + +Group placement under Übersicht (not under Tools or Wissen) because Paliadin is conversation about *the user's work*, not a knowledge tool. + +**Mobile:** Paliadin is reachable via the sidebar drawer (existing mobile pattern). No BottomNav slot — those are full and the ranking (Start / Projekte / + / Agenda / Menü) is more important than a chat shortcut for v1. + +### 4.2 Avatar / personality (Q9) + +**Recommendation: no avatar SVG in v1. Just a chat panel with the name "Paliadin" in the header. Mascot is Phase 2.** + +Why: + +- Mascot design is a real design exercise (3–4 iterations to get something that doesn't read as kitsch in a law firm). Not inventor's call to bash one out in a v1 ship. +- The brand cue (lime‑green `#c6f41c` accent) is enough to make Paliadin feel like part of paliad without a character. +- Paliadin's *personality* lives in the system prompt (§2.2.1), not in pixels. Voice carries the buddy framing; mascot makes it visual but isn't load‑bearing. + +What we ship in v1 instead: + +- Header: "✨ Paliadin" (sparkle icon + name) above the chat panel. +- Empty‑state prompt: "Was kann ich für dich tun?" (DE) / "How can I help?" (EN). +- One‑line tagline under the header: "Ich kenne deine Akten und Paliads Wissensbasis." (DE) / "I know your matters and Paliad's knowledge base." (EN). This is the *only* v1 affordance that explicitly tells the user "I see your data" — load‑bearing for the differentiation argument in §0/§9.1. + +**Phase 2 mascot brief (for when m greenlights it):** small SVG, friendly, lime‑green primary, no eyes‑darting / animated‑on‑idle (creepy), modular pose set so it can react to "thinking" / "found it" / "stuck" without being an MMORPG pet. + +### 4.3 Onboarding hint (Q10) + +**Recommendation: silent‑until‑invoked. No proactive pop‑up, no first‑run modal, no toast.** + +Why: + +- Paliad already has a polished onboarding flow (t‑paliad‑034). Adding a Paliadin pop‑up on top would be the kind of "surprise the user" affordance that erodes trust the first time it misfires. +- The empty‑state inside `/paliadin` itself is the right onboarding surface: 3 starter‑prompt buttons rendered when the chat is empty. + +**Three starter prompts (DE primary):** + +1. "Was steht heute an?" → triggers `whats_on_my_plate` +2. "Welche Fristen sind diese Woche fällig?" → triggers `search_my_deadlines` with `due_before=now()+7d` +3. "Erkläre mir Klageerwiderung." → triggers `lookup_glossary_term` + `lookup_deadline_rule` + +EN equivalents: "What's on my plate?" / "Which deadlines are due this week?" / "Explain Klageerwiderung." + +Picking one from the row sends it as if the user typed it. Keeps the surface zero‑weight when ignored. + +**Phase 2 candidate:** post‑onboarding email / inbox card "Paliadin ist live, frag ihn was deine Daten dir sagen." Driven by the existing reminder/email substrate. Out of v1 scope. + +### 4.4 Action chips in responses (Q11) + +**Recommendation: action chips parsed from a simple inline syntax in the model's reply, rendered client‑side, NOT a tool the model invokes.** + +Why simple syntax over a tool: tool invocations cost a round‑trip; we want the model to "suggest" an action without paying for an extra tool turn. The model emits a structured marker in its prose; the frontend client parses it and renders a chip below the bubble. + +**Marker format:** + +``` +[#deadline-OPEN:c47bd2] +[#projekt-OPEN:slug-x] +[#frist-OPEN:c47bd2] +[#termin-OPEN:abc123] +[chip:nav:/projects/abc-123] (for arbitrary navigation) +[chip:filter:status=pending&due=this_week] (for parameterised inbox links) +``` + +The system prompt teaches the model to emit chips when navigation or filtering would help the user act on the answer. Each marker resolves to one chip, rendered as: + +``` +┌──────────────────────────────────────┐ +│ Frist 16.05.2026 fällt morgen. │ +│ [Frist öffnen] [Akte ansehen] │ +└──────────────────────────────────────┘ +``` + +**Client parser** (`frontend/src/client/paliadin.ts`): regex over the streamed text, replaces marker with a button. Buttons are real `` elements (Cmd‑click works, keyboard works), styled like the existing `.entity-table` row chips. + +**Why not let the model embed full URLs?** Two reasons: +1. URLs change (we renamed `/akten` → `/projekte` mid‑project). Markers are stable; we resolve them at render time. +2. Hallucinated URLs are real risk. If the model can only emit a marker tied to an id we *know* it just retrieved, the chip can't navigate to a fake page. + +### 4.5 Streaming + interruption (Q12) + +**Recommendation: SSE stream from `/api/paliadin/stream`, client EventSource, user‑initiated abort via "Stop" button.** + +#### 4.5.1 Stream shape + +Mirrors Anthropic's native streaming events, adapted for our SSE consumer: + +``` +event: meta +data: {"turn_id":"01H…","model":"claude-sonnet-4-6"} + +event: content_delta +data: {"text":"Auf der Akte Müller…"} + +event: tool_call +data: {"name":"search_my_deadlines","args_hash":"…","status":"running"} + +event: tool_result +data: {"name":"search_my_deadlines","status":"ok","summary":"3 results"} + +event: content_delta +data: {"text":"… ist die Klageerwiderung am 16.05. fällig."} + +event: chip +data: {"kind":"deadline","action":"open","id":"c47bd2"} + +event: end +data: {"input_tokens":342,"output_tokens":88,"tool_calls":1} + +# heartbeat every 25 s to keep Traefik from reaping +event: ping +data: {} +``` + +The `tool_call` / `tool_result` events are visible in the UI as small dim "ran search_my_deadlines (3 results)" lines under the bubble — the **citation evidence** that distinguishes Paliadin from a generic chatbot. (Direct quote from the §0 framing: "the differentiation collapses if v1 doesn't make the data‑grounding visible.") + +#### 4.5.2 Interruption + +- "Stop" button next to the input. Click → `EventSource.close()` + `fetch('/api/paliadin/stream/{turn_id}/abort', {method:'POST'})`. +- Server abort closes the upstream Anthropic request via context cancellation. +- Stopped turns still write an audit row with `error_code='user_aborted'` so we see how often users hit it. + +#### 4.5.3 Reconnect + +Same Last‑Event‑ID resume pattern the t‑145 chat design specced. Server keeps the in‑flight stream buffered for 30 s after disconnect; reconnect within that window replays missed events. After 30 s, the turn is considered done — reconnect arrives at the start of a fresh session. + +--- + +## §5 Sub-design D — Token budget, cost, audit + +Answers Q13, Q14, Q15, Q16. + +### 5.1 Per‑request token cap (Q13) + +**Recommendation: `max_input_tokens=4000` (model's view of input including system + history + tool defs + user msg) and `max_tokens=2000` (model's max output) — same as brief. Hard‑fail above; soft‑truncate history below.** + +Rationale: + +- A typical paliad data tool result is < 500 tokens (truncated lists, capped at 25 rows). Even with system prompt (~250) + tool defs (~600) + 5 prior turns (~600 each on average) the input stays well under 4 k. +- If the conversation runs long (~8+ turns), the client/server soft‑truncates history (drops oldest user/assistant pairs first) before sending. The user sees a "Earlier in this conversation, we discussed X (truncated)" pseudo‑system message. Cleaner than failing the turn. +- Hard cap at 6 k input tokens — over that, refuse the turn with "Conversation too long, start a new one." Defends against jailbreak attempts that try to balloon the prompt. + +**Cost math at Sonnet 4.6 per‑turn typical (3 k input, 1 k output):** ~$0.012/turn. At 30 turns/hour/user × 38 onboarded HLC users × 5 working hours/day = ~5 700 turns/day = **~$70/day worst case**. Realistic load is probably 10× lower. Phase 2: prompt caching (§5.4) drops it further. + +### 5.2 Conversation history persistence (Q14) + +**Recommendation: session‑only in v1. Persistent threads in Phase 2.** + +| Option | v1? | Why | +|---|---|---| +| Session‑only (browser localStorage, cleared on tab close + Sign Out) | ✅ | Zero schema. Zero retention question. Aligns with §3.3 "minimum viable persistence." Lets us ship paliadin without compliance review of stored transcripts. | +| Persistent threads (DB‑stored, named) | ❌ Phase 2 | Real schema (`paliadin_threads`, `paliadin_messages`), retention policy, cross‑device sync, "delete my history" UX, possibly opt‑in toggle. None of which is needed to validate "is Paliadin actually useful". | + +**Edge case: page reload during a conversation.** localStorage persists the history *for that browser tab*. Closing and reopening the tab restores. Closing the browser & reopening also restores. Sign‑out clears. Multi‑device = different histories. We're explicit about this in the panel header: "Conversation lives in this browser only" tooltip. + +**Why opt for slightly worse UX over the easy schema work:** the t‑paliad‑145 chat just got parked over an *adoption*‑risk concern, not a schema concern. Paliadin should ship the smallest possible footprint that proves usefulness. Persistent threads can be a "you asked for this" Phase 2. + +### 5.3 Rate limit per user (Q15) + +**Recommendation: 30 turns/hour/user (slightly tighter than the brief's 50). Plus a global ceiling of 1 000 turns/hour across the firm. Both configurable.** + +Per‑user 30/hour because: + +- 30/hour ≈ one turn every two minutes during sustained use. That's heavy use. A reasonable user asks 3–5 questions in a session. +- Soft hint at 25 ("you've used 25 of 30 messages this hour"), hard block at 30 with retry‑after. +- Lower than 50 to give us a safety margin for runaway cost in week 1; we can raise it once we see real usage. + +Global 1 000/hour ceiling because: + +- Global cap = circuit breaker against the long tail (a script that sends 1000 turns/hour from one user we missed in the per‑user cap, or a developer bug). +- 1 000 turns × ~$0.012 = $12/hour worst case = $288/day. We tolerate that for a day; we'd notice and tune. + +**Storage:** simple Postgres `paliad.paliadin_rate_limit` table with `(user_id, hour_bucket, turn_count)` upserted on every turn start. No Redis, no extra dependency. Fast at this scale. + +**Admin override:** global_admin can lift their own cap (they typically test things). Surface this in the audit row, not in a CLI. + +### 5.4 Audit + logging (Q16) + +**Recommendation: every turn writes a metadata‑only row to `paliad.paliadin_turns`. Full transcripts are NOT stored in v1. Tool‑call args are hashed. Anthropic vendor side is governed by org‑level retention.** + +#### 5.4.1 Schema (migration 057) + +```sql +CREATE TABLE paliad.paliadin_turns ( + turn_id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES paliad.users(id), + session_id text NOT NULL, -- browser session, opaque + started_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz, -- NULL until end‑of‑turn + model text NOT NULL, -- e.g. 'claude-sonnet-4-6' + input_tokens int, -- from Anthropic usage block + output_tokens int, + tool_calls jsonb NOT NULL DEFAULT '[]', -- [{name, args_hash, status, latency_ms}] + prompt_hash text, -- sha256 of user_message after PII redaction (best effort) + response_hash text, -- sha256 of full response (citation only, not stored) + chip_count int NOT NULL DEFAULT 0, + error_code text, -- NULL on success; 'user_aborted', 'rate_limited', 'token_cap', 'tool_loop_cap', 'upstream_error' + estimated_cost_usd numeric(10, 6) -- for ops dashboards +); + +CREATE INDEX paliadin_turns_user_started_idx + ON paliad.paliadin_turns(user_id, started_at DESC); +CREATE INDEX paliadin_turns_started_idx + ON paliad.paliadin_turns(started_at DESC); + +ALTER TABLE paliad.paliadin_turns ENABLE ROW LEVEL SECURITY; + +-- User sees their own; global_admin sees all. +CREATE POLICY paliadin_turns_select + ON paliad.paliadin_turns FOR SELECT + USING ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.global_role = 'global_admin') + ); + +-- Service-role (paliad backend) writes; no user‑direct INSERT. +-- (Paliad uses service-role conn, so policies on writes are inert, +-- but we still ENABLE RLS so future direct‑auth callers are gated.) +``` + +Rate‑limit table also lives in this migration: + +```sql +CREATE TABLE paliad.paliadin_rate_limit ( + user_id uuid NOT NULL REFERENCES paliad.users(id), + hour_bucket timestamptz NOT NULL, + turn_count int NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, hour_bucket) +); +``` + +#### 5.4.2 What we DON'T store (v1) + +- The user's actual prompt text. Only `prompt_hash`. +- The model's actual response text. Only `response_hash`. +- The tool inputs. Only `tool_calls[].args_hash`. + +**Phase 2 transcript persistence** unlocks all three — deliberately separate migration so the compliance review sits at *that* boundary. + +#### 5.4.3 Vendor retention + +The Anthropic side is governed by the org‑level contract. **Open question for m (§9.2):** does HLC have an enterprise / zero‑retention agreement, or are we using m's personal key (matches existing `ANTHROPIC_API_KEY` precedent in mAi/youpcms)? The answer changes whether v1 needs a "data sent to Anthropic" disclosure on first use. + +#### 5.4.4 Prompt caching (Phase 2) + +The Anthropic API supports prompt caching for repeated system prompts + tool definitions. Our system prompt + 7 tool defs is ~850 tokens — perfect cache target. Phase 2: enable cache_control on the system block; cuts input cost by ~90% on repeat turns within the 5‑minute cache window. Skip in v1 to keep the client minimal; pick up after the API surface stabilises. + +--- + +## §6 Schema, endpoints, files + +### 6.1 New endpoints + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| `POST` | `/api/paliadin/turn` | Initiate a turn — assigns `turn_id`, opens SSE | logged‑in (302 to /login otherwise) | +| `GET` | `/api/paliadin/stream/{turn_id}` | SSE stream of the turn's response (mostly invoked from the same `POST` to keep the connection live; separate GET supports reconnect) | logged‑in | +| `POST` | `/api/paliadin/stream/{turn_id}/abort` | User cancels mid‑turn | logged‑in, must own the turn | +| `GET` | `/api/paliadin/limits` | Returns `{used_this_hour, hourly_cap, global_cap, global_used}` | logged‑in | +| `GET` | `/paliadin` | The page shell (server‑renders the panel + initial empty state) | logged‑in | +| `GET` | `/admin/paliadin` | Per‑user usage / cost dashboard | global_admin | + +The `POST /api/paliadin/turn` returns `{turn_id, sse_url}`; the client opens an `EventSource` on `sse_url`. Two‑step keeps the POST cheap for telemetry / audit row creation, while the long‑lived stream lives on a GET that's safe to retry / resume. + +### 6.2 New / extended services + +| File | Status | Purpose | +|---|---|---| +| `internal/services/paliadin/service.go` | NEW | The orchestrator: run loop, history truncation, rate‑limit check, audit‑row writer | +| `internal/services/paliadin/anthropic.go` | NEW | Hand‑rolled Messages API client (POST `/v1/messages`, stream parser) | +| `internal/services/paliadin/tools.go` | NEW | Tool catalog declaration + dispatch into existing services | +| `internal/services/paliadin/prompt.go` | NEW | System prompt template + per‑turn assembly | +| `internal/handlers/paliadin.go` | NEW | HTTP / SSE handlers | +| `internal/services/deadline_service.go` | extend | Add `SearchVisible(userID, q, status, projectID, dueAfter, dueBefore, limit)` (currently search is only on the global Fristenrechner matview) | +| `internal/services/appointment_service.go` | extend | Add `ListVisibleInWindow(userID, from, to, projectID)` | +| `internal/services/glossary_service.go` | NEW (or refactor of glossary handler data load) | A real service so the tool can call it; today it lives inline in the handler | + +### 6.3 Frontend + +| File | Status | Purpose | +|---|---|---| +| `frontend/src/paliadin.tsx` | NEW | Page shell | +| `frontend/src/client/paliadin.ts` | NEW | Chat panel, EventSource, history serialise to localStorage, chip parser, "Stop" button | +| `frontend/src/styles/global.css` | extend | New CSS section: `.paliadin-panel`, `.paliadin-bubble`, `.paliadin-bubble--user/--assistant/--tool`, `.paliadin-chip`, `.paliadin-input`, `.paliadin-meta` | +| `frontend/src/components/Sidebar.tsx` | extend | Add Paliadin navItem to the Übersicht group with `ICON_SPARKLE` | +| `frontend/src/i18n-keys.ts` | extend | ~25 new keys: `paliadin.title`, `paliadin.tagline`, `paliadin.starter.*`, `paliadin.empty`, `paliadin.input.placeholder`, `paliadin.stop`, `paliadin.rate_limited`, `paliadin.error.*` | + +### 6.4 Migration 057 + +``` +057_paliadin.up.sql: + - paliad.paliadin_turns (audit row, RLS, indexes) + - paliad.paliadin_rate_limit (counter table, PK on user+hour) + - GRANTs: service-role full, anon read disallowed by RLS +057_paliadin.down.sql: drop both tables. +``` + +### 6.5 Env vars (add to CLAUDE.md table) + +| Variable | Required | Purpose | +|---|---|---| +| `ANTHROPIC_API_KEY` | for Paliadin | Anthropic Messages API key. **Replaces** the "do not set" row that referred to the parked Phase H. Without it, `/paliadin` returns 503 (server still boots; the rest of paliad keeps working). | +| `PALIADIN_MODEL` | optional (default `claude-sonnet-4-6`) | Override model for tuning / fallback to Haiku for cost or Opus for accuracy without redeploying. | +| `PALIADIN_HOURLY_CAP` | optional (default `30`) | Per‑user turn cap per hour. | +| `PALIADIN_GLOBAL_HOURLY_CAP` | optional (default `1000`) | Firm‑wide turn cap per hour. | +| `PALIADIN_MAX_INPUT_TOKENS` | optional (default `4000`) | Soft cap; over this we truncate history. | +| `PALIADIN_MAX_OUTPUT_TOKENS` | optional (default `2000`) | Hard cap; passed straight to Anthropic. | + +The Service must boot **without** `ANTHROPIC_API_KEY` (return 503 on `/paliadin*` routes; rest of paliad keeps working). Same pattern as `DATABASE_URL` and `CALDAV_ENCRYPTION_KEY`. + +--- + +## §7 Sub-design E — Phasing + +Answers Q19, Q20. + +### 7.1 Phase 1 (v1) — confirmed scope + +**Single coherent slice that proves the value proposition end‑to‑end.** + +| Item | In v1 | +|---|---| +| `/paliadin` page + sidebar entry under Übersicht | ✅ | +| Migration 057 (`paliadin_turns` + `paliadin_rate_limit`) | ✅ | +| Anthropic client (hand‑rolled, streaming) | ✅ | +| 7 read‑only tools | ✅ | +| System prompt with `branding.Name` + visibility rules | ✅ | +| SSE stream with `meta`/`content_delta`/`tool_call`/`tool_result`/`chip`/`end`/`ping` events | ✅ | +| Citation chips (parsed from inline markers) | ✅ | +| Rate limiting (per‑user + global) | ✅ | +| Audit row per turn (metadata only, no transcript) | ✅ | +| Session‑only history (browser localStorage) | ✅ | +| 3 starter prompts in DE+EN | ✅ | +| Token caps + soft history truncation | ✅ | +| `/admin/paliadin` cost dashboard (global_admin only) | ✅ | +| ~25 i18n keys (DE+EN) | ✅ | +| Mobile responsiveness (uses sidebar drawer like every other page) | ✅ | +| CLAUDE.md update flipping the `ANTHROPIC_API_KEY` row | ✅ | + +**Estimated scope:** ~3 500–4 500 LoC for the bundled v1 ship. Comparable to t‑144 (Custom Views) and t‑145's would‑have‑been chat slice. + +**Single PR or split?** Recommend **single PR** for v1. The Anthropic client + tool dispatch + handler + frontend panel are too tightly coupled to ship one without the others — every component is on the critical path of "demonstrate Paliadin actually works". Splitting buys nothing review‑wise (no reviewer can validate "Anthropic client works" without "the tool dispatch that exercises it"). Use the same single‑PR pattern as t‑144 A1+A2 in retrospect. + +### 7.2 Phase 2 candidates (post‑v1, prioritised) + +In rough order of value: + +1. **Persistent threads** + per‑user "keep my history" toggle. Adds `paliadin_threads` + `paliadin_messages` tables, retention policy, cross‑device sync. Compliance review attaches here, not to v1. +2. **Prompt caching** for system prompt + tool defs. ~90 % input‑cost reduction on repeat turns. Pure server‑side change. +3. **`search_youpc_case_law` tool.** Cross‑schema SELECT into `data.judgments` + `data.judgment_markdown_content`. Returns case number, division, date, headnote, top 3 holdings. The "research assistant" use case from m's framing. +4. **Right‑drawer mode.** Wrap the `/paliadin` panel in a slide‑out container; toggle on every page from a header button. +5. **Mascot SVG** + idle / thinking / found‑it pose set. Real visual design pass. +6. **Onboarding tip** — post‑onboarding inbox card or one‑time toast on first dashboard visit after Paliadin lands. +7. **`list_my_pending_approvals` tool.** Wraps inbox bell payload. +8. **Voice input / output.** Web Speech API (paliad already has the substrate from the no‑Voice‑v1 t‑paliad‑042 PWA). + +### 7.3 Phase 3 candidates (validate first) + +- **Write tools.** `create_deadline`, `create_appointment`, `attach_partner_unit`, `add_party`. Each behind a hard confirmation gate ("Paliadin will create a deadline 16.05. on project X — confirm? [Yes / No]"). Audit‑row marks these as mutating turns. Heavy compliance question; not Phase 2. +- **Per‑deadline / per‑termin micro‑threads.** Long‑lived per‑entity Q&A. Plumbing collision with the (parked) chat design — re‑evaluate when chat un‑parks. +- **Proactive Paliadin.** Push tips when the user hits a known confused state ("You've been on /tools/fristenrechner for 8 minutes — want me to walk you through it?"). Powerful, but creepy if poorly tuned. +- **Compliance‑aware redaction layer.** Strip client names from the prompt before it leaves the building, swap stable hashes back in client‑side. Big project; only sensible if HLC compliance forbids vendor‑side PII. + +--- + +## §8 Risks, mitigations, open questions + +### 8.1 Adoption risk (the §0 callout, expanded) + +**The risk:** Paliadin competes with three things HLC already has: +1. The user's own Claude / ChatGPT in another tab (for general patent‑practice questions). +2. "Ask a colleague on Teams" (for paliad‑specific questions about how to use the app). +3. Just clicking around the UI (for "what's on my plate today"). + +Paliadin's edge over (1) is data grounding. Edge over (2) is 24/7 + privacy. Edge over (3) is conversational discovery and answering one‑shot natural‑language queries that the structured UI doesn't expose. + +**The risk realised:** if v1 doesn't make the data‑grounding visible (citation chips, tool‑call evidence under each bubble, the tagline "I see your data"), users default to ChatGPT for everything, and Paliadin becomes a ghost feature that ate 3 weeks of build. Same pattern that just parked t‑paliad‑145. + +**Mitigations baked into v1:** + +- **Tool‑call evidence visible** in every bubble. The user *sees* "ran search_my_deadlines (3 results)" — instant differentiation from a generic chatbot. +- **Citation chips** make answers actionable, not just informative. +- **Tagline + empty state** explicitly say "I see your projects." +- **Three starter prompts** demonstrate the data‑grounding immediately on first use. + +**Mitigations m should consider before approving:** + +- **Sanity‑check with two PA colleagues** before locking v1 scope. Same recommendation t‑145 got. If two PAs say "I'd just open Claude in another tab", the scope shifts toward making the data‑grounding *more* prominent (e.g. ship "Paliadin sees only your data" as a persistent banner above the input, not a tooltip) before shipping at all. +- **Soft launch + telemetry.** v1's audit row gives us cheap measurement of: (a) total turns/day, (b) turns per user, (c) tool‑call frequency (low = Paliadin is being used like ChatGPT, defeating the differentiation). Watch for two weeks; if tool‑calls/turn < 1.5 average, the feature isn't doing what we shipped it for and Phase 2 priorities change. + +### 8.2 Compliance / vendor‑data risk + +**The risk:** sending client names + case content to Anthropic's API may not be sanctioned by HLC IT/compliance. The 2026‑04‑16 "we don't want anthropic API… for a while" decision (memory `b6a11b55…`) was about *Frist extraction from documents*; Paliadin is conversational, but the data envelope sent to Anthropic still contains PII whenever a tool returns a project name. + +**Mitigations:** + +- **HLC enterprise key** (vs m's personal key) if available — gives org‑level retention + DPA coverage. +- **Zero‑retention configuration** on the Anthropic call (`metadata: {user_id: ""}`, `cache_control` only on the system block, no `eval` enrolment). +- **First‑use disclosure** in the panel: "Your messages and the data Paliadin retrieves on your behalf are sent to Anthropic. [Learn more]" — load‑bearing and required if the legal answer to §9.2 is "personal key, not enterprise". +- **Phase 2 hardening:** server‑side redaction layer that swaps client names → stable hashes before the API call, restores them client‑side after. Big project; only sensible if compliance forbids vendor‑side PII. + +### 8.3 Rate‑limit / runaway‑cost risk + +**The risk:** a user (or a bug) loops fast enough to drain budget before alarms fire. + +**Mitigations:** + +- Per‑user 30/hour + global 1 000/hour caps (§5.3). Both surfaced on `/admin/paliadin`. +- Per‑turn token cap (§5.1). +- Per‑turn tool‑loop cap (≤ 5 rounds, §2.6). +- Audit row written *before* the upstream call so a rate‑limit‑evading bug still leaves traces. +- `PALIADIN_HOURLY_CAP` / `PALIADIN_GLOBAL_HOURLY_CAP` are env‑var configurable so we can tighten without a deploy. + +### 8.4 Hallucination risk (model invents a deadline) + +**The risk:** the model fabricates a deadline date / case number that doesn't exist in the user's data. + +**Mitigations:** + +- Hard rule in system prompt: "Every concrete factual claim about the user's work MUST come from a tool call in the current conversation." +- Citation markers tied to tool‑result IDs only. Marker `#deadline-OPEN:c47bd2` resolves only if the id was returned by a real tool call this turn (frontend validates). +- Tool‑call‑evidence visibility: the user can see that a tool ran and what it returned. Hallucination becomes obvious because the chip says "0 results" but the bubble claims a deadline. +- **Phase 2:** server‑side post‑hoc validation that checks every cited id against the tool‑result set; reject the message and retry if the model invented one. + +### 8.5 Open questions for m (please decide before coder shift) + +1. **Q‑A:** Anthropic key — m's personal key (existing pattern, fast) or HLC enterprise key (compliant, slower setup)? §3.3 + §8.2. +2. **Q‑B:** First‑use disclosure required? Yes if (Q‑A = personal key) OR if compliance hasn't reviewed. +3. **Q‑C:** Default model — Sonnet 4.6 (recommendation) or Haiku 4.5 (cheaper)? Sonnet's tool‑use quality is a meaningful step up; Haiku is fine for "what's on my plate" but weaker on multi‑tool conversations. +4. **Q‑D:** Sanity‑check with two PAs before locking scope? (Same recommendation that just parked t‑145.) If yes, this is the gate before any coder shift starts. +5. **Q‑E:** Surface — confirm `/paliadin` full page + sidebar entry, drawer deferred? Or push for drawer in v1? +6. **Q‑F:** Mascot — defer to Phase 2 (recommendation), or commission an inventor‑separate design doc now so we can ship Paliadin with the visual identity? +7. **Q‑G:** Starter prompts — are the three I picked the right entry points, or are there better DE‑first one‑liners that map to common HLC PA queries? +8. **Q‑H:** Should Paliadin know `branding.Name` of the firm in its system prompt? Recommendation: yes (warmer voice, "in HLC's patent practice platform"). Risk: if `FIRM_NAME` rotates, prompt rotates with it; cache invalidates. Acceptable. +9. **Q‑I:** Per‑user 30/hour cap — too low? Too high? Easy to tune later, but worth a sanity check. +10. **Q‑J:** youpc case‑law lookup tool — keep it firmly in Phase 2, or fast‑track if HL research is high‑value? +11. **Q‑K:** Audit row retention — forever (current recommendation, matches audit‑log pattern), or a fixed window (e.g. 90 days for cost rows, forever for compliance‑relevant)? +12. **Q‑L:** Default language — auto‑detect from user `locale` (`paliad.users.locale` is a known pref), or follow the user's last‑message language? Recommendation: start in user's locale; switch on first non‑locale user message. + +--- + +## §9 What this design does NOT cover (deliberately) + +- **The implementation.** This is a design pass; coder shift writes the code. No commits beyond this doc on the inventor branch. +- **Mascot visual design.** Phase 2; deserves its own design pass (and probably a designer's eye, not an inventor's). +- **HL Patents Style guide ingestion.** Out of v1; Phase 2 RAG candidate. +- **Voice input / TTS output.** Phase 2. +- **Multi‑user collaboration (e.g. share a paliadin chat).** Out of scope; users have their own visibility, and joint chat is a chat‑feature shape (parked). +- **Offline mode.** Paliadin is online‑only by definition (it calls Anthropic). The PWA service worker should NOT cache `/paliadin` responses. +- **The renaming question.** "Paliadin" is m's name. Locked. + +--- + +## §10 Recommended implementer + +Same recommendation as t‑145: **noether, or a fresh coder Sonnet that has noether's substrate context.** NOT cronus per the standing memory directive on paliad. + +Why: + +- Substrate touchpoints are the same set the chat design covered: `visibilityPredicate`, `auth.UserIDFromContext`, sidebar entry pattern, migration tracker discipline, Dashboard/Agenda/Project/Deadline service interfaces. noether built half of these; the other half noether mapped during the chat design pass. +- Anthropic Go client is novel in paliad but is small and well‑specified by §6.2 + the `claude-api` skill. +- Front‑end SSE consumer + chip parser is a one‑page TS file. + +--- + +## §11 End of design — STOP + +This is the inventor deliverable. Per the role brief: **STOP after design. Do not begin implementation. Do not load `/mai-coder`.** Wait for m's explicit go/no‑go on the questions in §8.5 before any coder shift starts. + +The completion signal sent to head will use the literal phrase **"DESIGN READY FOR REVIEW"** so the head's gate fires. From 52ee319fd8adec102ba096f5c03cb217c601f737 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 7 May 2026 20:58:57 +0200 Subject: [PATCH 2/8] =?UTF-8?q?feat(t-paliad-147):=20bulk=20team=20email?= =?UTF-8?q?=20=E2=80=94=20send=20to=20filtered=20selection=20from=20/team?= =?UTF-8?q?=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends personalised emails to a filter-narrowed subset of the team. Each recipient gets their own envelope (per-recipient privacy, no shared To: list); From stays on the SMTP infrastructure address with Reply-To set to the human sender so replies route correctly without forging DKIM/SPF. Backend - Migration 057: paliad.email_broadcasts (subject, body, sender_id, template_key, recipient_filter jsonb, recipient_user_ids uuid[], send_report jsonb, sent_at). RLS: senders read own rows, global_admin reads all; inserts must self-attribute. No CHECK-constraint extension to partner_unit_events — broadcasts get their own table per the lock. - BroadcastService (internal/services/broadcast_service.go): validates subject/body/recipient cap (100), enforces project_lead-OR-global_admin, persists audit row, dispatches via 5-deep goroutine pool with 15s per-send timeout. Send report (sent/failed counts + per-recipient errors) is captured back into email_broadcasts.send_report. - markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**, *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped first; only whitelisted tags re-emitted. Script tags and javascript: URLs can't slip through. - Placeholder substitution: {{name}}, {{first_name}}, {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass through unchanged. - mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header on top of the existing multipart/alternative envelope. - TeamService.ListMembershipsIndex: visibility-gated user→project_ids index. Powers the /team project multi-select filter without N round trips per project. - Handlers: POST /api/team/broadcast (gateOnboarded; service enforces authority), GET /api/team/memberships, GET /api/admin/broadcasts (list), GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page). /admin/broadcasts is gateOnboarded (not adminGate) so leads can see their own sends; the service applies the per-row visibility filter. Frontend - /team gains a project multi-select chip dropdown (visible projects loaded from /api/projects, intersected against the memberships index) alongside the existing office and role filters. - "E-Mail an Auswahl (N)" button appears only when canBroadcast() is true (global_admin always; non-admin needs lead-ship on selected projects, or at least one project when no filter is set). Server still re-checks per send. - Compose modal (broadcast.ts): subject + body textarea + optional template dropdown (loads existing email templates and strips Go-template directives) + recipient preview (first 5 + expand) + send. Hard-blocks empty subject/body and N=0. Shows per-send report on success. - /admin/broadcasts viewer: read-only list with click-row-to-expand detail (subject, body, recipient list, send_report counts). Tests - broadcast_service_test.go: placeholder substitution table-driven, Markdown safe-render incl. XSS guards ( + + + ); +} diff --git a/frontend/src/admin.tsx b/frontend/src/admin.tsx index f308a5c..5667617 100644 --- a/frontend/src/admin.tsx +++ b/frontend/src/admin.tsx @@ -83,6 +83,11 @@ export function renderAdmin(): string {

Event-Typen

Firmenweite Event-Typen moderieren: archivieren, zusammenführen, befördern.

+ +
+

Broadcasts

+

Versendete Massen-E-Mails an Teamauswahlen einsehen.

+

Geplant

diff --git a/frontend/src/client/admin-broadcasts.ts b/frontend/src/client/admin-broadcasts.ts new file mode 100644 index 0000000..c172a41 --- /dev/null +++ b/frontend/src/client/admin-broadcasts.ts @@ -0,0 +1,137 @@ +// admin-broadcasts.ts — read-only viewer for paliad.email_broadcasts. +// +// global_admin sees every row; senders see only their own. Authority is +// enforced server-side; this client just renders whatever /api/admin/broadcasts +// returns. Click a row → load detail (subject, body, recipient list). + +import { initI18n, onLangChange, t } from "./i18n"; +import { initSidebar } from "./sidebar"; + +interface BroadcastRow { + id: string; + subject: string; + sender_id: string; + sender_name: string; + sender_email: string; + recipient_count: number; + sent_at: string; + template_key?: string; +} + +interface BroadcastDetailRecipient { + id: string; + email: string; + display_name: string; +} + +interface BroadcastDetail extends BroadcastRow { + body: string; + recipient_filter: Record; + send_report: { total: number; sent: number; failed: number }; + recipients: BroadcastDetailRecipient[]; +} + +let rows: BroadcastRow[] = []; + +function esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; +} + +function fmtDate(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString(); +} + +async function load(): Promise { + const tbody = document.getElementById("broadcasts-tbody")!; + const empty = document.getElementById("broadcasts-empty")!; + try { + const res = await fetch("/api/admin/broadcasts"); + if (!res.ok) { + if (res.status === 403) { + tbody.innerHTML = `${esc(t("common.forbidden") || "Zugriff verweigert.")}`; + return; + } + tbody.innerHTML = `${esc(t("common.load_error") || "Fehler beim Laden.")}`; + return; + } + rows = (await res.json()) as BroadcastRow[]; + } catch { + tbody.innerHTML = `${esc(t("common.load_error") || "Fehler beim Laden.")}`; + return; + } + if (!rows.length) { + tbody.innerHTML = ""; + empty.style.display = "block"; + return; + } + empty.style.display = "none"; + tbody.innerHTML = rows + .map( + (r) => ` + + ${esc(fmtDate(r.sent_at))} + ${esc(r.subject)} + ${esc(r.sender_name || r.sender_email || "—")} + ${r.recipient_count} + + `, + ) + .join(""); + tbody.querySelectorAll("tr[data-broadcast-id]").forEach((tr) => { + tr.addEventListener("click", () => loadDetail(tr.dataset.broadcastId!)); + tr.style.cursor = "pointer"; + }); +} + +async function loadDetail(id: string): Promise { + const detail = document.getElementById("broadcast-detail")!; + detail.classList.remove("hidden"); + detail.innerHTML = `

${esc(t("common.loading") || "Lade…")}

`; + try { + const res = await fetch(`/api/admin/broadcasts/${encodeURIComponent(id)}`); + if (!res.ok) { + detail.innerHTML = `

${esc(t("common.load_error") || "Fehler beim Laden.")}

`; + return; + } + const d = (await res.json()) as BroadcastDetail; + const recList = (d.recipients || []) + .map( + (r) => + `
  • ${esc(r.display_name || "—")} <${esc(r.email)}>
  • `, + ) + .join(""); + const report = d.send_report || { total: d.recipient_count, sent: d.recipient_count, failed: 0 }; + detail.innerHTML = ` +
    +
    +

    ${esc(d.subject)}

    +

    + ${esc(t("admin.broadcasts.detail.sent_by") || "Gesendet von")} ${esc(d.sender_name || d.sender_email)} + • ${esc(fmtDate(d.sent_at))} + • ${report.sent}/${report.total} ${esc(t("admin.broadcasts.detail.delivered") || "versandt")} + ${report.failed > 0 ? ` • ${report.failed} ${esc(t("admin.broadcasts.detail.failed") || "fehlgeschlagen")}` : ""} +

    +
    +
    ${esc(d.body)}
    +
    +

    ${esc(t("admin.broadcasts.detail.recipients") || "Empfänger")} (${d.recipients?.length ?? 0})

    +
      ${recList}
    +
    +
    + `; + detail.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } catch { + detail.innerHTML = `

    ${esc(t("common.load_error") || "Fehler beim Laden.")}

    `; + } +} + +document.addEventListener("DOMContentLoaded", () => { + initI18n(); + initSidebar(); + onLangChange(() => load()); + load(); +}); diff --git a/frontend/src/client/broadcast.ts b/frontend/src/client/broadcast.ts new file mode 100644 index 0000000..aaef01d --- /dev/null +++ b/frontend/src/client/broadcast.ts @@ -0,0 +1,283 @@ +// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7). +// +// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team +// page calls when the "E-Mail an Auswahl" button is clicked. The modal +// collects subject + body + (optional) template and posts to +// /api/team/broadcast. On success it shows a per-recipient send report +// and closes. +// +// Per-recipient privacy: each member receives their own envelope. The +// modal lists every addressee so the sender knows exactly who will be +// mailed; there is no surprise to-line. + +import { t } from "./i18n"; + +export interface BroadcastRecipient { + user_id: string; + email: string; + display_name: string; + first_name: string; + role_on_project: string; +} + +export interface OpenBroadcastModalArgs { + recipients: BroadcastRecipient[]; + projectID?: string | null; + projectIDs?: string[]; + offices?: string[]; + roles?: string[]; +} + +interface EmailTemplateOption { + key: string; + subject: string; + body: string; + is_default: boolean; +} + +const RECIPIENT_CAP = 100; + +function esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; +} + +// firstName extracts the first whitespace-separated token from a display +// name. "Anna von Beispiel" → "Anna". Empty input → "". +export function firstName(displayName: string): string { + return displayName.trim().split(/\s+/)[0] ?? ""; +} + +export function openBroadcastModal(args: OpenBroadcastModalArgs): void { + if (!args.recipients.length) { + alert(t("team.broadcast.error.no_recipients") || "Keine Empfänger ausgewählt."); + return; + } + if (args.recipients.length > RECIPIENT_CAP) { + alert( + (t("team.broadcast.error.too_many") || "Empfängerlimit ({cap}) überschritten.").replace( + "{cap}", + String(RECIPIENT_CAP), + ), + ); + return; + } + + // Existing modal? Remove. Avoids stacking on rapid double-click. + document.getElementById("broadcast-modal")?.remove(); + + const overlay = document.createElement("div"); + overlay.id = "broadcast-modal"; + overlay.className = "modal-overlay"; + overlay.innerHTML = renderShell(args); + document.body.appendChild(overlay); + + // Close handlers + overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove()); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) overlay.remove(); + }); + document.addEventListener("keydown", function escClose(e) { + if (e.key === "Escape") { + overlay.remove(); + document.removeEventListener("keydown", escClose); + } + }); + + // Recipient toggle + overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => { + const list = overlay.querySelector("[data-broadcast-recipient-list]"); + if (!list) return; + list.classList.toggle("hidden"); + }); + + // Template dropdown + const templateSelect = overlay.querySelector("[data-broadcast-template]"); + templateSelect?.addEventListener("change", async () => { + const key = templateSelect.value; + if (!key) return; + const lang = (document.documentElement.lang || "de") as "de" | "en"; + try { + const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`); + if (!res.ok) return; + const tpl = (await res.json()) as EmailTemplateOption; + const subjectInput = overlay.querySelector("[data-broadcast-subject]"); + const bodyInput = overlay.querySelector("[data-broadcast-body]"); + if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject); + if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body); + } catch { + /* template load failure is non-fatal — sender keeps freeform mode. */ + } + }); + + // Submit + const form = overlay.querySelector("[data-broadcast-form]"); + form?.addEventListener("submit", async (e) => { + e.preventDefault(); + await onSubmit(form, overlay, args); + }); +} + +function renderShell(args: OpenBroadcastModalArgs): string { + const count = args.recipients.length; + const previewItems = args.recipients + .slice(0, 5) + .map((r) => esc(r.display_name) + " <" + esc(r.email) + ">") + .join(", "); + const more = count > 5 ? ` +${count - 5}` : ""; + + const fullList = args.recipients + .map( + (r) => + `
  • ${esc(r.display_name)} <${esc(r.email)}>${ + r.role_on_project ? ` ${esc(r.role_on_project)}` : "" + }
  • `, + ) + .join(""); + + return ` + + `; +} + +async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise { + const subject = (form.querySelector("[data-broadcast-subject]")?.value ?? "").trim(); + const body = (form.querySelector("[data-broadcast-body]")?.value ?? "").trim(); + const templateKey = form.querySelector("[data-broadcast-template]")?.value ?? ""; + const errEl = overlay.querySelector("[data-broadcast-error]"); + const okEl = overlay.querySelector("[data-broadcast-success]"); + errEl?.classList.add("hidden"); + okEl?.classList.add("hidden"); + + if (!subject) { + showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich."); + return; + } + if (!body) { + showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich."); + return; + } + + const submitBtn = form.querySelector("[data-broadcast-submit]"); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = t("team.broadcast.sending") || "Sende…"; + } + + const recipientFilter: Record = {}; + if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs; + if (args.projectID) recipientFilter.project_id = args.projectID; + if (args.offices?.length) recipientFilter.offices = args.offices; + if (args.roles?.length) recipientFilter.roles = args.roles; + + const lang = (document.documentElement.lang === "en" ? "en" : "de"); + + try { + const res = await fetch("/api/team/broadcast", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + project_id: args.projectID ?? null, + subject, + body, + template_key: templateKey || undefined, + lang, + recipient_filter: recipientFilter, + recipients: args.recipients, + }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({ error: "Send failed" })); + showError(errEl, (errBody as { error?: string }).error || "Send failed"); + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`; + } + return; + } + const report = (await res.json()) as { sent: number; failed: number; total: number }; + if (okEl) { + okEl.classList.remove("hidden"); + const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen)."; + okEl.textContent = tpl + .replace("{sent}", String(report.sent)) + .replace("{total}", String(report.total)) + .replace("{failed}", String(report.failed)); + } + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = t("team.broadcast.sent") || "Versandt"; + } + setTimeout(() => overlay.remove(), 2500); + } catch (e) { + showError(errEl, String(e)); + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`; + } + } +} + +function showError(el: HTMLDivElement | null | undefined, msg: string) { + if (!el) return; + el.textContent = msg; + el.classList.remove("hidden"); +} + +// stripGoTemplate is best-effort: existing email templates carry +// `{{define "content"}}` wrappers and Go-template branches the broadcast +// compose form can't honour. The bulk-send pipeline expects plain +// Markdown + the placeholder set documented in the modal, so we strip +// the template directives before populating the textarea. Senders can +// still edit further. +function stripGoTemplate(src: string): string { + return src + .replace(/\{\{\s*(define|end|block|if|else|range|with)\b[^}]*\}\}/g, "") + .trim(); +} diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 30f43c3..c016c98 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1401,6 +1401,52 @@ const translations: Record> = { "team.dept.lead": "Lead", "team.dept.unassigned": "Ohne Partner Unit", "team.partner_unit.unassigned": "Ohne Partner Unit", + // Project filter (t-paliad-147) + "team.filter.project": "Projekt", + "team.filter.project.all": "Alle Projekte", + "team.filter.project.selected": "ausgewählt", + "team.filter.project.clear": "Alle abwählen", + // Broadcast modal (t-paliad-147) + "team.broadcast.button": "E-Mail an Auswahl", + "team.broadcast.title": "E-Mail an Auswahl", + "team.broadcast.recipients": "Empfänger", + "team.broadcast.show_all": "Alle anzeigen", + "team.broadcast.template": "Vorlage", + "team.broadcast.template_optional": "optional", + "team.broadcast.template_freeform": "Freitext", + "team.broadcast.template.invitation": "Einladung", + "team.broadcast.template.deadline_digest": "Frist-Digest", + "team.broadcast.subject": "Betreff", + "team.broadcast.body": "Nachricht", + "team.broadcast.body_placeholder": "Hallo {{first_name}}, …", + "team.broadcast.placeholders_hint": "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}", + "team.broadcast.markdown_hint": "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.", + "team.broadcast.send": "Senden", + "team.broadcast.sending": "Sende…", + "team.broadcast.sent": "Versandt", + "team.broadcast.success": "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).", + "team.broadcast.error.no_recipients": "Keine Empfänger ausgewählt.", + "team.broadcast.error.too_many": "Empfängerlimit ({cap}) überschritten.", + "team.broadcast.error.subject_required": "Betreff ist erforderlich.", + "team.broadcast.error.body_required": "Nachricht ist erforderlich.", + "common.close": "Schließen", + // Admin broadcasts viewer (t-paliad-147) + "admin.broadcasts.title": "Broadcasts — Paliad", + "admin.broadcasts.heading": "Broadcasts", + "admin.broadcasts.subtitle": "Versendete Massen-E-Mails an Teamauswahlen.", + "admin.broadcasts.col.sent_at": "Gesendet", + "admin.broadcasts.col.subject": "Betreff", + "admin.broadcasts.col.sender": "Absender:in", + "admin.broadcasts.col.count": "Empfänger", + "admin.broadcasts.loading": "Lade…", + "admin.broadcasts.empty": "Noch keine Broadcasts versandt.", + "admin.broadcasts.detail.sent_by": "Gesendet von", + "admin.broadcasts.detail.delivered": "versandt", + "admin.broadcasts.detail.failed": "fehlgeschlagen", + "admin.broadcasts.detail.recipients": "Empfänger", + "common.forbidden": "Zugriff verweigert.", + "common.load_error": "Fehler beim Laden.", + "common.loading": "Lade…", "partner_unit.heading": "Meine Partner Units", "partner_unit.subtitle": "Partner Units sind strukturelle Einheiten — getrennt von Projektteams. Mitgliedschaft wird vom Admin verwaltet.", "partner_unit.none": "Sie sind noch keiner Partner Unit zugeordnet.", @@ -1426,6 +1472,8 @@ const translations: Record> = { "admin.card.email_templates.desc": "Vorlagen für Einladungen, Erinnerungen und Layout anpassen.", "admin.card.feature_flags.title": "Feature-Flags", "admin.card.feature_flags.desc": "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.", + "admin.card.broadcasts.title": "Broadcasts", + "admin.card.broadcasts.desc": "Versendete Massen-E-Mails an Teamauswahlen einsehen.", "admin.email_templates.title": "Email-Templates — Paliad", "admin.email_templates.heading": "Email-Templates", "admin.email_templates.subtitle": "Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.", @@ -3208,6 +3256,52 @@ const translations: Record> = { "team.dept.lead": "Lead", "team.dept.unassigned": "No partner unit", "team.partner_unit.unassigned": "No partner unit", + // Project filter (t-paliad-147) + "team.filter.project": "Project", + "team.filter.project.all": "All projects", + "team.filter.project.selected": "selected", + "team.filter.project.clear": "Deselect all", + // Broadcast modal (t-paliad-147) + "team.broadcast.button": "Email selection", + "team.broadcast.title": "Email selection", + "team.broadcast.recipients": "Recipients", + "team.broadcast.show_all": "Show all", + "team.broadcast.template": "Template", + "team.broadcast.template_optional": "optional", + "team.broadcast.template_freeform": "Free-form", + "team.broadcast.template.invitation": "Invitation", + "team.broadcast.template.deadline_digest": "Deadline digest", + "team.broadcast.subject": "Subject", + "team.broadcast.body": "Message", + "team.broadcast.body_placeholder": "Hi {{first_name}}, …", + "team.broadcast.placeholders_hint": "Placeholders: {{name}}, {{first_name}}, {{role_on_project}}", + "team.broadcast.markdown_hint": "Markdown supported: **bold**, *italic*, [link](https://...), - bullet.", + "team.broadcast.send": "Send", + "team.broadcast.sending": "Sending…", + "team.broadcast.sent": "Sent", + "team.broadcast.success": "{sent} of {total} emails sent ({failed} failed).", + "team.broadcast.error.no_recipients": "No recipients selected.", + "team.broadcast.error.too_many": "Recipient limit ({cap}) exceeded.", + "team.broadcast.error.subject_required": "Subject is required.", + "team.broadcast.error.body_required": "Message is required.", + "common.close": "Close", + // Admin broadcasts viewer (t-paliad-147) + "admin.broadcasts.title": "Broadcasts — Paliad", + "admin.broadcasts.heading": "Broadcasts", + "admin.broadcasts.subtitle": "Sent bulk emails to team selections.", + "admin.broadcasts.col.sent_at": "Sent", + "admin.broadcasts.col.subject": "Subject", + "admin.broadcasts.col.sender": "Sender", + "admin.broadcasts.col.count": "Recipients", + "admin.broadcasts.loading": "Loading…", + "admin.broadcasts.empty": "No broadcasts sent yet.", + "admin.broadcasts.detail.sent_by": "Sent by", + "admin.broadcasts.detail.delivered": "delivered", + "admin.broadcasts.detail.failed": "failed", + "admin.broadcasts.detail.recipients": "Recipients", + "common.forbidden": "Access denied.", + "common.load_error": "Load error.", + "common.loading": "Loading…", "partner_unit.heading": "My Partner Units", "partner_unit.subtitle": "Partner Units are structural units — separate from project teams. Membership is admin-managed.", "partner_unit.none": "You are not a member of any Partner Unit yet.", @@ -3233,6 +3327,8 @@ const translations: Record> = { "admin.card.email_templates.desc": "Customise templates for invitations, reminders and the wrapper layout.", "admin.card.feature_flags.title": "Feature Flags", "admin.card.feature_flags.desc": "Enable features per office, partner unit or role.", + "admin.card.broadcasts.title": "Broadcasts", + "admin.card.broadcasts.desc": "Inspect bulk emails sent to team selections.", "admin.email_templates.title": "Email Templates — Paliad", "admin.email_templates.heading": "Email Templates", "admin.email_templates.subtitle": "Customise templates for invitations, reminders, and the shared layout wrapper.", diff --git a/frontend/src/client/team.ts b/frontend/src/client/team.ts index f36c0ae..5d84996 100644 --- a/frontend/src/client/team.ts +++ b/frontend/src/client/team.ts @@ -1,5 +1,6 @@ import { initI18n, onLangChange, t, tDyn } from "./i18n"; import { initSidebar } from "./sidebar"; +import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast"; interface User { id: string; @@ -10,6 +11,25 @@ interface User { job_title?: string | null; } +interface MembershipEntry { + user_id: string; + project_ids: string[]; + lead_project_ids: string[]; + roles: string[]; +} + +interface ProjectSummary { + id: string; + title: string; + type: string; + reference?: string | null; +} + +interface MeUser { + id: string; + global_role: string; +} + interface DepartmentMember { user_id: string; email: string; @@ -48,9 +68,13 @@ const ROLE_ORDER = [ let users: User[] = []; let departments: Department[] = []; +let memberships: MembershipEntry[] = []; +let projectsList: ProjectSummary[] = []; +let me: MeUser | null = null; let groupBy: "office" | "department" = "office"; let activeOffice = "all"; let activeRole = "all"; +let activeProjectIDs: Set = new Set(); let searchQuery = ""; const ICON_MAIL = ''; @@ -87,15 +111,26 @@ function initials(name: string): string { } async function loadAll() { - const [usersResp, deptsResp] = await Promise.all([ + const [usersResp, deptsResp, membershipsResp, projectsResp, meResp] = await Promise.all([ fetch("/api/users"), fetch("/api/partner-units?include=members"), + fetch("/api/team/memberships"), + fetch("/api/projects"), + fetch("/api/me"), ]); if (usersResp.ok) users = (await usersResp.json()) as User[]; if (deptsResp.ok) departments = (await deptsResp.json()) as Department[]; + if (membershipsResp.ok) memberships = (await membershipsResp.json()) as MembershipEntry[]; + if (projectsResp.ok) { + const raw = (await projectsResp.json()) as ProjectSummary[]; + projectsList = raw; + } + if (meResp.ok) me = (await meResp.json()) as MeUser; buildOfficeFilters(); buildRoleFilters(); + buildProjectFilter(); render(); + updateBroadcastButton(); } function presentOffices(): string[] { @@ -191,6 +226,176 @@ function userMatchesRole(u: User): boolean { return roleKey(u.job_title) === activeRole.toLowerCase(); } +// userMatchesProject returns true when the project filter is empty or +// when the user is a direct member of at least one selected project. +// Inherited memberships intentionally don't qualify here — users want +// "people I can mail on this matter", which means direct membership. +function userMatchesProject(u: User): boolean { + if (activeProjectIDs.size === 0) return true; + const m = memberships.find((m) => m.user_id === u.id); + if (!m) return false; + for (const pid of m.project_ids) { + if (activeProjectIDs.has(pid)) return true; + } + return false; +} + +// canBroadcast reports whether the current user is allowed to send a +// broadcast given the active project filter. global_admin always wins. +// Otherwise the user must be a 'lead' on every project they have +// selected (or, when no project is selected, on at least one of their +// own projects). +function canBroadcast(): boolean { + if (!me) return false; + if (me.global_role === "global_admin") return true; + const myMembership = memberships.find((m) => m.user_id === me?.id); + if (!myMembership || !myMembership.lead_project_ids.length) return false; + if (activeProjectIDs.size === 0) { + // No project filter — allow when caller leads at least one project. + // Server-side check still runs per-broadcast so a non-lead can never + // actually send. + return true; + } + for (const pid of activeProjectIDs) { + if (!myMembership.lead_project_ids.includes(pid)) return false; + } + return true; +} + +function buildProjectFilter() { + const container = document.getElementById("team-project-filter"); + if (!container) return; + // Show only projects the caller can see — projectsList already does + // that via the visibility-gated /api/projects endpoint. + const sortedProjects = [...projectsList].sort((a, b) => + (a.title || "").localeCompare(b.title || ""), + ); + const options = sortedProjects + .map( + (p) => + ``, + ) + .join(""); + const summary = activeProjectIDs.size === 0 + ? (t("team.filter.project.all") || "Alle Projekte") + : `${activeProjectIDs.size} ${t("team.filter.project.selected") || "ausgewählt"}`; + container.innerHTML = ` + + + `; + const trigger = container.querySelector("[data-project-trigger]"); + const panel = container.querySelector("[data-project-panel]"); + trigger?.addEventListener("click", (e) => { + e.stopPropagation(); + panel?.classList.toggle("hidden"); + }); + document.addEventListener("click", (e) => { + if (!container.contains(e.target as Node)) panel?.classList.add("hidden"); + }); + container.querySelectorAll("input[data-project-id]").forEach((cb) => { + cb.addEventListener("change", () => { + const pid = cb.dataset.projectId!; + if (cb.checked) activeProjectIDs.add(pid); + else activeProjectIDs.delete(pid); + buildProjectFilter(); + render(); + updateBroadcastButton(); + }); + }); + container.querySelector("[data-project-clear]")?.addEventListener("click", () => { + activeProjectIDs.clear(); + buildProjectFilter(); + render(); + updateBroadcastButton(); + }); +} + +function buildBroadcastButton() { + const wrap = document.getElementById("team-broadcast-wrap"); + if (!wrap) return; + if (!canBroadcast()) { + wrap.innerHTML = ""; + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + wrap.innerHTML = ` + + `; + document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick()); +} + +function updateBroadcastButton() { + buildBroadcastButton(); + const countEl = document.getElementById("team-broadcast-count"); + if (countEl) { + const n = displayedRecipients().length; + countEl.textContent = String(n); + const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null; + if (btn) btn.disabled = n === 0; + } +} + +// displayedRecipients returns the currently visible users as broadcast +// recipients. Personal placeholder fields are sourced from each user +// (display_name / first_name) and from the membership index when a +// project filter is set (role_on_project = the role on the selected +// project; falls back to first available role). +function displayedRecipients(): BroadcastRecipient[] { + const filtered = users.filter( + (u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u), + ); + return filtered.map((u) => { + const m = memberships.find((m) => m.user_id === u.id); + let role = ""; + if (m) { + if (activeProjectIDs.size > 0) { + const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid)); + if (idx >= 0) role = m.roles[idx]; + } else if (m.roles.length > 0) { + role = m.roles[0]; + } + } + return { + user_id: u.id, + email: u.email, + display_name: u.display_name, + first_name: firstName(u.display_name), + role_on_project: role, + }; + }); +} + +function onBroadcastClick() { + const recipients = displayedRecipients(); + const selectedProjectIDs = Array.from(activeProjectIDs); + // When exactly one project is selected we pass it as project_id so + // the backend can verify lead-ship on that project. With multi- + // select we leave project_id null and rely on global_admin (the + // service rejects non-admin senders without a project_id). + const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null; + const offices = activeOffice === "all" ? [] : [activeOffice]; + const roles = activeRole === "all" ? [] : [activeRole]; + openBroadcastModal({ + recipients, + projectID, + projectIDs: selectedProjectIDs, + offices, + roles, + }); +} + function memberAsUser(m: DepartmentMember): User | undefined { return users.find((u) => u.id === m.user_id); } @@ -297,8 +502,11 @@ function render() { const empty = document.getElementById("team-empty")!; const count = document.getElementById("team-count")!; - const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesSearch(u)); + const filtered = users.filter( + (u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u), + ); count.textContent = `${filtered.length} / ${users.length}`; + updateBroadcastButton(); if (filtered.length === 0) { list.innerHTML = ""; diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 7de3666..2b44204 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -46,8 +46,23 @@ export type I18nKey = | "admin.audit.source.reminder_log" | "admin.audit.subtitle" | "admin.audit.title" + | "admin.broadcasts.col.count" + | "admin.broadcasts.col.sender" + | "admin.broadcasts.col.sent_at" + | "admin.broadcasts.col.subject" + | "admin.broadcasts.detail.delivered" + | "admin.broadcasts.detail.failed" + | "admin.broadcasts.detail.recipients" + | "admin.broadcasts.detail.sent_by" + | "admin.broadcasts.empty" + | "admin.broadcasts.heading" + | "admin.broadcasts.loading" + | "admin.broadcasts.subtitle" + | "admin.broadcasts.title" | "admin.card.audit.desc" | "admin.card.audit.title" + | "admin.card.broadcasts.desc" + | "admin.card.broadcasts.title" | "admin.card.email_templates.desc" | "admin.card.email_templates.title" | "admin.card.event_types.desc" @@ -512,6 +527,10 @@ export type I18nKey = | "checklisten.tab.templates" | "checklisten.title" | "common.cancel" + | "common.close" + | "common.forbidden" + | "common.load_error" + | "common.loading" | "dashboard.action.short.akte_archived" | "dashboard.action.short.akte_created" | "dashboard.action.short.appointment_approval_approved" @@ -1585,10 +1604,36 @@ export type I18nKey = | "search.no_results" | "search.placeholder" | "sidebar.resize.title" + | "team.broadcast.body" + | "team.broadcast.body_placeholder" + | "team.broadcast.button" + | "team.broadcast.error.body_required" + | "team.broadcast.error.no_recipients" + | "team.broadcast.error.subject_required" + | "team.broadcast.error.too_many" + | "team.broadcast.markdown_hint" + | "team.broadcast.placeholders_hint" + | "team.broadcast.recipients" + | "team.broadcast.send" + | "team.broadcast.sending" + | "team.broadcast.sent" + | "team.broadcast.show_all" + | "team.broadcast.subject" + | "team.broadcast.success" + | "team.broadcast.template" + | "team.broadcast.template.deadline_digest" + | "team.broadcast.template.invitation" + | "team.broadcast.template_freeform" + | "team.broadcast.template_optional" + | "team.broadcast.title" | "team.dept.lead" | "team.dept.unassigned" | "team.empty" | "team.filter.all" + | "team.filter.project" + | "team.filter.project.all" + | "team.filter.project.clear" + | "team.filter.project.selected" | "team.filter.role" | "team.group.department" | "team.group.office" diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 75a134c..3243039 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -10786,3 +10786,221 @@ dialog.quick-add-sheet::backdrop { font-style: italic; } +/* === Bulk team-email broadcast (t-paliad-147) === */ + +/* Project multi-select filter on /team. */ +.team-filter-row-project { + position: relative; + display: inline-flex; + align-items: center; + margin-bottom: 8px; +} +.team-project-trigger { + display: inline-flex; + align-items: center; + gap: 6px; +} +.team-project-summary { + font-weight: 500; +} +.team-project-panel { + position: absolute; + top: 100%; + left: 0; + z-index: 20; + min-width: 280px; + max-width: 420px; + max-height: 360px; + overflow-y: auto; + margin-top: 4px; + padding: 12px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); +} +.team-project-panel.hidden { + display: none; +} +.team-project-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border); +} +.team-project-options { + display: flex; + flex-direction: column; + gap: 6px; +} +.team-project-options .filter-checkbox { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} +.team-project-options .filter-checkbox:hover { + background: var(--color-bg-muted); +} +.team-broadcast-wrap { + margin: 12px 0 0 0; +} +.team-broadcast-count { + display: inline-block; + margin-left: 6px; + padding: 1px 8px; + background: rgba(255, 255, 255, 0.25); + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} + +/* Broadcast compose modal — extends .modal-overlay / .modal pattern. */ +.modal-broadcast { + width: 720px; + max-width: 92vw; + max-height: 90vh; + display: flex; + flex-direction: column; +} +.modal-broadcast .modal-body { + overflow-y: auto; + flex: 1; + padding: 16px 20px; +} +.modal-broadcast label { + display: block; + margin-top: 12px; + margin-bottom: 4px; + font-weight: 500; + font-size: 14px; +} +.modal-broadcast input[type="text"], +.modal-broadcast textarea, +.modal-broadcast select { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--color-border); + border-radius: 4px; + font-family: inherit; + font-size: 14px; +} +.modal-broadcast textarea { + resize: vertical; + min-height: 200px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + line-height: 1.5; +} +.broadcast-recipient-summary { + padding: 10px 12px; + background: var(--color-bg-muted); + border-radius: 4px; + font-size: 13px; +} +.broadcast-recipient-preview { + margin-top: 4px; + color: var(--color-text-muted); +} +.broadcast-recipient-list { + margin-top: 8px; + max-height: 200px; + overflow-y: auto; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 8px 12px; +} +.broadcast-recipient-list.hidden { + display: none; +} +.broadcast-recipient-list ul { + margin: 0; + padding-left: 18px; +} +.broadcast-recipient-list li { + margin: 4px 0; + font-size: 13px; +} +.broadcast-recip-email { + color: var(--color-text-muted); + font-size: 12px; +} +.broadcast-recip-role { + margin-left: 6px; + padding: 0 6px; + background: var(--color-bg-muted); + border-radius: 3px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.broadcast-hint { + margin-top: 6px; + font-size: 12px; + color: var(--color-text-muted); +} +.broadcast-error { + margin-top: 12px; + padding: 8px 10px; + background: rgba(220, 38, 38, 0.08); + color: rgb(185, 28, 28); + border-radius: 4px; + font-size: 13px; +} +.broadcast-error.hidden, +.broadcast-success.hidden { + display: none; +} +.broadcast-success { + margin-top: 12px; + padding: 8px 10px; + background: rgba(34, 197, 94, 0.1); + color: rgb(21, 128, 61); + border-radius: 4px; + font-size: 13px; +} +.link-button { + background: none; + border: none; + padding: 0; + color: var(--color-link, #2563eb); + cursor: pointer; + text-decoration: underline; + font-size: inherit; +} + +/* /admin/broadcasts viewer */ +.broadcasts-table td { + vertical-align: top; + padding: 10px 12px; +} +.broadcast-detail-body { + margin-top: 12px; + padding: 12px 16px; + background: var(--color-bg-muted); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + white-space: pre-wrap; +} +.broadcast-detail-recipients ul { + margin: 8px 0; + padding-left: 18px; + columns: 2; +} +.broadcast-detail-recipients li { + break-inside: avoid; + font-size: 13px; + margin: 2px 0; +} +@media (max-width: 640px) { + .broadcast-detail-recipients ul { + columns: 1; + } +} + diff --git a/frontend/src/team.tsx b/frontend/src/team.tsx index 0bd5ff1..d6a5407 100644 --- a/frontend/src/team.tsx +++ b/frontend/src/team.tsx @@ -68,6 +68,12 @@ export function renderTeam(): string { +
    +
    + + +
    diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 2b44204..7421e30 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -165,6 +165,25 @@ export type I18nKey = | "admin.event_types.subtitle" | "admin.event_types.title" | "admin.heading" + | "admin.paliadin.abandon_rate" + | "admin.paliadin.classifier_heading" + | "admin.paliadin.col.classifier" + | "admin.paliadin.col.count" + | "admin.paliadin.col.duration" + | "admin.paliadin.col.prompt" + | "admin.paliadin.col.started" + | "admin.paliadin.col.tools" + | "admin.paliadin.daily_heading" + | "admin.paliadin.heading" + | "admin.paliadin.last7" + | "admin.paliadin.loading" + | "admin.paliadin.median_dur" + | "admin.paliadin.recent_heading" + | "admin.paliadin.subtitle" + | "admin.paliadin.title" + | "admin.paliadin.tool_rate" + | "admin.paliadin.top_heading" + | "admin.paliadin.total" | "admin.partner_units.action.delete" | "admin.partner_units.action.edit" | "admin.partner_units.action.members" @@ -1294,6 +1313,7 @@ export type I18nKey = | "nav.admin.audit" | "nav.admin.bereich" | "nav.admin.event_types" + | "nav.admin.paliadin" | "nav.admin.partner_units" | "nav.admin.team" | "nav.agenda" @@ -1322,6 +1342,7 @@ export type I18nKey = | "nav.links" | "nav.logout" | "nav.neuigkeiten" + | "nav.paliadin" | "nav.projekte" | "nav.soon.tooltip" | "nav.team" @@ -1394,6 +1415,17 @@ export type I18nKey = | "palette.footer.navigate" | "palette.footer.open" | "palette.section.actions" + | "paliadin.empty" + | "paliadin.heading" + | "paliadin.input.placeholder" + | "paliadin.reset" + | "paliadin.send" + | "paliadin.starter.concept" + | "paliadin.starter.today" + | "paliadin.starter.week" + | "paliadin.stop" + | "paliadin.tagline" + | "paliadin.title" | "partner_unit.heading" | "partner_unit.members_label" | "partner_unit.none" diff --git a/frontend/src/paliadin.tsx b/frontend/src/paliadin.tsx new file mode 100644 index 0000000..f073b55 --- /dev/null +++ b/frontend/src/paliadin.tsx @@ -0,0 +1,97 @@ +import { h } from "./jsx"; +import { Sidebar } from "./components/Sidebar"; +import { BottomNav } from "./components/BottomNav"; +import { Footer } from "./components/Footer"; +import { PWAHead } from "./components/PWAHead"; + +// Paliadin chat panel page (t-paliad-146 PoC). +// +// Single full-page surface; m types into an input at the bottom, sees +// a stream of bubbles above. Server-side hydration is deliberately +// minimal — the panel boots empty, the client manages state in +// localStorage (per design §0.5.4 session-only history). +export function renderPaliadin(): string { + return "" + ( + + + + + + + + + Paliadin — Paliad + + + + + + +
    +
    +
    +
    +
    +

    ✨ Paliadin

    +

    + Ich kenne deine Akten und Paliads Wissensbasis. +

    +
    + +
    + +
    +
    +

    Was kann ich für dich tun?

    +
    + + + +
    +
    +
    + +
    + + + +
    +
    +
    +
    + +
    + + + + ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 52890e3..310179f 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -11009,3 +11009,258 @@ dialog.quick-add-sheet::backdrop { } } + +/* ============================================================================ + * t-paliad-146 — Paliadin in-app AI buddy (PoC). + * ========================================================================== */ + +.paliadin-page { + padding-bottom: 0; +} + +.paliadin-container { + display: flex; + flex-direction: column; + height: calc(100vh - var(--page-chrome-h, 80px)); + max-height: calc(100vh - 80px); +} + +.paliadin-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + flex-shrink: 0; +} + +.paliadin-tagline { + font-size: 0.95rem; + color: var(--color-text-muted); +} + +.paliadin-reset { + flex-shrink: 0; +} + +.paliadin-stream { + flex: 1 1 auto; + overflow-y: auto; + padding: 1rem 0; + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 200px; +} + +.paliadin-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + text-align: center; + color: var(--color-text-muted); + padding: 2rem; +} + +.paliadin-empty p { + font-size: 1.2rem; + margin-bottom: 1.5rem; +} + +.paliadin-starters { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + max-width: 480px; +} + +.paliadin-starter { + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text); + padding: 0.75rem 1rem; + border-radius: 8px; + cursor: pointer; + text-align: left; + font-size: 0.95rem; +} + +.paliadin-starter:hover { + background: var(--color-surface-hover, var(--color-surface)); + border-color: var(--color-accent); +} + +.paliadin-bubble { + max-width: 85%; + padding: 0.75rem 1rem; + border-radius: 12px; + line-height: 1.5; + word-wrap: break-word; +} + +.paliadin-bubble--user { + align-self: flex-end; + background: var(--color-accent-tint, #e8fbb2); + border: 1px solid var(--color-accent); +} + +.paliadin-bubble--assistant { + align-self: flex-start; + background: var(--color-surface); + border: 1px solid var(--color-border); +} + +.paliadin-bubble--error { + border-color: var(--color-status-red, #c54); + background: var(--color-status-red-tint, #fee); +} + +.paliadin-bubble-role { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + margin-bottom: 0.25rem; +} + +.paliadin-bubble-text { + white-space: pre-wrap; +} + +.paliadin-bubble-meta { + margin-top: 0.5rem; + font-size: 0.75rem; + color: var(--color-text-muted); + font-family: monospace; +} + +.paliadin-chip { + display: inline-block; + background: var(--color-accent-tint, #e8fbb2); + border: 1px solid var(--color-accent); + color: var(--color-text); + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-size: 0.85rem; + text-decoration: none; + margin: 0 0.15rem; + cursor: pointer; +} + +.paliadin-chip:hover { + background: var(--color-accent); +} + +.paliadin-form { + display: flex; + gap: 0.5rem; + padding: 0.75rem 0; + flex-shrink: 0; + border-top: 1px solid var(--color-border); +} + +.paliadin-input { + flex: 1; + resize: vertical; + min-height: 2.5rem; + max-height: 12rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-surface); + color: var(--color-text); + font-family: inherit; + font-size: 1rem; +} + +.paliadin-input:focus { + outline: none; + border-color: var(--color-accent); +} + +/* /admin/paliadin dashboard. */ + +.paliadin-stat-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; + margin: 1.5rem 0 2rem; +} + +.paliadin-stat-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 1rem; +} + +.paliadin-stat-label { + font-size: 0.85rem; + color: var(--color-text-muted); + margin-bottom: 0.5rem; +} + +.paliadin-stat-value { + font-size: 1.75rem; + font-weight: 600; + color: var(--color-text); +} + +.paliadin-classifier { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 1rem 0 2rem; + max-width: 600px; +} + +.paliadin-classifier-row { + display: grid; + grid-template-columns: 6rem 1fr 3rem; + align-items: center; + gap: 0.75rem; +} + +.paliadin-classifier-label { + font-size: 0.9rem; + color: var(--color-text); +} + +.paliadin-classifier-bar { + background: var(--color-surface); + border: 1px solid var(--color-border); + height: 1.25rem; + border-radius: 4px; + overflow: hidden; +} + +.paliadin-classifier-fill { + height: 100%; + background: var(--color-accent); +} + +.paliadin-classifier-count { + font-family: monospace; + text-align: right; + color: var(--color-text-muted); +} + +.paliadin-spark { + display: flex; + align-items: flex-end; + gap: 2px; + height: 60px; + margin: 1rem 0 2rem; + padding: 0 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.paliadin-spark-bar { + flex: 1; + background: var(--color-accent); + min-height: 1px; + max-width: 12px; +} diff --git a/internal/db/migrations/058_paliadin_poc.down.sql b/internal/db/migrations/058_paliadin_poc.down.sql new file mode 100644 index 0000000..296ead3 --- /dev/null +++ b/internal/db/migrations/058_paliadin_poc.down.sql @@ -0,0 +1,3 @@ +-- t-paliad-146: Paliadin PoC — drop paliad.paliadin_turns. + +DROP TABLE IF EXISTS paliad.paliadin_turns; diff --git a/internal/db/migrations/058_paliadin_poc.up.sql b/internal/db/migrations/058_paliadin_poc.up.sql new file mode 100644 index 0000000..97c74ca --- /dev/null +++ b/internal/db/migrations/058_paliadin_poc.up.sql @@ -0,0 +1,142 @@ +-- t-paliad-146: Paliadin PoC — paliad.paliadin_turns. +-- +-- Design: docs/design-paliadin-2026-05-07.md §0.5.6 (PoC variant). +-- +-- Paliadin is the in-app conversational AI assistant. Phase 0 PoC runs on +-- m's laptop only (PALIADIN_ENABLED=false on prod default), backed by a +-- long-lived `claude` process inside a tmux session — not the Anthropic +-- Messages API. The PoC's load-bearing artefact is monitoring: every +-- turn writes a row here so m can decide via /admin/paliadin whether the +-- feature earns a production v1 build. +-- +-- The PoC variant of this table stores the FULL prompt + response (no +-- redaction) because m is the only user, m is m's own compliance officer, +-- and the whole point is to read what was asked later. Production v1 +-- swaps to hash-only storage; that's a separate migration. +-- +-- Sections: +-- 1. CREATE paliad.paliadin_turns (with RLS). +-- 2. Indexes. + +-- ============================================================================ +-- 1. paliad.paliadin_turns +-- ============================================================================ + +CREATE TABLE paliad.paliadin_turns ( + turn_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Who asked. FK to paliad.users (not auth.users) so deleting an auth + -- row leaves the audit trail intact via paliad.users. + user_id uuid NOT NULL REFERENCES paliad.users(id), + + -- Browser session ID (opaque). Lets us group turns into "a single + -- conversation" without storing the full thread server-side. + session_id text NOT NULL, + + started_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz, -- NULL until end-of-turn + duration_ms int, -- finished_at - started_at + + -- The user's prompt, verbatim. PoC scope only — production v1 stores + -- a redacted hash instead. See docs/design-paliadin-2026-05-07.md §3.3. + user_message text NOT NULL, + + -- Claude's response, verbatim, with the [paliadin-meta] trailer + -- already stripped. The trailer's parsed fields land in `used_tools`, + -- `rows_seen`, `chip_count`, `classifier_tag` below. + response text, + + -- Approximate token count (server-side word_count * 1.3). Claude Code + -- via tmux doesn't expose Anthropic's usage block, so this is a + -- coarse heuristic for the dashboard cost trend — not a billing + -- number. + response_tokens int, + + -- Tool names Claude used during this turn, parsed from the + -- [paliadin-meta] trailer block ("used_tools: search_my_deadlines, + -- lookup_court"). Empty array means Claude didn't use any tool — + -- the load-bearing dashboard signal: high tool-use rate justifies + -- the data-grounding pitch in §8.1. + used_tools text[] NOT NULL DEFAULT '{}'::text[], + + -- Row counts parallel to used_tools (e.g. "rows_seen: 3, 1" → {3, 1}). + -- Helps spot "tool ran but returned nothing" patterns. + rows_seen int[] NOT NULL DEFAULT '{}'::int[], + + -- Number of action chips Claude embedded in the response. + chip_count int NOT NULL DEFAULT 0, + + -- True if the user closed the SSE stream before Claude finished. + abandoned boolean NOT NULL DEFAULT false, + + -- Which paliad page m was on when he asked. Empty when invoked from + -- /paliadin directly. + page_origin text, + + -- Error code, NULL on success. Possible values: + -- tmux_unresponsive — couldn't write to the pane / pane died + -- pane_died — tmux window closed mid-turn + -- user_aborted — abandoned=true synonym, kept for query clarity + -- timeout — Claude didn't write the response file in time + -- prompt_disabled — PALIADIN_ENABLED=false at request time + error_code text, + + -- Coarse self-classification by Claude itself in the [paliadin-meta] + -- trailer ("data" / "concept" / "navigation" / "meta" / "other"). + -- Drives the use-case-shape histogram on /admin/paliadin. + classifier_tag text +); + +-- ============================================================================ +-- 2. Indexes +-- ============================================================================ + +-- Per-user timeline (the "my recent paliadin turns" query). Most rows for +-- the PoC will share user_id=m, so this index is mostly useful as a sort +-- helper. +CREATE INDEX paliadin_turns_user_started_idx + ON paliad.paliadin_turns(user_id, started_at DESC); + +-- Global timeline for /admin/paliadin dashboard. Keeps the dashboard +-- queries (top-N recent turns, daily counts) on an index scan even as +-- the table grows. +CREATE INDEX paliadin_turns_started_idx + ON paliad.paliadin_turns(started_at DESC); + +-- Histogram queries on classifier_tag. Tiny table at PoC scale; the +-- index pays for itself once we have weeks of data. +CREATE INDEX paliadin_turns_classifier_idx + ON paliad.paliadin_turns(classifier_tag, started_at DESC) + WHERE classifier_tag IS NOT NULL; + +-- ============================================================================ +-- 3. RLS +-- ============================================================================ + +ALTER TABLE paliad.paliadin_turns ENABLE ROW LEVEL SECURITY; + +-- A user sees their own turns; global_admin sees all rows. The /admin/ +-- paliadin dashboard runs under m (global_admin) and so sees the full +-- log. Other users would only see their own — though in PoC scope +-- there's only m, the policy is the production-shape from day one. +CREATE POLICY paliadin_turns_select + ON paliad.paliadin_turns FOR SELECT + USING ( + user_id = auth.uid() + OR EXISTS (SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.global_role = 'global_admin') + ); + +-- Service-role (paliad backend) writes. Direct-auth INSERT is blocked. +-- Paliad runs with the service role today so the policy is inert in +-- practice; we still enable RLS so future direct-auth callers are gated. +CREATE POLICY paliadin_turns_insert_admin_only + ON paliad.paliadin_turns FOR INSERT + WITH CHECK (false); + +CREATE POLICY paliadin_turns_update_admin_only + ON paliad.paliadin_turns FOR UPDATE + USING (false); + +COMMENT ON TABLE paliad.paliadin_turns IS + 'Per-turn audit log for Paliadin (in-app AI). PoC variant stores full prompt + response — production v1 will swap to hash-only. Powers /admin/paliadin dashboard. Design: docs/design-paliadin-2026-05-07.md §0.5.6.'; diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 78f163e..4d50ca4 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -66,12 +66,21 @@ type Services struct { Derivation *services.DerivationService UserView *services.UserViewService Broadcast *services.BroadcastService + + // Paliadin is wired only when PALIADIN_ENABLED=true at boot + // (PoC; m's laptop only). On prod it stays nil and all /paliadin* + // routes 404 because Register() skips registering them. + Paliadin *services.PaliadinService } func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) { authClient = client giteaToken = giteaAPIToken + if svc != nil && svc.Paliadin != nil { + paliadinSvc = svc.Paliadin + } + if svc != nil { dbSvc = &dbServices{ projects: svc.Project, @@ -441,6 +450,27 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected.HandleFunc("GET /views/{slug}", gateOnboarded(handleViewsShellPage)) } + // t-paliad-146 — Paliadin (PoC). Routes register only when the + // service is wired (PALIADIN_ENABLED=true). On prod where it's + // false, paliadinSvc stays nil and these URLs simply 404. + if paliadinSvc != nil { + protected.HandleFunc("GET /paliadin", gateOnboarded(handlePaliadinPage)) + protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn) + protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream) + protected.HandleFunc("POST /api/paliadin/reset", handlePaliadinReset) + // Admin dashboard (visibility self-gated to global_admin via the + // service-layer Stats query, but route is admin-only too for + // consistency with /admin/team / /admin/audit-log). + if svc != nil && svc.Users != nil { + protected.HandleFunc("GET /admin/paliadin", + auth.RequireAdminFunc(svc.Users, gateOnboarded(handleAdminPaliadinPage))) + protected.HandleFunc("GET /api/admin/paliadin/stats", + auth.RequireAdminFunc(svc.Users, handleAdminPaliadinStats)) + protected.HandleFunc("GET /api/admin/paliadin/turns", + auth.RequireAdminFunc(svc.Users, handleAdminPaliadinTurns)) + } + } + // Catch-all 404 — runs for any authenticated path that no more-specific // pattern claimed. Renders the chromed shell with HTTP 404 (Bug 9 from // tests/smoke-auth-2026-04-25.md). Must be registered last on this mux. diff --git a/internal/handlers/paliadin.go b/internal/handlers/paliadin.go new file mode 100644 index 0000000..f28ea04 --- /dev/null +++ b/internal/handlers/paliadin.go @@ -0,0 +1,351 @@ +package handlers + +// paliadin.go — HTTP/SSE handlers for the Paliadin PoC (t-paliad-146). +// +// Design: docs/design-paliadin-2026-05-07.md §0.5. +// +// Three user-facing surfaces: +// GET /paliadin — chat panel page shell +// POST /api/paliadin/turn — initiate a turn, returns {turn_id, sse_url} +// GET /api/paliadin/stream/{id} — SSE stream of the turn +// POST /api/paliadin/reset — /clear the conversation +// GET /admin/paliadin — monitoring dashboard (global_admin) +// GET /api/admin/paliadin/stats — stats JSON +// GET /api/admin/paliadin/turns — recent turns JSON +// +// Routes register only when the PaliadinService is wired (which only +// happens when PALIADIN_ENABLED=true at boot). On prod, where it's +// false by default, none of these URLs exist — they 404 like any +// unrouted path. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/google/uuid" + + "mgit.msbls.de/m/paliad/internal/services" +) + +// newDetachedContext returns a context with timeout that is independent +// of any incoming request — needed so the Claude-via-tmux turn isn't +// cancelled when the originating POST returns ahead of the SSE stream. +func newDetachedContext(timeout time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), timeout) +} + +// paliadinSvc is the live PaliadinService instance. nil when +// PALIADIN_ENABLED=false. Set by Register() at boot. +var paliadinSvc *services.PaliadinService + +// pendingTurns is an in-memory map of turn_id → result channel. The POST +// /api/paliadin/turn endpoint kicks off the work + writes a synthetic +// turn record; the GET /api/paliadin/stream/{id} endpoint reads from +// the channel + emits SSE events. PoC scope: single user, in-process +// state. Production v1 would use a Postgres-backed queue or pgnotify. +var ( + pendingMu sync.Mutex + pendingTurns = map[uuid.UUID]chan turnEvent{} +) + +// turnEvent is one SSE event for a turn-in-flight. +type turnEvent struct { + Kind string `json:"kind"` // meta | content | end | error + Data map[string]any `json:"data"` +} + +// turnRequest is the JSON body of POST /api/paliadin/turn. +type turnRequest struct { + UserMessage string `json:"user_message"` + SessionID string `json:"session_id"` + PageOrigin string `json:"page_origin,omitempty"` +} + +// turnResponse is what POST /api/paliadin/turn returns. +type turnResponse struct { + TurnID string `json:"turn_id"` + SSEURL string `json:"sse_url"` +} + +// handlePaliadinPage serves the static /paliadin chat panel. +func handlePaliadinPage(w http.ResponseWriter, r *http.Request) { + if paliadinSvc == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "paliadin disabled — PALIADIN_ENABLED=false", + }) + return + } + http.ServeFile(w, r, "dist/paliadin.html") +} + +// handleAdminPaliadinPage serves the /admin/paliadin monitoring page. +func handleAdminPaliadinPage(w http.ResponseWriter, r *http.Request) { + if paliadinSvc == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "paliadin disabled — PALIADIN_ENABLED=false", + }) + return + } + http.ServeFile(w, r, "dist/admin-paliadin.html") +} + +// handlePaliadinTurn kicks off a turn and returns the SSE URL. +// +// We don't block here; the actual Claude work runs in a goroutine that +// pushes events into the per-turn channel. The client immediately opens +// EventSource on the returned URL and reads as the goroutine writes. +func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) { + if paliadinSvc == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "paliadin disabled", + }) + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + + var req turnRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.UserMessage == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_message required"}) + return + } + if req.SessionID == "" { + req.SessionID = uuid.New().String() + } + + turnID := uuid.New() + ch := make(chan turnEvent, 16) + pendingMu.Lock() + pendingTurns[turnID] = ch + pendingMu.Unlock() + + // Goroutine drives the actual Claude turn. We use a fresh context + // (not r.Context()) because the request is going to return as soon + // as we hand back the SSE URL — we don't want the whole turn to + // cancel when the POST completes. + go runPaliadinTurnAsync(turnID, services.TurnRequest{ + UserID: uid, + SessionID: req.SessionID, + UserMessage: req.UserMessage, + PageOrigin: req.PageOrigin, + }, ch) + + writeJSON(w, http.StatusOK, turnResponse{ + TurnID: turnID.String(), + SSEURL: "/api/paliadin/stream/" + turnID.String(), + }) +} + +// runPaliadinTurnAsync executes the turn and writes events into ch. +// Uses a 2-minute hard timeout independently of the originating request. +func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) { + defer func() { + // Drain + close. The SSE handler reads until the channel closes. + close(ch) + }() + + // Send a meta event so the client can show "Paliadin denkt nach …" + send(ch, turnEvent{ + Kind: "meta", + Data: map[string]any{ + "turn_id": turnID.String(), + "started_at": time.Now().UTC().Format(time.RFC3339), + }, + }) + + ctx, cancel := newDetachedContext(120 * time.Second) + defer cancel() + + result, err := paliadinSvc.RunTurn(ctx, req) + if err != nil { + errCode := "upstream_error" + if errors.Is(err, services.ErrTmuxUnavailable) { + errCode = "tmux_unavailable" + } + send(ch, turnEvent{ + Kind: "error", + Data: map[string]any{"code": errCode, "message": err.Error()}, + }) + return + } + + // One-shot content event with the full body. The frontend simulates + // streaming with a typewriter effect (cf. design §0.5.5: real + // chunked streaming would require Claude to write the response file + // progressively — out of PoC scope). + send(ch, turnEvent{ + Kind: "content", + Data: map[string]any{"text": result.Response}, + }) + + send(ch, turnEvent{ + Kind: "end", + Data: map[string]any{ + "turn_id": turnID.String(), + "used_tools": result.UsedTools, + "rows_seen": result.RowsSeen, + "chip_count": result.ChipCount, + "classifier_tag": result.ClassifierTag, + "duration_ms": result.DurationMS, + }, + }) +} + +// handlePaliadinStream is the SSE endpoint the EventSource subscribes +// to. Reads from the per-turn channel + writes SSE-framed events. +func handlePaliadinStream(w http.ResponseWriter, r *http.Request) { + if paliadinSvc == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "paliadin disabled", + }) + return + } + turnIDStr := r.PathValue("id") + turnID, err := uuid.Parse(turnIDStr) + if err != nil { + http.Error(w, "invalid turn_id", http.StatusBadRequest) + return + } + + pendingMu.Lock() + ch, ok := pendingTurns[turnID] + pendingMu.Unlock() + if !ok { + http.Error(w, "unknown turn_id (already finished, or never started)", http.StatusNotFound) + return + } + + // SSE headers. + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") // disable nginx/Traefik buffering + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + // Heartbeat ticker keeps reverse proxies from reaping the connection. + heartbeat := time.NewTicker(25 * time.Second) + defer heartbeat.Stop() + + for { + select { + case <-r.Context().Done(): + // Client closed the connection — don't drain, leave the + // channel for the goroutine to finish into. Pending-turns + // cleanup happens after end/error events flush below. + return + case <-heartbeat.C: + fmt.Fprint(w, "event: ping\ndata: {}\n\n") + flusher.Flush() + case ev, more := <-ch: + if !more { + // Goroutine finished. Tidy up the pending-turns map. + pendingMu.Lock() + delete(pendingTurns, turnID) + pendingMu.Unlock() + return + } + payload, _ := json.Marshal(ev.Data) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Kind, payload) + flusher.Flush() + if ev.Kind == "end" || ev.Kind == "error" { + // Don't return immediately — wait for the channel to + // close so the cleanup branch above runs and the client + // gets a clean EOF. + } + } + } +} + +// handlePaliadinReset clears the Claude conversation context. +func handlePaliadinReset(w http.ResponseWriter, r *http.Request) { + if paliadinSvc == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "paliadin disabled", + }) + return + } + if _, ok := requireUser(w, r); !ok { + return + } + ctx, cancel := newDetachedContext(10 * time.Second) + defer cancel() + if err := paliadinSvc.ResetSession(ctx); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "reset failed: " + err.Error(), + }) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// ============================================================================= +// /admin/paliadin — monitoring dashboard. +// ============================================================================= + +// handleAdminPaliadinStats returns the aggregate stats for the dashboard. +func handleAdminPaliadinStats(w http.ResponseWriter, r *http.Request) { + if paliadinSvc == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "paliadin disabled"}) + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + stats, err := paliadinSvc.Stats(r.Context(), uid) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, stats) +} + +// handleAdminPaliadinTurns returns the most recent turn rows. +func handleAdminPaliadinTurns(w http.ResponseWriter, r *http.Request) { + if paliadinSvc == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "paliadin disabled"}) + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + turns, err := paliadinSvc.ListRecentTurns(r.Context(), uid, 50) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, turns) +} + +// ============================================================================= +// helpers. +// ============================================================================= + +// send pushes an event onto the channel without blocking — drops on +// overflow. PoC scope: 16-deep buffer, single subscriber, very unlikely +// to overflow even with the slow Claude-via-tmux path. +func send(ch chan<- turnEvent, ev turnEvent) { + select { + case ch <- ev: + default: + // Channel full — drop. Logged by the SSE consumer's gap (it + // keeps reading; only "end"/"error" matter for completion). + } +} diff --git a/internal/services/paliadin.go b/internal/services/paliadin.go new file mode 100644 index 0000000..ae22f28 --- /dev/null +++ b/internal/services/paliadin.go @@ -0,0 +1,727 @@ +package services + +// PaliadinService — Phase 0 PoC of the in-app AI buddy (t-paliad-146). +// +// Design: docs/design-paliadin-2026-05-07.md §0.5 (PoC track). +// +// Architecture: a long-lived `claude` process inside a tmux session. +// Prompts go in via `tmux send-keys -l`; responses come back via a +// per-turn file the system prompt instructs Claude to write +// (Write(/tmp/paliadin/{turn_id}.txt)). The service polls that file, +// strips the [paliadin-meta] trailer block, parses the metadata, writes +// an audit row, and emits the response back to the SSE handler. +// +// The architecture is lifted (with adaptation to Go) from +// ~/dev/mVoice/server.py:250-380, which has been driving the goldi voice +// surface in production since 2026-Q1. +// +// PoC ONLY runs on m's laptop (PALIADIN_ENABLED=false on prod default). +// Hardcoded single-user, single-tmux-window scope. Do not attempt to +// deploy this to the Dokploy container — there is no `claude` CLI there. + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +// PaliadinService manages the tmux-claude PoC. +type PaliadinService struct { + db *sqlx.DB + tmuxSession string + responseDir string + users *UserService + + // Cached pane target ("session:window-idx") once the voice window is + // either discovered or created. Reset to "" if the pane dies. + mu sync.Mutex + paneTarget string + + // Single in-flight turn at a time. PoC scope — one user (m), serialised + // by a session-level mutex. Production v1 would queue / fan out. + turnMu sync.Mutex +} + +// NewPaliadinService wires the service. Call only when PALIADIN_ENABLED=true. +func NewPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *PaliadinService { + if tmuxSession == "" { + tmuxSession = "paliad-paliadin" + } + if responseDir == "" { + responseDir = "/tmp/paliadin" + } + return &PaliadinService{ + db: db, + tmuxSession: tmuxSession, + responseDir: responseDir, + users: users, + } +} + +// PaliadinTurn is the audit row. +type PaliadinTurn struct { + TurnID uuid.UUID `db:"turn_id" json:"turn_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + SessionID string `db:"session_id" json:"session_id"` + StartedAt time.Time `db:"started_at" json:"started_at"` + FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"` + DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"` + UserMessage string `db:"user_message" json:"user_message"` + Response *string `db:"response" json:"response,omitempty"` + ResponseTokens *int `db:"response_tokens" json:"response_tokens,omitempty"` + UsedTools pq.StringArray `db:"used_tools" json:"used_tools"` + RowsSeen pq.Int64Array `db:"rows_seen" json:"rows_seen"` + ChipCount int `db:"chip_count" json:"chip_count"` + Abandoned bool `db:"abandoned" json:"abandoned"` + PageOrigin *string `db:"page_origin" json:"page_origin,omitempty"` + ErrorCode *string `db:"error_code" json:"error_code,omitempty"` + ClassifierTag *string `db:"classifier_tag" json:"classifier_tag,omitempty"` +} + +// TurnRequest is what the handler passes to RunTurn. +type TurnRequest struct { + UserID uuid.UUID + SessionID string + UserMessage string + PageOrigin string // empty when unknown +} + +// TurnResult is what RunTurn returns to the handler. +type TurnResult struct { + TurnID uuid.UUID + Response string // body without [paliadin-meta] trailer + UsedTools []string + RowsSeen []int + ChipCount int + ClassifierTag string + DurationMS int +} + +// ErrPaliadinDisabled is the canonical "service is wired but turned off" +// signal. Handlers map it to 503. +var ErrPaliadinDisabled = errors.New("paliadin: disabled") + +// ErrTmuxUnavailable indicates we couldn't talk to tmux (binary missing, +// session unreachable, etc.). Handlers map it to 503 with a hint. +var ErrTmuxUnavailable = errors.New("paliadin: tmux unavailable") + +// RunTurn executes one full Q&A round. Blocks until Claude has written +// the response file or we time out (default 60 s). Writes the audit row +// in both success + error paths. +// +// PoC: serialised. The package-level turnMu enforces "one at a time". +// m is the only user, so this is fine. +func (s *PaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) { + s.turnMu.Lock() + defer s.turnMu.Unlock() + + turnID := uuid.New() + startedAt := time.Now().UTC() + + // Audit row — written *first* so a crash mid-turn still leaves traces. + if err := s.insertTurnRow(ctx, &PaliadinTurn{ + TurnID: turnID, + UserID: req.UserID, + SessionID: req.SessionID, + StartedAt: startedAt, + UserMessage: req.UserMessage, + PageOrigin: optionalString(req.PageOrigin), + }); err != nil { + return nil, fmt.Errorf("paliadin: insert turn row: %w", err) + } + + // Ensure tmux session + Claude pane. + target, err := s.ensurePane(ctx) + if err != nil { + _ = s.markTurnError(ctx, turnID, "tmux_unresponsive") + return nil, fmt.Errorf("%w: %v", ErrTmuxUnavailable, err) + } + + // Make sure the response dir exists. + if err := os.MkdirAll(s.responseDir, 0o755); err != nil { + _ = s.markTurnError(ctx, turnID, "tmux_unresponsive") + return nil, fmt.Errorf("paliadin: mkdir response dir: %w", err) + } + + // Send the framed prompt. The system prompt teaches Claude to react + // to the [PALIADIN:turn_id] envelope by writing the response file. + envelope := fmt.Sprintf("[PALIADIN:%s] %s", turnID, sanitiseForTmux(req.UserMessage)) + if err := s.sendToPane(ctx, target, envelope); err != nil { + _ = s.markTurnError(ctx, turnID, "tmux_unresponsive") + return nil, fmt.Errorf("%w: send prompt: %v", ErrTmuxUnavailable, err) + } + + // Poll for the response file. Fixed 60 s timeout; abort early if the + // caller's context is cancelled (e.g. user clicked Stop). + respPath := filepath.Join(s.responseDir, turnID.String()+".txt") + body, err := s.pollForResponse(ctx, respPath, 60*time.Second) + if err != nil { + ec := "timeout" + if errors.Is(err, context.Canceled) { + ec = "user_aborted" + } + _ = s.markTurnAbandonedOrError(ctx, turnID, ec, ec == "user_aborted") + return nil, err + } + + // Strip + parse the [paliadin-meta] trailer. Best-effort: the prompt + // instructs Claude to emit it but the PoC's monitoring is precisely + // what tells us how reliable that is in practice. + cleanBody, meta := splitTrailer(body) + tokens := approxTokenCount(cleanBody) + chipCount := countChips(cleanBody) + finished := time.Now().UTC() + durationMS := int(finished.Sub(startedAt) / time.Millisecond) + + // Write the result back into the audit row. + if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, meta, chipCount); err != nil { + log.Printf("paliadin: complete turn %s: %v", turnID, err) + // Don't fail the user-facing request on audit-row write errors — + // the response is real even if the bookkeeping is broken. + } + + return &TurnResult{ + TurnID: turnID, + Response: cleanBody, + UsedTools: meta.UsedTools, + RowsSeen: meta.RowsSeen, + ChipCount: chipCount, + ClassifierTag: meta.ClassifierTag, + DurationMS: durationMS, + }, nil +} + +// ResetSession sends `/clear` to the Claude pane so the next turn starts +// from a clean conversation. Used by the "New conversation" button. +func (s *PaliadinService) ResetSession(ctx context.Context) error { + s.mu.Lock() + target := s.paneTarget + s.mu.Unlock() + if target == "" { + // Nothing to reset; the next RunTurn will spin up a fresh pane. + return nil + } + if err := s.sendToPane(ctx, target, "/clear"); err != nil { + return err + } + return nil +} + +// ListRecentTurns reads the last N turns visible to the caller. +// global_admin sees everything; everyone else sees their own. +func (s *PaliadinService) ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + out := make([]PaliadinTurn, 0, limit) + q := ` + SELECT turn_id, user_id, session_id, started_at, finished_at, duration_ms, + user_message, response, response_tokens, used_tools, rows_seen, + chip_count, abandoned, page_origin, error_code, classifier_tag + FROM paliad.paliadin_turns + WHERE user_id = $1 + OR EXISTS (SELECT 1 FROM paliad.users u + WHERE u.id = $1 AND u.global_role = 'global_admin') + ORDER BY started_at DESC + LIMIT $2 + ` + if err := s.db.SelectContext(ctx, &out, q, callerID, limit); err != nil { + return nil, fmt.Errorf("paliadin: list turns: %w", err) + } + return out, nil +} + +// PaliadinStats is the aggregate view shown on /admin/paliadin. +type PaliadinStats struct { + TotalTurns int `json:"total_turns"` + TurnsLast7Days int `json:"turns_last_7_days"` + MedianDurationMS int `json:"median_duration_ms"` + P90DurationMS int `json:"p90_duration_ms"` + ToolUseRate float64 `json:"tool_use_rate"` // 0..1 + AbandonRate float64 `json:"abandon_rate"` // 0..1 + ByClassifier map[string]int `json:"by_classifier"` // tag → count + DailyCounts []PaliadinDailyCount `json:"daily_counts"` // last 30 days + TopPrompts []PaliadinPromptCount `json:"top_prompts"` // most-frequent normalised prompts +} + +type PaliadinDailyCount struct { + Day string `db:"day" json:"day"` // YYYY-MM-DD + Count int `db:"count" json:"count"` +} + +type PaliadinPromptCount struct { + Prompt string `db:"prompt" json:"prompt"` + Count int `db:"count" json:"count"` +} + +// Stats computes the dashboard aggregate. global_admin sees everything; +// everyone else sees their own slice (PoC has only m, but the policy +// matches RLS on the table). +func (s *PaliadinService) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error) { + stats := &PaliadinStats{ + ByClassifier: map[string]int{}, + DailyCounts: []PaliadinDailyCount{}, + TopPrompts: []PaliadinPromptCount{}, + } + + // Visibility predicate: caller's own rows OR all rows if global_admin. + visible := `(user_id = $1 OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin'))` + + // Total + 7-day count. + if err := s.db.QueryRowxContext(ctx, fmt.Sprintf(` + SELECT COUNT(*), + COUNT(*) FILTER (WHERE started_at >= now() - interval '7 days') + FROM paliad.paliadin_turns + WHERE %s + `, visible), callerID).Scan(&stats.TotalTurns, &stats.TurnsLast7Days); err != nil { + return nil, fmt.Errorf("paliadin: stats totals: %w", err) + } + + if stats.TotalTurns == 0 { + return stats, nil + } + + // Duration percentiles. Skip rows still in flight (duration_ms NULL). + if err := s.db.QueryRowxContext(ctx, fmt.Sprintf(` + SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms), 0)::int, + COALESCE(percentile_cont(0.9) WITHIN GROUP (ORDER BY duration_ms), 0)::int + FROM paliad.paliadin_turns + WHERE %s AND duration_ms IS NOT NULL + `, visible), callerID).Scan(&stats.MedianDurationMS, &stats.P90DurationMS); err != nil { + return nil, fmt.Errorf("paliadin: stats percentiles: %w", err) + } + + // Tool-use + abandon rates. + var toolUsedTurns, abandonedTurns int + if err := s.db.QueryRowxContext(ctx, fmt.Sprintf(` + SELECT COUNT(*) FILTER (WHERE array_length(used_tools, 1) > 0), + COUNT(*) FILTER (WHERE abandoned = true) + FROM paliad.paliadin_turns + WHERE %s + `, visible), callerID).Scan(&toolUsedTurns, &abandonedTurns); err != nil { + return nil, fmt.Errorf("paliadin: stats rates: %w", err) + } + stats.ToolUseRate = float64(toolUsedTurns) / float64(stats.TotalTurns) + stats.AbandonRate = float64(abandonedTurns) / float64(stats.TotalTurns) + + // Histogram by classifier_tag. + rows, err := s.db.QueryxContext(ctx, fmt.Sprintf(` + SELECT COALESCE(classifier_tag, 'untagged') AS tag, COUNT(*) AS n + FROM paliad.paliadin_turns + WHERE %s + GROUP BY tag + `, visible), callerID) + if err != nil { + return nil, fmt.Errorf("paliadin: stats classifier: %w", err) + } + defer rows.Close() + for rows.Next() { + var tag string + var n int + if err := rows.Scan(&tag, &n); err != nil { + return nil, err + } + stats.ByClassifier[tag] = n + } + + // Daily counts (last 30 days). + if err := s.db.SelectContext(ctx, &stats.DailyCounts, fmt.Sprintf(` + SELECT to_char(date_trunc('day', started_at), 'YYYY-MM-DD') AS day, + COUNT(*) AS count + FROM paliad.paliadin_turns + WHERE %s + AND started_at >= now() - interval '30 days' + GROUP BY day + ORDER BY day ASC + `, visible), callerID); err != nil { + return nil, fmt.Errorf("paliadin: stats daily: %w", err) + } + + // Top prompts (normalised: lowercase + collapse whitespace + trim). + if err := s.db.SelectContext(ctx, &stats.TopPrompts, fmt.Sprintf(` + SELECT trim(regexp_replace(lower(user_message), '\s+', ' ', 'g')) AS prompt, + COUNT(*) AS count + FROM paliad.paliadin_turns + WHERE %s + GROUP BY prompt + ORDER BY count DESC, prompt ASC + LIMIT 10 + `, visible), callerID); err != nil { + return nil, fmt.Errorf("paliadin: stats top prompts: %w", err) + } + + return stats, nil +} + +// ============================================================================= +// tmux orchestration — adapted from mVoice/server.py:250-380. +// ============================================================================= + +// ensurePane returns the tmux target ("session:window-idx") of the live +// Claude pane, creating both session and window if missing. +func (s *PaliadinService) ensurePane(ctx context.Context) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // Cheap path: if we have a cached target and it's still alive, reuse. + if s.paneTarget != "" && s.paneAlive(ctx, s.paneTarget) { + return s.paneTarget, nil + } + + // Ensure session. + if err := runTmux(ctx, "has-session", "-t", s.tmuxSession); err != nil { + // Create detached. + if err := runTmux(ctx, "new-session", "-d", "-s", s.tmuxSession); err != nil { + return "", fmt.Errorf("new-session: %w", err) + } + } + + // Look for an existing window tagged with @paliadin-scope=chat. + if existing := s.findChatWindow(ctx); existing != "" { + s.paneTarget = existing + return existing, nil + } + + // No window — create one running `claude` in a fresh pane. Must be + // interactive: claude reads stdin, so the tmux pane behaves like a + // terminal. We use `new-window -P -F` to print the new index back. + out, err := runTmuxOut(ctx, "new-window", "-t", s.tmuxSession, + "-n", "claude-paliadin", + "-P", "-F", "#{window_index}", + "claude") + if err != nil { + return "", fmt.Errorf("new-window claude: %w", err) + } + idx := strings.TrimSpace(out) + target := fmt.Sprintf("%s:%s", s.tmuxSession, idx) + + // Wait for Claude's prompt indicator. Claude Code's interactive + // prompt rendering varies but always settles into a state where the + // pane has a "❯" prompt glyph or "│" sidebar visible. We give it + // 30 s, which is generous. + if err := s.waitForPaneReady(ctx, target, 30*time.Second); err != nil { + return "", fmt.Errorf("wait-for-ready: %w", err) + } + + // Tag the window so a re-discover next boot finds it. + _ = runTmux(ctx, "set-window-option", "-t", target, "@paliadin-scope", "chat") + _ = runTmux(ctx, "set-window-option", "-t", target, "@fix-name", "claude-paliadin") + + // Send the bootstrap system prompt so Claude knows who it is and how + // to reply (write to the per-turn file with [paliadin-meta] trailer). + if err := s.sendToPane(ctx, target, paliadinSystemPrompt(s.responseDir)); err != nil { + return "", fmt.Errorf("send system prompt: %w", err) + } + // Give Claude a moment to absorb the system prompt before turns flow. + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(2 * time.Second): + } + + s.paneTarget = target + return target, nil +} + +func (s *PaliadinService) findChatWindow(ctx context.Context) string { + out, err := runTmuxOut(ctx, "list-windows", "-t", s.tmuxSession, + "-F", "#{window_index}") + if err != nil { + return "" + } + for _, idx := range strings.Fields(out) { + target := fmt.Sprintf("%s:%s", s.tmuxSession, idx) + scope, err := runTmuxOut(ctx, "show-window-option", + "-t", target, "-v", "@paliadin-scope") + if err == nil && strings.TrimSpace(scope) == "chat" { + return target + } + } + return "" +} + +func (s *PaliadinService) paneAlive(ctx context.Context, target string) bool { + if err := runTmux(ctx, "has-session", "-t", target); err != nil { + return false + } + return true +} + +func (s *PaliadinService) waitForPaneReady(ctx context.Context, target string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + out, err := runTmuxOut(ctx, "capture-pane", "-t", target, "-p") + if err == nil && (strings.Contains(out, "❯") || strings.Contains(out, "│")) { + return nil + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("pane %s not ready within %s", target, timeout) +} + +func (s *PaliadinService) sendToPane(ctx context.Context, target, msg string) error { + // `-l` sends the message literally (no key parsing) — necessary so + // our prompt's special characters don't get interpreted. + if err := runTmux(ctx, "send-keys", "-t", target, "-l", msg); err != nil { + return err + } + // Trailing Enter. tmux send-keys treats "Enter" as a special key name. + if err := runTmux(ctx, "send-keys", "-t", target, "Enter"); err != nil { + return err + } + return nil +} + +// pollForResponse waits for the response file to materialise. Returns +// the file's content (and removes the file). Treats stale files (left +// over from earlier turns) as a non-event — the file existing without a +// fresh mtime is a corner case the caller already de-duplicates by +// having a unique turn_id per request. +func (s *PaliadinService) pollForResponse(ctx context.Context, path string, timeout time.Duration) (string, error) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + data, err := os.ReadFile(path) + if err == nil && len(data) > 0 { + // Brief settle delay so we don't read mid-flush. + time.Sleep(50 * time.Millisecond) + data, _ = os.ReadFile(path) + _ = os.Remove(path) + return string(data), nil + } + time.Sleep(200 * time.Millisecond) + } + return "", fmt.Errorf("paliadin: response timeout after %s", timeout) +} + +// ============================================================================= +// shell / tmux helpers. +// ============================================================================= + +// runTmux runs `tmux `. Discards output. Returns error if tmux +// returns non-zero. +func runTmux(ctx context.Context, args ...string) error { + c, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + cmd := exec.CommandContext(c, "tmux", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("tmux %s: %w (stderr: %s)", strings.Join(args, " "), err, stderr.String()) + } + return nil +} + +// runTmuxOut runs `tmux ` and returns stdout. Useful for +// capture-pane / list-windows / show-window-option. +func runTmuxOut(ctx context.Context, args ...string) (string, error) { + c, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + cmd := exec.CommandContext(c, "tmux", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("tmux %s: %w (stderr: %s)", strings.Join(args, " "), err, stderr.String()) + } + return stdout.String(), nil +} + +// sanitiseForTmux removes control sequences that would confuse the pane. +// `tmux send-keys -l` sends literally, but stray newlines inside the +// message would split it across multiple "send" actions, breaking the +// turn envelope. +func sanitiseForTmux(s string) string { + s = strings.ReplaceAll(s, "\r", " ") + s = strings.ReplaceAll(s, "\n", " ") + // Cap length: a runaway prompt is a footgun. + const maxLen = 8000 + if len(s) > maxLen { + s = s[:maxLen] + " […truncated]" + } + return s +} + +// ============================================================================= +// trailer parsing. +// ============================================================================= + +// trailerMeta is what we extract from the [paliadin-meta]…[/paliadin-meta] +// block at the end of Claude's response. Best-effort: missing fields +// default to zero values. +type trailerMeta struct { + UsedTools []string + RowsSeen []int + ClassifierTag string +} + +var trailerRE = regexp.MustCompile(`(?s)\n*---\s*\n+\[paliadin-meta\]\s*\n(.+?)\n\[/paliadin-meta\]\s*$`) + +// splitTrailer separates the meta block from the body. If no trailer is +// present, the entire input is returned as the body. +func splitTrailer(body string) (string, trailerMeta) { + body = strings.TrimRight(body, " \t\n\r") + m := trailerRE.FindStringSubmatchIndex(body) + if m == nil { + return body, trailerMeta{} + } + cleanBody := strings.TrimRight(body[:m[0]], " \t\n\r") + metaText := body[m[2]:m[3]] + return cleanBody, parseTrailer(metaText) +} + +func parseTrailer(text string) trailerMeta { + out := trailerMeta{} + for _, line := range strings.Split(text, "\n") { + k, v, ok := splitFirst(strings.TrimSpace(line), ":") + if !ok { + continue + } + v = strings.TrimSpace(v) + switch strings.ToLower(strings.TrimSpace(k)) { + case "used_tools": + for _, t := range strings.Split(v, ",") { + t = strings.TrimSpace(t) + if t != "" { + out.UsedTools = append(out.UsedTools, t) + } + } + case "rows_seen": + for _, t := range strings.Split(v, ",") { + n, err := strconv.Atoi(strings.TrimSpace(t)) + if err == nil { + out.RowsSeen = append(out.RowsSeen, n) + } + } + case "classifier_tag": + out.ClassifierTag = v + } + } + return out +} + +func splitFirst(s, sep string) (string, string, bool) { + i := strings.Index(s, sep) + if i < 0 { + return "", "", false + } + return s[:i], s[i+len(sep):], true +} + +// approxTokenCount is a coarse word-count × 1.3 heuristic. Real token +// counts aren't exposed by Claude Code via tmux; this is just for the +// dashboard's cost-trend sense. +func approxTokenCount(s string) int { + if s == "" { + return 0 + } + words := strings.Fields(s) + return int(float64(len(words)) * 1.3) +} + +// countChips matches the `[#deadline-OPEN:…]`, `[#projekt-OPEN:…]`, +// `[chip:…]` markers the system prompt asks Claude to embed. PoC's +// frontend renders these as buttons; for the audit log we only need +// the count. +var chipRE = regexp.MustCompile(`\[(?:#[a-z]+-OPEN:[A-Za-z0-9\-_]+|chip:[a-z]+:[^\]]+)\]`) + +func countChips(s string) int { + return len(chipRE.FindAllString(s, -1)) +} + +// ============================================================================= +// audit-row writers. +// ============================================================================= + +func (s *PaliadinService) insertTurnRow(ctx context.Context, t *PaliadinTurn) error { + q := ` + INSERT INTO paliad.paliadin_turns ( + turn_id, user_id, session_id, started_at, user_message, page_origin + ) VALUES ($1, $2, $3, $4, $5, $6) + ` + _, err := s.db.ExecContext(ctx, q, + t.TurnID, t.UserID, t.SessionID, t.StartedAt, t.UserMessage, t.PageOrigin) + return err +} + +func (s *PaliadinService) completeTurn(ctx context.Context, turnID uuid.UUID, + finishedAt time.Time, durationMS int, response string, tokens int, + meta trailerMeta, chipCount int) error { + rowsSeen := make(pq.Int64Array, 0, len(meta.RowsSeen)) + for _, n := range meta.RowsSeen { + rowsSeen = append(rowsSeen, int64(n)) + } + q := ` + UPDATE paliad.paliadin_turns + SET finished_at = $2, + duration_ms = $3, + response = $4, + response_tokens = $5, + used_tools = $6, + rows_seen = $7, + chip_count = $8, + classifier_tag = $9 + WHERE turn_id = $1 + ` + _, err := s.db.ExecContext(ctx, q, + turnID, finishedAt, durationMS, response, tokens, + pq.StringArray(meta.UsedTools), rowsSeen, chipCount, + optionalString(meta.ClassifierTag)) + return err +} + +func (s *PaliadinService) markTurnError(ctx context.Context, turnID uuid.UUID, code string) error { + finished := time.Now().UTC() + q := ` + UPDATE paliad.paliadin_turns + SET finished_at = $2, error_code = $3 + WHERE turn_id = $1 AND finished_at IS NULL + ` + _, err := s.db.ExecContext(ctx, q, turnID, finished, code) + return err +} + +func (s *PaliadinService) markTurnAbandonedOrError(ctx context.Context, turnID uuid.UUID, code string, abandoned bool) error { + finished := time.Now().UTC() + q := ` + UPDATE paliad.paliadin_turns + SET finished_at = $2, error_code = $3, abandoned = $4 + WHERE turn_id = $1 AND finished_at IS NULL + ` + _, err := s.db.ExecContext(ctx, q, turnID, finished, code, abandoned) + return err +} + +func optionalString(s string) *string { + if s == "" { + return nil + } + return &s +} + +// Compile-time type guards (catches sql.ErrNoRows shifts). +var _ error = sql.ErrNoRows diff --git a/internal/services/paliadin_prompt.go b/internal/services/paliadin_prompt.go new file mode 100644 index 0000000..9f9f3f7 --- /dev/null +++ b/internal/services/paliadin_prompt.go @@ -0,0 +1,269 @@ +package services + +// Paliadin system prompt — Phase 0 PoC. +// +// This is the bootstrap message sent to the long-lived `claude` pane +// once, right after the pane is created. It defines who Paliadin is, +// how to reply (write to the per-turn response file, emit a +// [paliadin-meta] trailer block), what SQL to run, and how visibility +// is enforced. +// +// Design: docs/design-paliadin-2026-05-07.md §0.5.3 + §2.2.1. +// +// Conventions: +// - The prompt MUST end with the response-file write rule, since that +// is the contract the Go service polls on. +// - SQL recipes MUST always include the visibility predicate +// (paliad.can_see_project) on any project-scoped query — even +// though m's global_role=global_admin technically lets him see +// everything, we keep the muscle memory consistent with the +// production-v1 design. +// - The trailer format is stable; the trailer parser in paliadin.go +// must be kept in sync. + +import "strings" + +// paliadinSystemPrompt returns the full bootstrap message for a fresh +// Claude pane. The response_dir argument is the path where Claude must +// write its per-turn response files. +// +// Built via concatenation rather than fmt.Sprintf because the prompt +// contains German genitive apostrophes ("m's") that Sprintf misreads as +// format verbs. +func paliadinSystemPrompt(responseDir string) string { + return strings.TrimSpace(` +Du bist Paliadin — der eingebaute KI-Assistent in Paliad, m's Patentpraxis-Plattform. Du hilfst m bei seiner täglichen Arbeit: Akten finden, Fristen prüfen, Begriffe erklären, Gerichte nachschlagen, UPC-Rechtsprechung recherchieren. + +# Persönlichkeit + +- Direkt, kompetent, juristisch präzise. Keine Floskeln. +- Sprich wie ein Patentanwalts-Kollege mit zehn Jahren UPC-Erfahrung — nicht wie ein generischer Chatbot. +- Belege jede konkrete Aussage mit einem Tool-Call oder einer Zitat-Quelle. Niemals raten. +- Antworte standardmäßig auf Deutsch (m's Arbeitssprache). Wenn m auf Englisch fragt, antworte auf Englisch. +- Keine Emojis, keine "Ich helfe dir gerne!"-Phrasen. + +# Antwort-Protokoll (KRITISCH) + +Jede Anfrage von m kommt im Format: ` + "`[PALIADIN:turn_id] `" + ` + +Sobald du die turn_id liest: +1. Recherchiere mit deinen Tools (siehe SQL-Rezepte unten). +2. Formuliere eine knappe, faktenbasierte Antwort in Markdown. +3. Schreibe die Antwort in eine Datei: ` + "`Write(" + responseDir + "/{turn_id}.txt)`" + ` +4. WICHTIG: Schreib SOFORT, sobald du die Antwort hast. Das System wartet (Timeout: 60s). +5. Häng am Ende des Antworttextes IMMER einen [paliadin-meta]-Block an — sonst weiß das System nicht, was du gemacht hast. + +# Trailer-Format (PFLICHT am Ende jeder Antwort) + +Trenne den Block mit einer Leerzeile + ---, dann: + + [paliadin-meta] + used_tools: + rows_seen: + classifier_tag: + [/paliadin-meta] + +Beispiel: + + [paliadin-meta] + used_tools: search_my_deadlines, lookup_court + rows_seen: 3, 1 + classifier_tag: data + [/paliadin-meta] + +Die classifier_tag-Werte: +- ` + "`data`" + ` — m fragt nach seinen eigenen Daten ("welche Frist…", "auf welchem Projekt…") +- ` + "`concept`" + ` — m fragt nach einem juristischen Begriff/Verfahren ("was ist Klageerwiderung?") +- ` + "`navigation`" + ` — m sucht eine Seite/Funktion in Paliad ("wie öffne ich…") +- ` + "`meta`" + ` — Frage über Paliadin selbst, oder Smalltalk +- ` + "`other`" + ` — alles andere (Recherche, Web-Wissen) + +# Action-Chips (optional, aber gerne nutzen) + +Wenn du eine konkrete Folge-Aktion anbieten kannst, embed einen Chip-Marker direkt in den Antworttext. Das Frontend rendert ihn als anklickbaren Button: + +- ` + "`[#deadline-OPEN:c47bd2-...]`" + ` — öffnet die Fristen-Detailseite +- ` + "`[#projekt-OPEN:slug-x]`" + ` — öffnet die Projekt-Detailseite +- ` + "`[chip:nav:/projects/abc-123]`" + ` — beliebige Navigation +- ` + "`[chip:filter:status=pending&due=this_week]`" + ` — gefilterter Inbox-Link + +Verwende NUR IDs/Slugs, die du tatsächlich aus einem Tool-Call zurückbekommen hast. Niemals erfinden. + +# Hard Rules + +1. **Keine Erfindungen.** Wenn ein Tool keine Daten liefert, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden. +2. **Jede konkrete Aussage über m's eigene Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich. +3. **Schreibe nichts in die DB.** Du bist read-only. Wenn m etwas ändern will, sag ihm wo in Paliad. +4. **Visibility-Gate respektieren.** Auch wenn m global_admin ist: jede projekt-bezogene Abfrage MUSS ` + "`paliad.can_see_project(project_id)`" + ` enthalten. Konsistenz mit der späteren Multi-User-Version. +5. **Nicht über die Daten anderer User spekulieren**, selbst wenn m sie namentlich erwähnt — frag nach Projekt-ID/Slug. + +# SQL-Rezepte + +Du hast Zugriff auf zwei Datenquellen über das Supabase MCP (mcp__supabase__execute_sql): +- ` + "`paliad.*`" + ` — m's Patent-Praxis-Daten (Projekte, Fristen, Termine, Parteien, Gerichte, Glossar, Deadline-Rules) +- ` + "`data.*`" + ` — youpc.org UPC-Rechtsprechung (Urteile, Headnotes, Knowledge Graph) — selbe physische DB! + +## 1. whats_on_my_plate — m's Dashboard-Übersicht + +` + "```sql" + ` +SELECT + (SELECT count(*) FROM paliad.deadlines d + WHERE paliad.can_see_project(d.project_id) + AND d.status = 'pending' AND d.due_date < current_date) AS overdue, + (SELECT count(*) FROM paliad.deadlines d + WHERE paliad.can_see_project(d.project_id) + AND d.status = 'pending' AND d.due_date = current_date) AS today, + (SELECT count(*) FROM paliad.deadlines d + WHERE paliad.can_see_project(d.project_id) + AND d.status = 'pending' + AND d.due_date BETWEEN current_date AND current_date + 7) AS this_week, + (SELECT count(*) FROM paliad.appointments a + WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id)) + AND a.start_at::date = current_date) AS appointments_today; +` + "```" + ` + +## 2. list_my_projects + +` + "```sql" + ` +SELECT id, kind, label, status, parent_id, path + FROM paliad.projects + WHERE paliad.can_see_project(id) + AND status = 'active' + ORDER BY path + LIMIT 25; +` + "```" + ` + +## 3. get_project_detail (gegeben slug oder id) + +` + "```sql" + ` +SELECT p.*, + (SELECT json_agg(d ORDER BY d.due_date) + FROM paliad.deadlines d WHERE d.project_id = p.id + AND paliad.can_see_project(d.project_id)) AS deadlines, + (SELECT json_agg(a ORDER BY a.start_at) + FROM paliad.appointments a WHERE a.project_id = p.id + AND paliad.can_see_project(a.project_id)) AS appointments, + (SELECT json_agg(pa) FROM paliad.parties pa WHERE pa.project_id = p.id) AS parties + FROM paliad.projects p + WHERE paliad.can_see_project(p.id) + AND (p.id::text = '' OR p.slug = '') + LIMIT 1; +` + "```" + ` + +## 4. search_my_deadlines (status / Datum / Projekt) + +` + "```sql" + ` +SELECT d.id, d.title, d.due_date, d.status, p.label AS project_label, d.event_id + FROM paliad.deadlines d + JOIN paliad.projects p ON p.id = d.project_id + WHERE paliad.can_see_project(d.project_id) + AND ($status::text IS NULL OR d.status = $status) + AND ($due_after::date IS NULL OR d.due_date >= $due_after) + AND ($due_before::date IS NULL OR d.due_date <= $due_before) + ORDER BY d.due_date ASC + LIMIT 25; +` + "```" + ` + +## 5. list_my_appointments (Zeitfenster) + +` + "```sql" + ` +SELECT a.id, a.title, a.start_at, a.end_at, a.location, p.label AS project_label + FROM paliad.appointments a + LEFT JOIN paliad.projects p ON p.id = a.project_id + WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id)) + AND a.start_at >= $from + AND a.start_at <= $to + ORDER BY a.start_at ASC + LIMIT 25; +` + "```" + ` + +## 6. lookup_court (Gerichtskatalog — firm-wide reference) + +` + "```sql" + ` +SELECT c.slug, c.name, c.country, c.kind, c.address + FROM paliad.courts c + WHERE c.name ILIKE '%' || $q || '%' + OR c.slug ILIKE '%' || $q || '%' + ORDER BY similarity(c.name, $q) DESC + LIMIT 10; +` + "```" + ` + +## 7. lookup_glossary_term (Patent-Glossar, DE+EN) + +` + "```sql" + ` +-- Hinweis: Glossar ist statisch in internal/handlers/glossary.go. +-- Der Service lädt JSON beim Boot. Wenn du einen Begriff suchst, frag mich +-- direkt im Chat — m hat den Glossar-Volltext im Kopf, oder ich kann ihn +-- aus paliad.deadline_rules.legal_source ableiten. +` + "```" + ` + +## 8. lookup_deadline_rule (Fristenrechner-Konzepte) + +` + "```sql" + ` +SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text, + r.deadline_text_en, r.legal_source, r.deadline_notes, r.deadline_notes_en + FROM paliad.deadline_rules r + WHERE r.concept_label ILIKE '%' || $q || '%' + OR r.rule_code ILIKE '%' || $q || '%' + OR r.legal_source ILIKE '%' || $q || '%' + ORDER BY similarity(r.concept_label, $q) DESC + LIMIT 5; +` + "```" + ` + +## 9. lookup_youpc_case (UPC-Rechtsprechung — cross-schema!) + +` + "```sql" + ` +SELECT j.node_id, j.upc_number, j.court_division, j.judgment_type, + j.proceedings_type, j.decision_date, j.headnote_summary, + j.tags + FROM data.judgments j + WHERE j.upc_number ILIKE '%' || $q || '%' + OR j.headnote_summary ILIKE '%' || $q || '%' + OR j.tags::text ILIKE '%' || $q || '%' + ORDER BY j.decision_date DESC + LIMIT 5; +` + "```" + ` + +Volltext eines Urteils (wenn m fragt "was steht in dem Urteil?"): + +` + "```sql" + ` +SELECT content + FROM data.judgment_markdown_content + WHERE judgment_node_id = + ORDER BY chunk_index + LIMIT 1; +` + "```" + ` + +# Beispiel-Antwort + +m fragt: ` + "`[PALIADIN:abc-123] welche fristen sind diese woche fällig?`" + ` + +Du machst: +1. ` + "`mcp__supabase__execute_sql`" + ` mit Rezept #4 (search_my_deadlines), $status='pending', $due_after=current_date, $due_before=current_date+7 +2. Du bekommst z.B. 3 Zeilen zurück. +3. Du schreibst: + +` + "```" + ` +Write("/tmp/paliadin/abc-123.txt", """ +Diese Woche stehen 3 Fristen an: + +- **16.05.** Klageerwiderung auf Müller v. Acme [#deadline-OPEN:c47bd2-1] — UPC LD München +- **17.05.** Replik auf BMW v. Daimler [#deadline-OPEN:e92a01-3] +- **20.05.** Wiedereinsetzungsantrag auf Bosch-Patent [#deadline-OPEN:f31b09-7] + +Willst du eine davon im Detail anschauen? + +--- +[paliadin-meta] +used_tools: search_my_deadlines +rows_seen: 3 +classifier_tag: data +[/paliadin-meta] +""") +` + "```" + ` + +# Wichtig + +Der erste turn-Envelope, den du nach diesem System-Prompt bekommst, ist eine richtige m-Anfrage. Antworte gemäß Protokoll. Bei der allerersten Anfrage darfst du dich kurz vorstellen ("Hi m, ich bin Paliadin — bereit."), danach normaler Modus. +`) +} diff --git a/internal/services/paliadin_test.go b/internal/services/paliadin_test.go new file mode 100644 index 0000000..1f91b06 --- /dev/null +++ b/internal/services/paliadin_test.go @@ -0,0 +1,149 @@ +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:]) + } +} From 8d714dd95e7391866ea94d8ecb1f89f81ed18913 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 7 May 2026 21:57:20 +0200 Subject: [PATCH 8/8] fix(t-paliad-146): gate Paliadin to owner email in code, drop PALIADIN_ENABLED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m's call (2026-05-07 21:52): "remove the export variable, that is bad form. It should be connected only to my account." The PALIADIN_ENABLED env var was a deploy-time toggle: easy to mis-flip, splits prod/dev behaviour, and reads as "could be turned on for anyone." Replaced with a per-request gate in code: services.PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com" handlers/paliadin.go now gates every entry point through requirePaliadinOwner, which looks up paliad.users.email by the caller's UUID and returns 404 (not 403 — pretend the route doesn't exist) for anyone else. Routes register unconditionally; the gate is in the code, not the deploy. main.go wires PaliadinService whenever DATABASE_URL is set and logs the owner identity at boot. CLAUDE.md drops the PALIADIN_ENABLED row and gains an explanatory note about the in-code gate. Sidebar entries (Paliadin under Übersicht; Paliadin Monitor under Admin) now render with display:none, revealed by sidebar.ts after /api/me confirms the caller's email matches PALIADIN_OWNER_EMAIL — same fail-closed pattern the Admin group already uses. Side-effect for ops: paliad.de production now serves the routes too, but only to m, and only successfully if the host has tmux + claude in PATH (which Dokploy doesn't). m hitting /paliadin from prod gets a "tmux unavailable" — clear failure mode, not a security concern. One new test (TestPaliadinOwnerEmail_IsLowercaseStable) keeps the constant aligned with migration 023's seed so a future rename of m's account doesn't silently strand the gate. All existing tests pass. --- .claude/CLAUDE.md | 3 +- cmd/server/main.go | 35 +++++------- frontend/src/client/sidebar.ts | 27 ++++++++++ frontend/src/components/Sidebar.tsx | 17 +++++- internal/handlers/handlers.go | 31 ++++------- internal/handlers/paliadin.go | 84 ++++++++++++++--------------- internal/services/paliadin.go | 30 +++++++++++ internal/services/paliadin_test.go | 16 ++++++ 8 files changed, 157 insertions(+), 86 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c0fee49..4ee74fb 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -47,9 +47,10 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form | `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. | | `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). | -| `PALIADIN_ENABLED` | optional (default `false`) | Master switch for the Paliadin PoC (t-paliad-146). When `true`, the server wires `PaliadinService` and registers `/paliadin`, `/api/paliadin/*`, and `/admin/paliadin` routes. Requires a local `tmux` binary + a working `claude` CLI in PATH (the PoC orchestrates a long-lived Claude Code pane). On Dokploy prod containers, neither is present — leaving the var unset / `false` is the only correct setting. PoC scope is m's laptop only. | | `PALIADIN_TMUX_SESSION` | optional (default `paliad-paliadin`) | tmux session name the Paliadin service uses for its long-lived `claude` pane. | | `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. | + +> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy. | `FIRM_NAME` | optional (default `HLC`) | Display name of the firm Paliad is being branded for in this deployment. Read once at process start by `internal/branding.Name` (Go) and inlined into client bundles by `frontend/build.ts` (TypeScript). Powers every user-facing surface — landing hero, page titles, login hint, Downloads page, footer, invitation/reminder email bodies. The `ALLOWED_EMAIL_DOMAINS` whitelist is a separate concern (real DNS domains, not display name) and rotates independently. | > *Note on `DATABASE_URL`:* "Work without DB" ≠ "ungated". All knowledge-platform routes (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) are still behind the auth gate (302 to `/login` for anon visitors); only `/`, `/login`, `/logout`, and `/assets/*` are public. The `gateOnboarded` middleware additionally blocks unonboarded users from app pages but does NOT gate the knowledge-platform pages. diff --git a/cmd/server/main.go b/cmd/server/main.go index d6b8bb2..50805b2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -163,27 +163,20 @@ func main() { Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc), } - // t-paliad-146 — Paliadin PoC. Only wires when PALIADIN_ENABLED=true - // is explicitly set. Default-off means production deployments - // (paliad.de on Dokploy) skip the wiring entirely; the routes - // don't even register. PoC stays on m's laptop. - if os.Getenv("PALIADIN_ENABLED") == "true" { - tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION") - responseDir := os.Getenv("PALIADIN_RESPONSE_DIR") - svcBundle.Paliadin = services.NewPaliadinService(pool, users, tmuxSession, responseDir) - tmuxLabel := tmuxSession - if tmuxLabel == "" { - tmuxLabel = "paliad-paliadin" - } - respLabel := responseDir - if respLabel == "" { - respLabel = "/tmp/paliadin" - } - log.Printf("paliadin: enabled (tmux session=%q, response dir=%q) — PoC scope, m-only", - tmuxLabel, respLabel) - } else { - log.Println("paliadin: disabled (PALIADIN_ENABLED!=true) — routes will not register") - } + // t-paliad-146 — Paliadin PoC. Always wired when DATABASE_URL + // is set; the per-request handler gate (requirePaliadinOwner) + // restricts access to the single owner email + // (services.PaliadinOwnerEmail). All other authenticated users + // get a 404 — the route effectively does not exist for them. + // On hosts without tmux + the `claude` CLI (e.g. the Dokploy + // container), the owner gate still applies; if m ever hits the + // route from such a host, the service returns "tmux unavailable" + // without ever invoking shell-out. + tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION") + responseDir := os.Getenv("PALIADIN_RESPONSE_DIR") + svcBundle.Paliadin = services.NewPaliadinService(pool, users, tmuxSession, responseDir) + log.Printf("paliadin: wired (owner=%s; gate is per-request, not per-deploy)", + services.PaliadinOwnerEmail) // Wire ApprovalService into the entity services so Create / Update / // Complete / Delete consult paliad.approval_policies (t-paliad-138). // Without this wiring, the policies and request tables exist but no diff --git a/frontend/src/client/sidebar.ts b/frontend/src/client/sidebar.ts index d85d869..1692f27 100644 --- a/frontend/src/client/sidebar.ts +++ b/frontend/src/client/sidebar.ts @@ -72,6 +72,7 @@ export function initSidebar() { initChangelogBadge(); initInboxBadge(); initAdminGroup(); + initPaliadinLinks(); initUserViewsGroup(); initThemeToggle(); const sidebar = document.querySelector(".sidebar"); @@ -517,6 +518,32 @@ function userViewIconSvg(icon?: string): string { } } +// PALIADIN_OWNER_EMAIL must match services.PaliadinOwnerEmail (Go side). +// PoC scope — see docs/design-paliadin-2026-05-07.md §0.5. +const PALIADIN_OWNER_EMAIL = "matthias.siebels@hoganlovells.com"; + +// initPaliadinLinks reveals the Paliadin sidebar entries (under Übersicht +// + Admin) when /api/me confirms the caller is the Paliadin owner. Same +// fail-closed display:none pattern as initAdminGroup. Non-owners never +// see the entries; the routes themselves return 404 if they navigate +// to /paliadin or /admin/paliadin manually anyway. +function initPaliadinLinks(): void { + const top = document.getElementById("sidebar-paliadin-link") as HTMLElement | null; + const admin = document.getElementById("sidebar-admin-paliadin-link") as HTMLElement | null; + if (!top && !admin) return; + fetch("/api/me", { credentials: "same-origin" }) + .then((r) => (r.ok ? r.json() : null)) + .then((me: { email?: string } | null) => { + if (me && me.email && me.email.toLowerCase() === PALIADIN_OWNER_EMAIL) { + if (top) top.style.display = ""; + if (admin) admin.style.display = ""; + } + }) + .catch(() => { + // silent: failing closed is the safe default. + }); +} + // initAdminGroup reveals the Admin section in the sidebar when the caller's // /api/me lookup confirms global_role='global_admin'. The markup is in the // DOM with display:none for everyone — flipping it on after the fetch lands diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index f2c1a5e..a5a2d19 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -116,7 +116,14 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) + navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath) + navItem("/inbox", ICON_BELL, "nav.inbox", "Inbox", currentPath, "sidebar-inbox-badge") + - navItem("/paliadin", ICON_SPARKLE, "nav.paliadin", "Paliadin", currentPath) + + // Paliadin entry \u2014 owner-only, hidden by default. sidebar.ts + // reveals it after /api/me confirms the caller is the + // Paliadin owner (t-paliad-146 PoC scope). Same fail-closed + // pattern as the admin group below. + `` + navItem("/team", ICON_USERS, "nav.team", "Team", currentPath), )} @@ -172,7 +179,13 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st {navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)} {navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)} {navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)} - {navItem("/admin/paliadin", ICON_SPARKLE, "nav.admin.paliadin", "Paliadin Monitor", currentPath)} + {/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */} +
    diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 4d50ca4..3f466c6 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -450,26 +450,17 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected.HandleFunc("GET /views/{slug}", gateOnboarded(handleViewsShellPage)) } - // t-paliad-146 — Paliadin (PoC). Routes register only when the - // service is wired (PALIADIN_ENABLED=true). On prod where it's - // false, paliadinSvc stays nil and these URLs simply 404. - if paliadinSvc != nil { - protected.HandleFunc("GET /paliadin", gateOnboarded(handlePaliadinPage)) - protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn) - protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream) - protected.HandleFunc("POST /api/paliadin/reset", handlePaliadinReset) - // Admin dashboard (visibility self-gated to global_admin via the - // service-layer Stats query, but route is admin-only too for - // consistency with /admin/team / /admin/audit-log). - if svc != nil && svc.Users != nil { - protected.HandleFunc("GET /admin/paliadin", - auth.RequireAdminFunc(svc.Users, gateOnboarded(handleAdminPaliadinPage))) - protected.HandleFunc("GET /api/admin/paliadin/stats", - auth.RequireAdminFunc(svc.Users, handleAdminPaliadinStats)) - protected.HandleFunc("GET /api/admin/paliadin/turns", - auth.RequireAdminFunc(svc.Users, handleAdminPaliadinTurns)) - } - } + // t-paliad-146 — Paliadin (PoC). Routes register unconditionally; + // the per-request handler gate (requirePaliadinOwner) returns 404 + // for any authenticated user other than services.PaliadinOwnerEmail. + // No deploy-time toggle — the gate is in the code, not in the env. + protected.HandleFunc("GET /paliadin", gateOnboarded(handlePaliadinPage)) + protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn) + protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream) + protected.HandleFunc("POST /api/paliadin/reset", handlePaliadinReset) + protected.HandleFunc("GET /admin/paliadin", gateOnboarded(handleAdminPaliadinPage)) + protected.HandleFunc("GET /api/admin/paliadin/stats", handleAdminPaliadinStats) + protected.HandleFunc("GET /api/admin/paliadin/turns", handleAdminPaliadinTurns) // Catch-all 404 — runs for any authenticated path that no more-specific // pattern claimed. Renders the chromed shell with HTTP 404 (Bug 9 from diff --git a/internal/handlers/paliadin.go b/internal/handlers/paliadin.go index f28ea04..68c0bd5 100644 --- a/internal/handlers/paliadin.go +++ b/internal/handlers/paliadin.go @@ -40,9 +40,36 @@ func newDetachedContext(timeout time.Duration) (context.Context, context.CancelF } // paliadinSvc is the live PaliadinService instance. nil when -// PALIADIN_ENABLED=false. Set by Register() at boot. +// DATABASE_URL was unset (the service depends on the audit table). +// Set by Register() at boot. var paliadinSvc *services.PaliadinService +// requirePaliadinOwner gates every paliadin handler to the single +// owner email (services.PaliadinOwnerEmail = m). Anyone else gets a +// 404 — the gate is a "this URL doesn't exist for you" pretence +// rather than a 403, so a curious colleague can't even confirm the +// route is wired. +func requirePaliadinOwner(w http.ResponseWriter, r *http.Request) bool { + if paliadinSvc == nil { + http.NotFound(w, r) + return false + } + uid, ok := requireUser(w, r) + if !ok { + return false + } + owner, err := paliadinSvc.IsOwner(r.Context(), uid) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return false + } + if !owner { + http.NotFound(w, r) + return false + } + return true +} + // pendingTurns is an in-memory map of turn_id → result channel. The POST // /api/paliadin/turn endpoint kicks off the work + writes a synthetic // turn record; the GET /api/paliadin/stream/{id} endpoint reads from @@ -72,23 +99,20 @@ type turnResponse struct { SSEURL string `json:"sse_url"` } -// handlePaliadinPage serves the static /paliadin chat panel. +// handlePaliadinPage serves the static /paliadin chat panel. Gated to +// the single Paliadin owner (m); every other authenticated user gets +// a 404 — the route effectively does not exist for them. func handlePaliadinPage(w http.ResponseWriter, r *http.Request) { - if paliadinSvc == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{ - "error": "paliadin disabled — PALIADIN_ENABLED=false", - }) + if !requirePaliadinOwner(w, r) { return } http.ServeFile(w, r, "dist/paliadin.html") } // handleAdminPaliadinPage serves the /admin/paliadin monitoring page. +// Same owner gate — even other global_admins can't see this surface. func handleAdminPaliadinPage(w http.ResponseWriter, r *http.Request) { - if paliadinSvc == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{ - "error": "paliadin disabled — PALIADIN_ENABLED=false", - }) + if !requirePaliadinOwner(w, r) { return } http.ServeFile(w, r, "dist/admin-paliadin.html") @@ -100,17 +124,10 @@ func handleAdminPaliadinPage(w http.ResponseWriter, r *http.Request) { // pushes events into the per-turn channel. The client immediately opens // EventSource on the returned URL and reads as the goroutine writes. func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) { - if paliadinSvc == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{ - "error": "paliadin disabled", - }) + if !requirePaliadinOwner(w, r) { return } - uid, ok := requireUser(w, r) - if !ok { - return - } - + uid, _ := requireUser(w, r) // already validated by requirePaliadinOwner var req turnRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) @@ -205,10 +222,7 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<- // handlePaliadinStream is the SSE endpoint the EventSource subscribes // to. Reads from the per-turn channel + writes SSE-framed events. func handlePaliadinStream(w http.ResponseWriter, r *http.Request) { - if paliadinSvc == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{ - "error": "paliadin disabled", - }) + if !requirePaliadinOwner(w, r) { return } turnIDStr := r.PathValue("id") @@ -274,13 +288,7 @@ func handlePaliadinStream(w http.ResponseWriter, r *http.Request) { // handlePaliadinReset clears the Claude conversation context. func handlePaliadinReset(w http.ResponseWriter, r *http.Request) { - if paliadinSvc == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{ - "error": "paliadin disabled", - }) - return - } - if _, ok := requireUser(w, r); !ok { + if !requirePaliadinOwner(w, r) { return } ctx, cancel := newDetachedContext(10 * time.Second) @@ -300,14 +308,10 @@ func handlePaliadinReset(w http.ResponseWriter, r *http.Request) { // handleAdminPaliadinStats returns the aggregate stats for the dashboard. func handleAdminPaliadinStats(w http.ResponseWriter, r *http.Request) { - if paliadinSvc == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "paliadin disabled"}) - return - } - uid, ok := requireUser(w, r) - if !ok { + if !requirePaliadinOwner(w, r) { return } + uid, _ := requireUser(w, r) stats, err := paliadinSvc.Stats(r.Context(), uid) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -318,14 +322,10 @@ func handleAdminPaliadinStats(w http.ResponseWriter, r *http.Request) { // handleAdminPaliadinTurns returns the most recent turn rows. func handleAdminPaliadinTurns(w http.ResponseWriter, r *http.Request) { - if paliadinSvc == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "paliadin disabled"}) - return - } - uid, ok := requireUser(w, r) - if !ok { + if !requirePaliadinOwner(w, r) { return } + uid, _ := requireUser(w, r) turns, err := paliadinSvc.ListRecentTurns(r.Context(), uid, 50) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) diff --git a/internal/services/paliadin.go b/internal/services/paliadin.go index ae22f28..1641197 100644 --- a/internal/services/paliadin.go +++ b/internal/services/paliadin.go @@ -40,6 +40,16 @@ import ( "github.com/lib/pq" ) +// PaliadinOwnerEmail is the only account allowed to use the Paliadin +// PoC. Hardcoded — by design — so the gate cannot be flipped via a +// deploy env var. PoC ships at this scope; multi-user opens up only +// when production v1 lands behind its own auth model. +// +// Matches the seed in migration 023 (m's job_title row). If m's email +// ever rotates, this constant must rotate with it; there is no other +// path to enabling Paliadin. +const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com" + // PaliadinService manages the tmux-claude PoC. type PaliadinService struct { db *sqlx.DB @@ -57,6 +67,26 @@ type PaliadinService struct { turnMu sync.Mutex } +// IsOwner returns true when the given user_id corresponds to m's +// account (the only Paliadin PoC user). Resolves via paliad.users.email +// rather than caching a UUID so a DB rebuild that reassigns auth UUIDs +// doesn't strand the gate. +// +// Returns (false, nil) for any other user — including unknown UUIDs and +// users without an email row. Errors only on DB failure. +func (s *PaliadinService) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error) { + var email string + err := s.db.QueryRowxContext(ctx, + `SELECT email FROM paliad.users WHERE id = $1`, userID).Scan(&email) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("paliadin: lookup owner: %w", err) + } + return strings.EqualFold(email, PaliadinOwnerEmail), nil +} + // NewPaliadinService wires the service. Call only when PALIADIN_ENABLED=true. func NewPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *PaliadinService { if tmuxSession == "" { diff --git a/internal/services/paliadin_test.go b/internal/services/paliadin_test.go index 1f91b06..16fb38b 100644 --- a/internal/services/paliadin_test.go +++ b/internal/services/paliadin_test.go @@ -140,6 +140,22 @@ func TestSanitiseForTmux(t *testing.T) { } } +func TestPaliadinOwnerEmail_IsLowercaseStable(t *testing.T) { + // Sanity check: the constant matches the email seeded in + // migration 023 verbatim. If it ever drifts, the gate would + // reject m on a fresh DB without anyone noticing. + want := "matthias.siebels@hoganlovells.com" + if PaliadinOwnerEmail != want { + t.Fatalf("PaliadinOwnerEmail = %q; want %q (matches migration 023 seed)", + PaliadinOwnerEmail, want) + } + // Lowercase invariant — the gate uses strings.EqualFold but we + // store + compare lowercase consistently anyway. + if strings.ToLower(PaliadinOwnerEmail) != PaliadinOwnerEmail { + t.Errorf("PaliadinOwnerEmail must be lowercase: %q", PaliadinOwnerEmail) + } +} + func TestSanitiseForTmux_TruncatesLong(t *testing.T) { long := strings.Repeat("x", 10_000) got := sanitiseForTmux(long)