Skip to content

Commit 971203a

Browse files
fix: only suggest brain notes after user correction
Clean first-turn answers stay quiet. A chip appears only when the user corrects or teaches after a prior Agent reply. One unsaved chip at a time; dismissed targets stay suppressed for the session. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent ae10b41 commit 971203a

11 files changed

Lines changed: 284 additions & 47 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,9 @@ The Agent tab must not inherit the admin MCP token. `/api/agent/session` mints a
275275
without `canManageContent` and then 403'd. `get_brain_context` now stamps
276276
`callerCapabilities`; if `doNotOffer` includes `save_brain_note`, the
277277
agent must not mention it. MCP `save_brain_note` also fail-closes before
278-
the POST. Admins get a non-blocking suggestion bubble under the answer
279-
(`POST /brain/notes/propose` + accept); overlaps with existing notes or
280-
business rules merge into one intent.
278+
the POST. Admins get a non-blocking suggestion bubble only after they
279+
correct or teach the Agent (`POST /brain/notes/propose` + accept) — a
280+
clean first answer stays quiet. Overlaps merge into one intent.
281281

282282
### Verification Anti-Patterns (do not repeat)
283283

agent/SOUL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ After the answer you may offer **one short follow-up question** (a single line)
2929
action — especially `save_brain_note` — do not mention it, do not ask
3030
"should I save this", and do not render a Yes button. Answering a metric
3131
is not a request to persist it. The product UI may show a non-blocking
32-
save bubble after your answer for admins; leave that to the UI.
32+
save bubble after the user corrects or teaches a definition; leave that
33+
to the UI. Never volunteer it yourself.
3334

3435
## Remembering things — two different places
3536

backend/src/main/java/com/dbaagent/dto/BrainNoteProposalRequest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ public class BrainNoteProposalRequest {
1111
private String connectionId;
1212
private String question;
1313
private String answer;
14+
/** Previous assistant answer. Required for a proposal — clean first turns stay quiet. */
15+
private String priorAnswer;
1416
}

