Skip to content

Commit 099bf2c

Browse files
mauricecarrier7t
andauthored
fix(triage): one preamble on the follow-up + chip-matching category labels (PP-4847) (#1317)
Two cosmetic defects from a regression sim-drive: 1. Doubled preamble. The reducer always prepends "Before I file this — " to the escalation follow-up, but 3 of 7 corpus prompts carried their OWN preamble ("Before I send this to support —", "Quick question before I file this —", "Before I escalate —"), so the patron saw two. Standardize: the reducer owns the single canonical preamble; the corpus prompts are now bare questions. A new corpus lint (EscalationPreambleTests) loads the shipped catalog and fails if any prompt reintroduces a self-preamble. 2. Category label mismatch. The ticket-preview "Category" rendered draft.category.rawValue.capitalized → "Signin" (no space) and surfaced the internal `reader` case as "Reader", neither matching the chip the patron tapped. Add KBCategory.displayName (audiobook→Audiobook, reader→Reading, signin→Sign in, …) and route BOTH the preview and the chips through it, so the two can never drift again. Verified: TriageBotCore 22/22 green (new KBCategoryDisplayNameTests + EscalationPreambleTests); catalog.json re-validated. UI label wiring (#if canImport(UIKit)) is exercised by the iOS build in CI. **Scope:** category display label + the follow-up preamble wording/corpus only. **Not done:** PP-4846 (intermittent stuck-Send race) — separate branch, repro-first. **Deferred:** none. Co-authored-by: t <t@t.io>
1 parent 04fccbb commit 099bf2c

7 files changed

Lines changed: 170 additions & 15 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
name: pp-4847-triage-preamble-labels
3+
created: 2026-07-21
4+
author: claude-opus-4-8
5+
---
6+
7+
**ADR refs:** none recorded for the triage-bot area.
8+
9+
## Context
10+
11+
Two cosmetic triage-bot defects from a regression sim-drive (PP-4847):
12+
1. **Doubled preamble.** The reducer always prepends "Before I file this — " to
13+
the escalation follow-up question, but 3 of 7 corpus prompts carry their OWN
14+
preamble ("Before I send this to support —", "Quick question before I file
15+
this —", "Before I escalate —"), so the patron sees two.
16+
2. **Category label mismatch.** The ticket-preview "Category" field renders
17+
`draft.category.rawValue.capitalized` — so `signin` → "Signin" (no space) and
18+
`reader` → "Reader", neither matching the chip the patron tapped ("Sign in",
19+
"Reading").
20+
21+
## Claims
22+
23+
- adds public computed property `displayName` to `KBCategory` (patron-facing
24+
label matching the category-chip wording)
25+
- migrates `TicketPreviewCard` category row from `rawValue.capitalized` to
26+
`category.displayName`
27+
- migrates `CategoryChipsView` chip labels to `category.displayName` (single
28+
source of truth so chip and preview can never drift again)
29+
- removes the self-preamble from the 3 corpus `escalationFollowUp.prompt`s in
30+
catalog.json so the reducer owns the single canonical preamble
31+
32+
## Anti-claims
33+
34+
- does NOT change the reducer's preamble wording or the escalation flow
35+
- does NOT change any category `rawValue` (JSON/telemetry keys unchanged)
36+
- does NOT touch redaction, send-consent, or ticket-submission logic
37+
- does NOT touch any auth / DRM / borrow / download path
38+
39+
## Files in scope
40+
41+
- Palace/Packages/PalaceTriageBot/Sources/TriageBotCore/Models/KBEntry.swift
42+
- Palace/Packages/PalaceTriageBot/Sources/TriageBotCore/Resources/catalog.json
43+
- Palace/Packages/PalaceTriageBot/Sources/TriageBotUI/TicketPreviewCard.swift
44+
- Palace/Packages/PalaceTriageBot/Sources/TriageBotUI/CategoryChipsView.swift
45+
- Palace/Packages/PalaceTriageBot/Tests/TriageBotCoreTests/KBCategoryDisplayNameTests.swift
46+
- Palace/Packages/PalaceTriageBot/Tests/TriageBotCoreTests/EscalationPreambleTests.swift

Palace/Packages/PalaceTriageBot/Sources/TriageBotCore/Models/KBEntry.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ public enum KBCategory: String, Codable, Sendable, CaseIterable {
3131
case download
3232
case library
3333
case other
34+
35+
/// Patron-facing label, matching the category-chip wording exactly so the
36+
/// ticket-preview "Category" field reads the same as the chip the patron
37+
/// tapped (PP-4847). `rawValue.capitalized` produced "Signin" (no space) and
38+
/// surfaced the internal `reader` case as "Reader" instead of "Reading".
39+
public var displayName: String {
40+
switch self {
41+
case .audiobook: return "Audiobook"
42+
case .reader: return "Reading"
43+
case .signin: return "Sign in"
44+
case .download: return "Download"
45+
case .library: return "Library"
46+
case .other: return "Other"
47+
}
48+
}
3449
}
3550

3651
/// What KIND of entry this is — the axis that lets general-help content live

Palace/Packages/PalaceTriageBot/Sources/TriageBotCore/Resources/catalog.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@
105105
]
106106
},
107107
"escalation_follow_up": {
108-
"prompt": "Before I send this to support — which audiobook title is doing this? It helps them spot patterns across publishers.",
108+
"prompt": "Which audiobook title is doing this? It helps them spot patterns across publishers.",
109109
"placeholder": "e.g. The Midnight Library by Matt Haig",
110110
"diagnostic": "audiobook.followup_title_captured"
111111
},
@@ -326,7 +326,7 @@
326326
]
327327
},
328328
"escalation_follow_up": {
329-
"prompt": "Quick question before I file this — which library are you trying to add? (City, county, or system name is fine.)",
329+
"prompt": "Which library are you trying to add? (City, county, or system name is fine.)",
330330
"placeholder": "e.g. Brooklyn Public Library",
331331
"diagnostic": "library.followup_name_captured"
332332
},
@@ -395,7 +395,7 @@
395395
"jira": "PP-4430"
396396
},
397397
"escalation_follow_up": {
398-
"prompt": "Before I escalate — what car model + year are you using, and is it wired CarPlay or wireless? Helps us repro on the same setup.",
398+
"prompt": "What car model + year are you using, and is it wired CarPlay or wireless? Helps us repro on the same setup.",
399399
"placeholder": "e.g. 2021 Honda Civic, wired",
400400
"diagnostic": "carplay.followup_car_captured"
401401
},

