Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/unit-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ jobs:
- name: Run PalaceTriageBot package tests
run: swift test --package-path Palace/Packages/PalaceTriageBot

# The package suite above runs RedactionCorpusTests, but a deny-list guard
# is only worth its ability to fail. --self-test disables redaction to prove
# the guard goes red on an un-redacted corpus, catching a guard that has
# silently stopped guarding. Build products are warm from the step above.
- name: Verify TriageBot redaction leak gate
run: bash scripts/triage-corpus-check.sh --self-test

- name: Checkout Certificates
uses: actions/checkout@v7
with:
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ See [`docs/architecture/architectural-triad.md`](./docs/architecture/architectur
- Book state: `TPPBookRegistry` is the single source of truth
- Test mocks: centralized in `PalaceTests/Mocks/`, use `TPPBookMocker` for book factories
- Test HTTP stubbing: `HTTPStubURLProtocol` + `URLSession.stubbedSession()`
- Triage bot (`Palace/Packages/PalaceTriageBot/`): **read
[`docs/architecture/triage-bot-v1-as-built.md`](./docs/architecture/triage-bot-v1-as-built.md)
before changing it.** The classifier's scoring and guards, the redaction pattern
set and its ordering, and the corpus schema are behavioral contracts with
non-obvious rationale, and several code comments in the package are stale where
that document is correct. The forward design for the shared server and Android
client is
[`triage-bot-shared-architecture-proposal.md`](./docs/architecture/triage-bot-shared-architecture-proposal.md).

## TDD & Test Quality — MANDATORY

Expand Down
146 changes: 80 additions & 66 deletions Palace/Packages/PalaceTriageBot/README.md
Original file line number Diff line number Diff line change
@@ -1,93 +1,107 @@
# PalaceTriageBot

In-app triage and support chatbot for Palace. iOS-first demo build that
auto-resolves common known-issue tickets before they reach a human triager
and captures structured context on the ones that do escalate.
<!-- audit-verified: entry/test counts recomputed from catalog.json and Tests/ on 2026-07-28; gateway/AI/flag behavior verified against TriageBotFactory.swift, EmailTicketGateway.swift, RemoteFeatureFlags.swift -->

In-app triage and support bot for Palace. Matches patron problem descriptions
against a curated local knowledge base, walks guided troubleshooting steps,
and escalates unresolved issues as structured, redacted support tickets.

**Full as-built architecture, flows, and gap analysis:**
[`docs/architecture/triage-bot-v1-as-built.md`](../../../docs/architecture/triage-bot-v1-as-built.md).
This README is intentionally short and package-focused.

## Why three products

| Product | Contents | Reusability |
|---|---|---|
| `TriageBotCore` | Pure-Swift business logic — KBEntry/Catalog schema, LocalClassifier, ConversationReducer, ContextRedactor, protocols. **No UIKit, no SwiftUI, no Firebase.** | KMP-portable: every type and protocol has a 1:1 Kotlin analogue. The bundled `catalog.json` is the shared schema. |
| `TriageBotIOS` | iOS-native adapters — `DefaultIosContextProvider`, `ClipboardTicketGateway`, `OSLogTelemetrySink`. Wraps UIKit / OSLog / NWPathMonitor. | iOS only. Android writes its own adapters implementing the same protocols. |
| `TriageBotUI` | SwiftUI chat surface — `SupportChatView`, `TriageBotViewModel`, message bubbles, KB cards, ticket preview. | iOS only. Android writes Compose UI. The shape (categories, KB match card, ticket preview) is documented so both stay aligned. |

The 70 / 30 split between portable Core and iOS-only adapter/UI is the
point: when the KMP port opens up, the Kotlin module reuses everything in
`Core` and re-implements only the iOS-specific layers.

## Enabling for the demo
| `TriageBotCore` | Pure-Swift business logic: KBEntry/Catalog schema, LocalClassifier + TextNormalizer, ConversationReducer, ContextRedactor, TicketDraft/TicketEmailComposition, TelemetryContract, protocols. **No UIKit, no SwiftUI, no Firebase.** | Portable by design intent. Note: this is a portable Swift core, not shared code; no Kotlin exists today. A future Android port reimplements against the same protocols and the bundled `catalog.json` schema, with the quality/hold-out test corpora as the conformance contract. |
| `TriageBotIOS` | iOS-native adapters: `DefaultIosContextProvider`, `EmailTicketGateway`, `ClipboardTicketGateway`, `ClaudeFallbackClassifier`, `AnthropicKeyStore`, `OSLogTelemetrySink`. Wraps UIKit / MessageUI / OSLog / Keychain / NWPathMonitor. | iOS only. |
| `TriageBotUI` | SwiftUI chat surface: `SupportChatView`, `TriageBotViewModel`, message bubbles, KB match + guided step cards, ticket preview. | iOS only. |

The bot is feature-flagged via Firebase Remote Config and defaults to **off**.
## Enabling

To enable in a TestFlight or simulator build without a Firebase round-trip:
The bot is feature-flagged via Firebase Remote Config and defaults **off** in
TestFlight/App Store builds (DEBUG builds default on). To force it on without
a Firebase round-trip:

```bash
# Simulator
xcrun simctl spawn booted defaults write org.thepalaceproject.palace \
RemoteFeatureFlags.triageBotLocalOverride -bool true

# Then relaunch Palace. Settings Support Get Help becomes visible.
# Then relaunch Palace. Settings -> Support -> Get Help becomes visible.
```

For production rollout, set `triage_bot_enabled` to `true` in the
Firebase Remote Config console. The secondary flag
`triage_bot_ticket_submission_enabled` gates real HelpSpot submission;
when off (default), confirmed tickets copy their JSON payload to the
pasteboard so the flow stays exercisable without poking HelpSpot.
Three flags total: `triage_bot_enabled` (master kill-switch),
`triage_bot_ticket_submission_enabled` (transport selection, below), and
`triage_bot_ai_fallback_enabled` (Claude fallback; also requires a Keychain
API key, so it is effectively inert in App Store builds).

## Architecture in one paragraph

The user opens chat. `TriageBotViewModel.send(.start)` triggers the reducer,
which appends a welcome message + category chips and emits a
`.captureContext` effect. The host runs the effect via `DefaultIosContextProvider`,
which snapshots build/version/device/network/log lines and feeds the
result back through `.contextLoaded`. The reducer runs the snapshot through
`ContextRedactor` (strips tokens, hashes UUIDs, redacts emails). User taps
a category → describes the issue → `LocalClassifier` runs keyword overlap
against `KnowledgeBase.entries(in: category)` filtered by distributor/auth.
High-confidence match → KB card. Low confidence or no match → `TicketDraft`
with the redacted context. User confirms send → `ClipboardTicketGateway`
serializes the draft to pasteboard + returns a synthesized receipt.

All state lives in `ConversationState`; all transitions are described by
pure `ConversationReducer.reduce(state, action) -> (state, effects)`. The
host runs effects but doesn't decide anything.
All state lives in `ConversationState`; all transitions are pure
`ConversationReducer.reduce(state, action) -> (state, effects)`.
`TriageBotViewModel` is the only place effects become I/O. On open, the
reducer emits `captureContext`; the snapshot (device/OS/network/log tail) is
run through `ContextRedactor` (tokens stripped, UUIDs and barcodes hashed,
prose credentials redacted) before the reducer ever holds it. The patron
picks a category and describes the issue; `LocalClassifier` scores distinct
match regions against the KB. A confident match shows a KB card, optionally
with a guided multi-step flow whose outcomes are recorded as a resolution
trace. Low confidence drafts a `TicketDraft`, optionally asking one
structured escalation follow-up first; when the AI fallback is wired, the
on-device `ClaudeFallbackClassifier` gets one shot before escalation. The
patron reviews the full draft (per-field omission, enforced at
serialization), a send-consent gate blocks rapid-tap confirms, and failed
sends persist the draft for the next session.

## Ticket transport

- `triage_bot_ticket_submission_enabled` ON: `EmailTicketGateway` presents
the iOS Mail composer pre-addressed to `support@thepalaceproject.org` with
`palace-diagnostics.json` + `palace-logs.txt` attached. The patron sends
from their own account; the app never sends programmatically. Falls back
to the clipboard gateway when `canSendMail()` is false.
- Flag OFF (production default): `ClipboardTicketGateway` copies the JSON
payload to the pasteboard.
- **There is no HelpSpot API gateway.** Receipt ids are synthesized locally
(`EMAIL-<epoch>` / `DEMO-CLIPBOARD-<epoch>`).

## Tests

```bash
swift test --package-path Palace/Packages/PalaceTriageBot
```

26 tests passing — 10 redactor (privacy gate), 8 classifier (match /
disambiguate / escalate behavior, filter exclusion), 7 reducer (round-trip
flows including notify-me, file-anyway, escalate→submit→receipt, submission
failure, redactor integration), 1 UI placeholder.

## KB schema

`Sources/TriageBotCore/Resources/catalog.json` ships 8 hand-curated entries
from May 2026 HelpSpot triage. Each entry has the YAML-equivalent fields
documented in `KBEntry.swift`:

- `symptom_keywords` — string list, classifier scores keyword overlap
- `distributor_filter` / `auth_type_filter` — optional, narrows candidates
- `status` — `open` / `fixed_in` / `wontfix` / `user_error` / `duplicate_of`
- `fixed_in_version` — surfaces "update your app" when applicable
- `confidence_threshold` — per-entry suggest-vs-escalate gate
- `trust_level` — `authoritative` / `signal` / `context` (Phase 2)
- `visibility` — `user_facing` / `internal_only` (Phase 2)

Server-backed hot updates implement `KnowledgeBaseSource` and drop in
without touching the rest of the system.

## What's NOT in the demo

- Server-side endpoints (KB hosting, AI fallback, cluster detector) —
Phase 2; the protocol seam (`KnowledgeBaseSource`) is in place
- HelpSpot real-submission gateway — Phase 2; `ClipboardTicketGateway` is
the demo fallback
- Android client — Phase 2; the Core module is KMP-portable on purpose
- Crashlytics fingerprint matching — Phase 2
- Per-library KB overrides — Phase 3
291 test functions across 39 files: 283 in `TriageBotCoreTests`
(macOS-runnable), 3 in `TriageBotIOSTests`, 5 in `TriageBotUITests` (the
latter two are UIKit-gated; real assertions run on the iOS simulator in CI).
Notable suites: `ResponseQualityTests` (recall/precision/rejection benchmark
with named-miss allowlists), `HoldoutGeneralizationTests` (blind hold-out
from real HelpSpot tickets), `RedactionCorpusTests` (deny-list leak gate,
also run by `scripts/triage-corpus-check.sh`), `CatalogSchemaLintTests`,
`HowToGovernanceTests`, `AIFallbackInertTests` (proves zero network requests
with the flag off).

## KB

`Sources/TriageBotCore/Resources/catalog.json` ships 18 hand-curated entries
(9 known-issue + 9 how_to FAQ, July 2026 corpus) sourced from real HelpSpot
tickets, with provenance pinned in each entry's `internal_reference`. Field
semantics and which fields actually drive behavior are documented in
`KBEntry.swift` and in the as-built doc. Corpus updates are a maintainer-run
editorial process gated by the schema-lint / governance / quality tests; a
corpus change ships with an app release. Server-backed hot updates would
implement `KnowledgeBaseSource`; only the bundled and in-memory sources
exist today.

## Known gaps (V1)

- No HelpSpot API integration; email is the only real transport.
- AI fallback is on-device with a device-held key; the server-proxy posture
is future work (see the shared-architecture proposal in
`docs/architecture/`).
- `distributor`/`authType` context fields are nil in production, so entry
filters keyed on them never engage.
- Notify-me-on-fix is a stub; no notification is scheduled.
- Crashlytics fingerprint matching does not exist (the field is always empty).
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// Pure visibility rules for the secondary actions on a KB match card.
///
/// Kept in TriageBotCore, alongside `HelpEntryPointPolicy`, so the rule is
/// portable and testable without a SwiftUI host (which cannot build under
/// macOS `swift test`).
public enum KBMatchActionPolicy {

/// Whether the "Notify me" affordance should render on a KB match card.
///
/// Currently always false. The affordance used to render whenever an entry
/// carried a `fixed_in_version`, and tapping it produced a synthetic local
/// receipt plus a reassuring message. Nothing was ever scheduled and no
/// mechanism existed to deliver a notification, so the card promised the
/// patron something the app could not do.
///
/// `entryHasFixVersion` is the condition that gates the affordance once a
/// real delivery path exists; return it in place of `false` to restore.
/// The reducer still handles `.userTappedNotifyMeOnFix`, and its tests
/// still cover that path, so restoring is a one-line change here.
public static func showsNotifyMeOnFix(entryHasFixVersion: Bool) -> Bool {
false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,14 @@ public enum TicketEmailComposition {
public static func attachments(for rawDraft: TicketDraft) -> [Attachment] {
// PP-4807: encode the sanitized draft so omitted fields are ABSENT from
// the JSON attachment (nil optionals are dropped by encodeIfPresent).
// PP-4883: serialize via the shared TicketWirePayload so the email
// attachment and the clipboard copy path produce byte-identical bytes.
// Pass the RAW draft to jsonData and let TicketWirePayload be the sole
// sanitize owner for the JSON — `draft` below is only for the log file.
let draft = rawDraft.sanitizedForSubmission()
var result: [Attachment] = []

let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
if let json = try? encoder.encode(draft) {
if let json = try? TicketWirePayload.jsonData(for: rawDraft) {
result.append(Attachment(
fileName: "palace-diagnostics.json",
mimeType: "application/json",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

/// The single owner of ticket serialization for every path that puts a ticket
/// on the wire — the email `palace-diagnostics.json` attachment, the clipboard
/// copy path, and any future gateway (a HelpSpot POST, an Android sink).
///
/// It always encodes the WIRE-READY draft (`sanitizedForSubmission()`), so a
/// field the patron switched off in the preview is absent from the bytes on
/// every path — not merely hidden in the UI. Before PP-4883 the clipboard
/// gateway JSON-encoded the *raw* draft and honored no omissions (as-built gap
/// 12); routing all paths through here is what keeps them from drifting apart.
///
/// Lives in TriageBotCore (not TriageBotIOS) so it is unit-tested under macOS
/// `swift test` — the same fast gate that already guards the email path — even
/// though the gateways that call it are UIKit-gated.
public enum TicketWirePayload {

/// Pretty-printed, deterministically-keyed JSON of the sanitized draft.
/// Falls back to `"{}"` only if encoding fails (it does not, for a
/// well-formed draft) so callers writing to a sink never crash.
public static func json(for rawDraft: TicketDraft) -> String {
guard let data = try? jsonData(for: rawDraft),
let string = String(data: data, encoding: .utf8) else {
return "{}"
}
return string
}

/// The raw JSON bytes, for callers (the email attachment) that need `Data`.
public static func jsonData(for rawDraft: TicketDraft) throws -> Data {
let draft = rawDraft.sanitizedForSubmission()
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(draft)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ public protocol DiagnosticsPreference: Sendable {
/// UserDefaults-backed preference. Foundation-only so it lives in Core and is
/// testable under macOS `swift test`. Absent key reads as `true` (default ON).
public final class UserDefaultsDiagnosticsPreference: DiagnosticsPreference, @unchecked Sendable {
/// The single UserDefaults key both this preference and the Settings toggle
/// (PP-4884) read/write. Exposed so the app-side toggle binds to the exact
/// key the gating context provider honors — no drift between the switch a
/// patron flips and the choice the bot obeys.
public static let defaultsKey = "triagebot.includeDiagnostics"

private let defaults: UserDefaults
private let key: String

public init(defaults: UserDefaults = .standard, key: String = "triagebot.includeDiagnostics") {
public init(defaults: UserDefaults = .standard, key: String = UserDefaultsDiagnosticsPreference.defaultsKey) {
self.defaults = defaults
self.key = key
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ public protocol ContextProvider: Sendable {
/// HTTP API (or, for demo, copies the JSON to the pasteboard). Failure modes
/// surface via the throwing init — the reducer maps thrown errors to
/// `ticketSubmissionFailed` actions.
///
/// Serialization contract (PP-4883): any implementation that writes the draft
/// to an external sink (email attachment, pasteboard, network body) MUST
/// serialize `draft.sanitizedForSubmission()` — in practice, route through
/// `TicketWirePayload` — never the raw draft. The patron reviews the ticket
/// field-by-field and can switch fields off; a gateway that encodes the raw
/// draft ships fields the patron omitted (as-built gap 12).
public protocol TicketGateway: Sendable {
func submit(_ draft: TicketDraft) async throws -> TicketReceipt
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,16 @@ public struct ConversationReducer: Sendable {
kind: .text("Sending your ticket…")
))
next.step = .submitting(ticket: draft)
effects.append(.submitTicket(draft))
// PP-4883: the draft leaves the reducer WIRE-READY — every field the
// patron switched off is stripped here, in pure Core, before any
// gateway sees it. This is the single chokepoint that makes the
// omit-choices guarantee independent of which gateway runs (email,
// clipboard, a future HelpSpot POST) and testable under the fast
// `swift test` gate. `.submitting(ticket: draft)` keeps the original
// draft so the failure/retry/persist path still shows what the
// patron reviewed. `sanitizedForSubmission()` is idempotent, so the
// gateway's own serialization sanitize is harmless defense-in-depth.
effects.append(.submitTicket(draft.sanitizedForSubmission()))
effects.append(.emitTelemetry(.init(
name: "triage_ticket_submit_requested",
parameters: ["priority": draft.priority.rawValue]
Expand Down Expand Up @@ -630,7 +639,8 @@ public struct ConversationReducer: Sendable {
}
next.messages.append(.init(sender: .bot, kind: .text("Trying again…")))
next.step = .submitting(ticket: draft)
effects.append(.submitTicket(draft))
// PP-4883: sanitize on the retry path too (see .userConfirmedTicketSubmit).
effects.append(.submitTicket(draft.sanitizedForSubmission()))
effects.append(.emitTelemetry(.init(name: "triage_ticket_submit_retried")))

case .userTappedStartOver:
Expand Down
Loading
Loading