backend/src/main/java/com/dbaagent/service/brain/core/BrainNoteIntentService.java

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,23 @@
2020
public class BrainNoteIntentService {
2121

2222
private static final Pattern BACKTICK_IDENT = Pattern.compile("`([^`]+)`");
23-
private static final Pattern DEFINITION_CUE = Pattern.compile(
24-
"\\b(metric|definition|means|pinned|use this|correct|from|count|always|filter|join)\\b",
25-
Pattern.CASE_INSENSITIVE
26-
);
2723
private static final Set<String> STOP_TABLES = Set.of(
2824
"information_schema", "pg_catalog", "mysql", "sys", "performance_schema"
2925
);
26+
/**
27+
* User follow-ups that teach or correct. Phrase contains() — not a fat
28+
* regex — so a long Agent transcript cannot ReDoS the propose path.
29+
*/
30+
private static final List<String> FEEDBACK_PHRASES = List.of(
31+
"that's wrong", "that is wrong", "that's not", "that is not",
32+
"incorrect", "actually ", "instead", "should be", "should use",
33+
"you should", "don't use", "do not use", "never use",
34+
"always use", "always filter", "always join", "always exclude",
35+
"we use", "we always", "we never", "not that", "not the ",
36+
"pin this", "pin that", "remember this", "remember:",
37+
"save this", "save that", "use this", "use that",
38+
"too high", "too low", "off by", "the right "
39+
);
3040

3141
public record ContextItem(
3242
String id,
@@ -50,15 +60,25 @@ public record Proposal(
5060
) {}
5161

5262
public Optional<Proposal> proposeFromTurn(String question, String answer, List<ContextItem> existing) {
53-
if (answer == null || answer.isBlank()) {
54-
return Optional.empty();
55-
}
56-
String cleaned = stripMarkdownNoise(answer);
57-
if (cleaned.length() < 24) {
63+
return proposeFromTurn(question, answer, existing, null);
64+
}
65+
66+
/**
67+
* Only draft a note when the user just corrected or taught after a prior
68+
* Agent answer. A clean first-turn definition is not a recommendation.
69+
*/
70+
public Optional<Proposal> proposeFromTurn(
71+
String question,
72+
String answer,
73+
List<ContextItem> existing,
74+
String priorAnswer
75+
) {
76+
if (!isCorrectionTurn(question, priorAnswer)) {
5877
return Optional.empty();
5978
}
60-
String combined = (question == null ? "" : question) + "\n" + cleaned;
61-
if (!DEFINITION_CUE.matcher(combined).find() && !looksLikePinnedDefinition(cleaned)) {
79+
String cleaned = stripMarkdownNoise(nvl(answer));
80+
String combined = nvl(question) + "\n" + cleaned;
81+
if (combined.trim().length() < 24) {
6282
return Optional.empty();
6383
}
6484

@@ -68,13 +88,13 @@ public Optional<Proposal> proposeFromTurn(String question, String answer, List<C
6888
}
6989
String tableName = target.get()[0];
7090
String columnName = target.get()[1];
71-
String excerpt = excerpt(cleaned);
91+
String excerpt = excerpt(nvl(question) + (cleaned.isBlank() ? "" : " " + cleaned));
7292
String proposed = columnName != null
7393
? "For " + tableName + "." + columnName + ": " + excerpt
7494
: "For " + tableName + ": " + excerpt;
7595
String label = columnName != null
76-
? "Save definition: " + columnName
77-
: "Save definition: " + tableName;
96+
? "Save correction: " + columnName
97+
: "Save correction: " + tableName;
7898

7999
Proposal draft = new Proposal(
80100
columnName != null ? "COLUMN" : "TABLE",
@@ -91,6 +111,30 @@ public Optional<Proposal> proposeFromTurn(String question, String answer, List<C
91111
return Optional.of(resolveOverlap(draft, existing == null ? List.of() : existing));
92112
}
93113

114+
public boolean isCorrectionTurn(String question, String priorAnswer) {
115+
if (priorAnswer == null || priorAnswer.isBlank()) {
116+
return false;
117+
}
118+
return looksLikeUserFeedback(question);
119+
}
120+
121+
public boolean looksLikeUserFeedback(String question) {
122+
if (question == null || question.isBlank()) {
123+
return false;
124+
}
125+
String q = question.toLowerCase(Locale.ROOT).trim();
126+
if (q.startsWith("no,") || q.startsWith("no ") || q.startsWith("no-")
127+
|| q.startsWith("no—") || q.startsWith("nope")) {
128+
return true;
129+
}
130+
for (String phrase : FEEDBACK_PHRASES) {
131+
if (q.contains(phrase)) {
132+
return true;
133+
}
134+
}
135+
return false;
136+
}
137+
94138
public Proposal resolveOverlap(Proposal draft, List<ContextItem> existing) {
95139
if (draft == null) {
96140
return null;
@@ -333,9 +377,8 @@ private boolean tableMatches(String left, String right) {
333377
return false;
334378
}
335379

336-
private boolean looksLikePinnedDefinition(String answer) {
337-
return answer.toLowerCase(Locale.ROOT).contains("pinned")
338-
|| answer.toLowerCase(Locale.ROOT).contains("correct");
380+
private static String nvl(String value) {
381+
return value == null ? "" : value;
339382
}
340383

341384
private String excerpt(String text) {

backend/src/main/java/com/dbaagent/service/brain/core/BrainNoteProposalService.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ public Optional<BrainNoteProposalResponse> proposeFromTurn(BrainNoteProposalRequ
2929
return Optional.empty();
3030
}
3131
List<BrainNoteIntentService.ContextItem> context = loadContext(request.getConnectionId());
32-
return intentService.proposeFromTurn(request.getQuestion(), request.getAnswer(), context)
32+
return intentService.proposeFromTurn(
33+
request.getQuestion(),
34+
request.getAnswer(),
35+
context,
36+
request.getPriorAnswer()
37+
)
3338
.filter(proposal -> !"SKIP".equals(proposal.action()))
3439
.map(this::toResponse);
3540
}

backend/src/test/java/com/dbaagent/service/brain/core/BrainNoteIntentServiceTest.java

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,34 +9,66 @@
99

1010
class BrainNoteIntentServiceTest {
1111

12+
private static final String PRIOR_WRONG =
13+
"There are 12,004 people in dim_person.";
14+
private static final String CORRECTION =
15+
"No, that's wrong — the pinned metric is `meditator_count_current` from `marts.dim_person`.";
16+
private static final String AGENT_AFTER = "290066 meditators on that pinned metric.";
17+
1218
private final BrainNoteIntentService service = new BrainNoteIntentService();
1319

1420
@Test
15-
void proposeFromTurn_extractsQualifiedTableAndDefinition() {
21+
void proposeFromTurn_staysQuietWhenTheFirstAnswerNeedsNoFeedback() {
1622
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
1723
"what is the meditator count?",
1824
"The correct pinned metric is `meditator_count_current` from `marts.dim_person`, totaling 290066 meditators.",
19-
List.of()
25+
List.of(),
26+
null
27+
);
28+
29+
assertThat(proposal).isEmpty();
30+
}
31+
32+
@Test
33+
void proposeFromTurn_staysQuietWhenTheUserJustThanksTheAgent() {
34+
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
35+
"thanks, that's right",
36+
"Glad it helped.",
37+
List.of(),
38+
"The correct pinned metric is `meditator_count_current` from `marts.dim_person`."
39+
);
40+
41+
assertThat(proposal).isEmpty();
42+
}
43+
44+
@Test
45+
void proposeFromTurn_offersAfterUserCorrectsTheAgent() {
46+
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
47+
CORRECTION,
48+
AGENT_AFTER,
49+
List.of(),
50+
PRIOR_WRONG
2051
);
2152

2253
assertThat(proposal).isPresent();
2354
assertThat(proposal.get().action()).isEqualTo("NEW");
2455
assertThat(proposal.get().tableName()).isEqualTo("marts.dim_person");
2556
assertThat(proposal.get().columnName()).isEqualTo("meditator_count_current");
26-
assertThat(proposal.get().bubbleLabel()).contains("meditator_count_current");
27-
assertThat(proposal.get().excerpt()).contains("pinned metric");
57+
assertThat(proposal.get().bubbleLabel()).contains("correction");
58+
assertThat(proposal.get().excerpt()).contains("meditator_count_current");
2859
assertThat(proposal.get().proposedNoteText()).contains("marts.dim_person");
2960
}
3061

3162
@Test
3263
void proposeFromTurn_skipsWhenExistingNoteIsSameIntent() {
33-
String existing = "For marts.dim_person.meditator_count_current: The correct pinned metric is meditator_count_current from marts.dim_person";
64+
String existing = "For marts.dim_person.meditator_count_current: No, that's wrong — the pinned metric is meditator_count_current from marts.dim_person. 290066 meditators on that pinned metric.";
3465
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
35-
"what is the meditator count?",
36-
"The correct pinned metric is `meditator_count_current` from `marts.dim_person`.",
66+
CORRECTION,
67+
AGENT_AFTER,
3768
List.of(new BrainNoteIntentService.ContextItem(
3869
"note-1", "marts.dim_person", "meditator_count_current", existing, "brain note"
39-
))
70+
)),
71+
PRIOR_WRONG
4072
);
4173

4274
assertThat(proposal).isPresent();
@@ -47,15 +79,16 @@ void proposeFromTurn_skipsWhenExistingNoteIsSameIntent() {
4779
@Test
4880
void proposeFromTurn_mergesOverlappingContextIntoOneIntent() {
4981
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
50-
"what is the meditator count?",
51-
"The correct pinned metric is `meditator_count_current` from `marts.dim_person`, excluding ambiguous matches.",
82+
"No, that's wrong — the pinned metric is `meditator_count_current` from `marts.dim_person`, excluding ambiguous matches.",
83+
AGENT_AFTER,
5284
List.of(new BrainNoteIntentService.ContextItem(
5385
"note-1",
5486
"marts.dim_person",
5587
"meditator_count_current",
5688
"dim_person is the person dimension used for IRC region rollups.",
5789
"brain note"
58-
))
90+
)),
91+
PRIOR_WRONG
5992
);
6093

6194
assertThat(proposal).isPresent();
@@ -68,15 +101,16 @@ void proposeFromTurn_mergesOverlappingContextIntoOneIntent() {
68101
@Test
69102
void proposeFromTurn_matchesBareTableNameAgainstQualifiedContext() {
70103
Optional<BrainNoteIntentService.Proposal> proposal = service.proposeFromTurn(
71-
"what is the meditator count?",
72-
"The correct pinned metric is `meditator_count_current` from `marts.dim_person`, excluding ambiguous matches.",
104+
"No, that's wrong — the pinned metric is `meditator_count_current` from `marts.dim_person`, excluding ambiguous matches.",
105+
AGENT_AFTER,
73106
List.of(new BrainNoteIntentService.ContextItem(
74107
"rule-1",
75108
"dim_person",
76109
"meditator_count_current",
77110
"dim_person is the person dimension used for IRC region rollups.",
78111
"business rule"
79-
))
112+
)),
113+
PRIOR_WRONG
80114
);
81115

82116
assertThat(proposal).isPresent();

src/components/AgentChat/AgentChatPanel.jsx

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { brainAPI } from '@/lib/api/client'
77
import AgentMarkdown from './AgentMarkdown'
88
import AgentRecommendationBubbles from './AgentRecommendationBubbles'
99
import { sanitizeAssistantAnswer } from './sanitizeAssistantAnswer'
10+
import {
11+
isSuppressedProposal,
12+
proposalTargetKey,
13+
shouldOfferBrainSuggestion,
14+
} from './shouldOfferBrainSuggestion'
1015
import styles from './AgentChatPanel.module.css'
1116

1217
function updateLast(messages, updater) {
@@ -16,6 +21,15 @@ function updateLast(messages, updater) {
1621
return copy
1722
}
1823

24+
function findPriorAssistant(messages) {
25+
for (let i = messages.length - 3; i >= 0; i--) {
26+
if (messages[i]?.role === 'assistant' && messages[i].content) {
27+
return messages[i]
28+
}
29+
}
30+
return null
31+
}
32+
1933
function toolLabel(d) {
2034
const name = (d?.name || '').replace(/^mcp_deepsql_/, '').replace(/^skill_view$/, 'skill')
2135
if (d?.args?.query) return `SQL · ${String(d.args.query).replace(/\s+/g, ' ').slice(0, 90)}`
@@ -60,6 +74,7 @@ export default function AgentChatPanel({ connectionId, connectionName, canManage
6074
const streamIdRef = useRef(null)
6175
const listRef = useRef(null)
6276
const firstMsgRef = useRef(true)
77+
const suppressedTargetsRef = useRef(new Set())
6378
const profileRef = useRef(null) // resolved agent profile (for new sessions)
6479
const convIdRef = useRef(null) // backend conversation id (the per-user index row)
6580
const restoredRef = useRef(false) // guards the persist effect until boot finishes
@@ -170,8 +185,8 @@ export default function AgentChatPanel({ connectionId, connectionName, canManage
170185
setMessages((m) => updateLast(m, (a) => ({ ...a, streaming: false })))
171186
setSending(false)
172187
esRef.current = null
173-
// Recommendation bubbles load after the turn so the composer is
174-
// already free. Failures here must never block chat.
188+
// Correction chips load after the turn so the composer is already
189+
// free. Clean first answers stay quiet. Failures never block chat.
175190
if (canManageContent) {
176191
queueMicrotask(() => proposeFromLastTurn())
177192
}
@@ -187,15 +202,30 @@ export default function AgentChatPanel({ connectionId, connectionName, canManage
187202
const proposeFromLastTurn = () => {
188203
setMessages((current) => {
189204
const last = current[current.length - 1]
190-
const prev = current[current.length - 2]
205+
const user = current[current.length - 2]
191206
if (!last || last.role !== 'assistant' || last.error || last.proposal) {
192207
return current
193208
}
209+
const priorAssistant = findPriorAssistant(current)
210+
const hasUnsavedProposal = current.some((m) => m.proposal && !m.proposal.status)
211+
if (!shouldOfferBrainSuggestion({
212+
userText: user?.role === 'user' ? user.content : '',
213+
priorAssistantText: priorAssistant?.content || '',
214+
hasUnsavedProposal,
215+
})) {
216+
return current
217+
}
194218
const answer = sanitizeAssistantAnswer(last.content || '')
195-
const question = prev?.role === 'user' ? prev.content : ''
196-
brainAPI.proposeNoteFromTurn({ connectionId, question, answer })
219+
const question = user?.role === 'user' ? user.content : ''
220+
brainAPI.proposeNoteFromTurn({
221+
connectionId,
222+
question,
223+
answer,
224+
priorAnswer: priorAssistant.content,
225+
})
197226
.then((proposal) => {
198227
if (!proposal) return
228+
if (isSuppressedProposal(proposal, [...suppressedTargetsRef.current])) return
199229
setMessages((msgs) => updateLast(msgs, (assistant) => (
200230
assistant.proposal ? assistant : { ...assistant, proposal }
201231
)))
@@ -205,7 +235,9 @@ export default function AgentChatPanel({ connectionId, connectionName, canManage
205235
})
206236
}
207237

208-
const patchLastProposal = (patch) => {
238+
const suppressAndPatch = (proposal, patch) => {
239+
const key = proposalTargetKey(proposal)
240+
if (key) suppressedTargetsRef.current.add(key)
209241
setMessages((m) => updateLast(m, (a) => (
210242
a.proposal ? { ...a, proposal: { ...a.proposal, ...patch } } : a
211243
)))
@@ -290,8 +322,8 @@ export default function AgentChatPanel({ connectionId, connectionName, canManage
290322
<AgentRecommendationBubbles
291323
connectionId={connectionId}
292324
proposal={m.proposal}
293-
onDismiss={() => patchLastProposal({ status: 'dismissed' })}
294-
onAccepted={() => patchLastProposal({ status: 'saved' })}
325+
onDismiss={() => suppressAndPatch(m.proposal, { status: 'dismissed' })}
326+
onAccepted={() => suppressAndPatch(m.proposal, { status: 'saved' })}
295327
/>
296328
)}
297329
</div>

src/components/AgentChat/AgentRecommendationBubbles.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export default function AgentRecommendationBubbles({ connectionId, proposal, onD
4848
onClick={() => setOpen((value) => !value)}
4949
>
5050
<BookmarkPlus size={13} />
51-
{proposal.bubbleLabel || 'Save definition'}
51+
{proposal.bubbleLabel || 'Save correction'}
5252
</button>
5353
</div>
5454
{open && (

0 commit comments

Comments
 (0)