Palace/Packages/PalaceTriageBot/Sources/TriageBotUI/CategoryChipsView.swift

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ struct CategoryChipsView: View {
1414

1515
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
1616

17-
private let categories: [(KBCategory, String, String)] = [
18-
(.audiobook, "🎧", "Audiobook"),
19-
(.reader, "📖", "Reading"),
20-
(.signin, "🔑", "Sign in"),
21-
(.download, "⬇️", "Download"),
22-
(.library, "📚", "Library"),
23-
(.other, "💬", "Other")
17+
// Emoji per category; the label comes from `KBCategory.displayName` so the
18+
// chip wording and the ticket-preview "Category" field share one source of
19+
// truth and can't drift (PP-4847).
20+
private let categories: [(KBCategory, String)] = [
21+
(.audiobook, "🎧"),
22+
(.reader, "📖"),
23+
(.signin, "🔑"),
24+
(.download, "⬇️"),
25+
(.library, "📚"),
26+
(.other, "💬")
2427
]
2528

2629
var body: some View {
@@ -31,8 +34,8 @@ struct CategoryChipsView: View {
3134
// compact adaptive grid at normal sizes.
3235
if dynamicTypeSize.isAccessibilitySize {
3336
VStack(alignment: .leading, spacing: 8) {
34-
ForEach(categories, id: \.0) { category, emoji, label in
35-
chip(category, emoji, label)
37+
ForEach(categories, id: \.0) { category, emoji in
38+
chip(category, emoji, category.displayName)
3639
}
3740
}
3841
} else {
@@ -41,8 +44,8 @@ struct CategoryChipsView: View {
4144
// / "Download" onto two lines. (Re-land of the pill-label fix.)
4245
LazyVGrid(columns: [GridItem(.adaptive(minimum: 155), spacing: 8)],
4346
alignment: .leading, spacing: 8) {
44-
ForEach(categories, id: \.0) { category, emoji, label in
45-
chip(category, emoji, label)
47+
ForEach(categories, id: \.0) { category, emoji in
48+
chip(category, emoji, category.displayName)
4649
}
4750
}
4851
}

Palace/Packages/PalaceTriageBot/Sources/TriageBotUI/TicketPreviewCard.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ struct TicketPreviewCard: View {
5454
}
5555

5656
VStack(alignment: .leading, spacing: 6) {
57-
staticRow("Category", value: draft.category.rawValue.capitalized)
57+
staticRow("Category", value: draft.category.displayName)
5858
staticRow("App", value: "\(draft.context.appVersion) (\(draft.context.appBuild))")
5959
staticRow("Device", value: "\(draft.context.deviceModel) · iOS \(draft.context.osVersion)")
6060

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import XCTest
2+
@testable import TriageBotCore
3+
4+
/// PP-4847: the reducer owns a single "Before I file this —" preamble on the
5+
/// escalation follow-up question. Corpus prompts must therefore be bare
6+
/// questions — a prompt that carries its own preamble renders doubled
7+
/// ("Before I file this — Before I send this to support — which title…").
8+
final class EscalationPreambleTests: XCTestCase {
9+
10+
/// Corpus lint: no follow-up prompt may carry its own "Before I file/send/
11+
/// escalate" preamble. Loads the real shipped catalog.
12+
func testCorpusFollowUpPrompts_carryNoOwnPreamble() throws {
13+
let entries = try BundledCatalogSource.loadCatalogSync().entries
14+
let banned = ["before i file", "before i send", "before i escalate"]
15+
var offenders: [String] = []
16+
for entry in entries {
17+
guard let prompt = entry.escalationFollowUp?.prompt else { continue }
18+
let lower = prompt.lowercased()
19+
if banned.contains(where: { lower.contains($0) }) {
20+
offenders.append("\(entry.id): \(prompt)")
21+
}
22+
}
23+
XCTAssertTrue(offenders.isEmpty,
24+
"these follow-up prompts carry their own preamble; the reducer adds one, so they render doubled: \(offenders)")
25+
}
26+
27+
private func drive(_ r: ConversationReducer, category: KBCategory, text: String) -> ConversationState {
28+
var (state, _) = r.reduce(state: ConversationState(), action: .start)
29+
(state, _) = r.reduce(state: state, action: .userTappedCategory(category))
30+
(state, _) = r.reduce(state: state, action: .inputChanged(text))
31+
return r.reduce(state: state, action: .userSubmittedDescription).0
32+
}
33+
34+
/// The reducer renders exactly one preamble, sitting directly in front of the
35+
/// bare-question prompt.
36+
func testReducer_followUpMessage_hasExactlyOnePreamble() {
37+
let entries = [KBEntry(id: "KI-FU", category: .other, status: .open,
38+
symptomKeywords: ["frobnicate widget"],
39+
userFacingWorkaround: "Try turning it off and on again please.",
40+
escalationFollowUp: KBEscalationFollowUp(prompt: "Which widget model is it?"),
41+
confidenceThreshold: 0.1)]
42+
let r = ConversationReducer(knowledgeBase: KnowledgeBase(
43+
catalog: KBCatalog(version: "test", updatedAt: "2026-07-20", entries: entries)))
44+
let state = drive(r, category: .other, text: "my frobnicate widget is broken")
45+
46+
let followUp = state.messages.compactMap { message -> String? in
47+
if case .text(let t) = message.kind, t.contains("Which widget model is it?") { return t }
48+
return nil
49+
}.first
50+
51+
guard let text = followUp else {
52+
return XCTFail("expected a follow-up message containing the prompt; got \(state.messages)")
53+
}
54+
let preambleCount = text.components(separatedBy: "Before I file this —").count - 1
55+
XCTAssertEqual(preambleCount, 1, "expected exactly one preamble, got \(preambleCount): \(text)")
56+
XCTAssertTrue(text.contains("Before I file this — Which widget model is it?"),
57+
"the single preamble must sit directly in front of the bare question: \(text)")
58+
}
59+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import XCTest
2+
@testable import TriageBotCore
3+
4+
/// PP-4847: the ticket-preview "Category" field must read like the chip the
5+
/// patron tapped, not `rawValue.capitalized` (which produced "Signin" and
6+
/// surfaced the internal `reader` case as "Reader" instead of "Reading").
7+
final class KBCategoryDisplayNameTests: XCTestCase {
8+
9+
func testDisplayName_matchesChipWording() {
10+
XCTAssertEqual(KBCategory.audiobook.displayName, "Audiobook")
11+
XCTAssertEqual(KBCategory.reader.displayName, "Reading")
12+
XCTAssertEqual(KBCategory.signin.displayName, "Sign in")
13+
XCTAssertEqual(KBCategory.download.displayName, "Download")
14+
XCTAssertEqual(KBCategory.library.displayName, "Library")
15+
XCTAssertEqual(KBCategory.other.displayName, "Other")
16+
}
17+
18+
/// The two cases that were actually wrong under `rawValue.capitalized`. A
19+
/// regression back to that would make these equal again — so assert they differ.
20+
func testDisplayName_signinAndReader_differFromRawValueCapitalized() {
21+
XCTAssertNotEqual(KBCategory.signin.displayName, KBCategory.signin.rawValue.capitalized,
22+
"'signin'.capitalized == 'Signin' (no space) — displayName must fix it")
23+
XCTAssertNotEqual(KBCategory.reader.displayName, KBCategory.reader.rawValue.capitalized,
24+
"'reader'.capitalized == 'Reader' — the chip says 'Reading'")
25+
}
26+
27+
func testDisplayName_everyCaseNonEmpty() {
28+
for category in KBCategory.allCases {
29+
XCTAssertFalse(category.displayName.isEmpty, "\(category) must have a display name")
30+
}
31+
}
32+
}

0 commit comments

Comments
 (0)