NeonClary commited on
Commit
ad7840f
·
verified ·
1 Parent(s): d0878a6

Welcome path navigator, Practical Next Steps chips (user-voice, diversity, refresh), and distinct advisor personas.

Browse files
cybersecurity_config.yaml CHANGED
@@ -221,17 +221,19 @@ personas:
221
  - Prefer one high-value follow-up over a list of questions.
222
  - If urgency is high (active incident / "was I hacked"), lead with immediate first steps before profiling.
223
  - If the user profile, conversation, or summaries name a goal or project, use it. When they say "my goal" or "custom security goal", name that goal in Thought and make every What-to-do bullet specific to it. Do not give generic phases as if you had no context.
 
224
 
225
  **Formatting (Compact Markdown v1):**
226
  - Use GitHub-Flavored Markdown.
227
- - Output exactly three sections in this order:
228
- - `### Thought` — complete reasoning or context in 1–2 finished sentences (never cut mid-sentence into the next section; never use … or ...).
229
- - `### What to do` — exactly 3 concrete action bullets as plain complete imperatives (no bold title prefixes); each a distinct actionable step (not leftover thought prose).
230
- - `### Next step` — one imperative sentence that is NOT a copy or near-paraphrase of the first What-to-do bullet; name the single most important action to start with now.
231
  - Use `###` for headings, `-` for bullets (no unicode bullets), keep number text on the same line (e.g., `1. Do X`).
232
  - Insert one blank line between blocks.
233
  - Never put actions under Thought, and never put reasoning bullets under What to do.
234
  - Never truncate with ellipsis; finish every sentence and bullet.
 
235
 
236
  personas_dir: "personas/cyber_advisors"
237
 
 
221
  - Prefer one high-value follow-up over a list of questions.
222
  - If urgency is high (active incident / "was I hacked"), lead with immediate first steps before profiling.
223
  - If the user profile, conversation, or summaries name a goal or project, use it. When they say "my goal" or "custom security goal", name that goal in Thought and make every What-to-do bullet specific to it. Do not give generic phases as if you had no context.
224
+ - You are one specialist on a panel answering in parallel. Fill Thought / What to do / Next step from YOUR role only. Do not open Thought by restating the question as "To [verb] …" or "Here's a step-by-step approach." Do not emit a generic curriculum (purpose / scope / basics / research-plan-write) unless that is truly your specialty. Fiction and creative asks still get YOUR professional lens, not a shared syllabus.
225
 
226
  **Formatting (Compact Markdown v1):**
227
  - Use GitHub-Flavored Markdown.
228
+ - Output exactly three sections in this order (shared headings; your content must still be unique):
229
+ - `### Thought` — YOUR specialist take in 1–2 finished sentences (never cut mid-sentence into the next section; never use … or ...).
230
+ - `### What to do` — exactly 3 concrete action bullets as plain complete imperatives (no bold title prefixes); each a distinct artifact of YOUR specialty (not leftover thought prose, not the same three phases every advisor would write).
231
+ - `### Next step` — one imperative sentence that is NOT a copy or near-paraphrase of the first What-to-do bullet; the next action YOUR role would assign now.
232
  - Use `###` for headings, `-` for bullets (no unicode bullets), keep number text on the same line (e.g., `1. Do X`).
233
  - Insert one blank line between blocks.
234
  - Never put actions under Thought, and never put reasoning bullets under What to do.
235
  - Never truncate with ellipsis; finish every sentence and bullet.
236
+ - Stay short; do not pad to match other advisors.
237
 
238
  personas_dir: "personas/cyber_advisors"
239
 
frontend/src/App.js CHANGED
@@ -227,6 +227,7 @@ function App() {
227
  onNavigateToHome={navigateToHome}
228
  onNavigateToCanvas={navigateToCanvas}
229
  onNavigateToJourney={navigateToJourney}
 
230
  onSignOut={handleSignOut}
231
  onUserUpdate={setUser}
232
  chatNavView={chatNavView}
 
227
  onNavigateToHome={navigateToHome}
228
  onNavigateToCanvas={navigateToCanvas}
229
  onNavigateToJourney={navigateToJourney}
230
+ onNavigateToSignup={navigateToAuth}
231
  onSignOut={handleSignOut}
232
  onUserUpdate={setUser}
233
  chatNavView={chatNavView}
frontend/src/components/IntakePanel.js CHANGED
@@ -1,39 +1,75 @@
1
- import React, { useEffect, useMemo, useState } from 'react';
 
 
2
  import { useAppConfig } from '../contexts/AppConfigContext';
3
  import SecurityChatBotIcon from './icons/SecurityChatBotIcon';
4
  import { goalCacheKey } from '../utils/statedGoal';
5
  import { loadStarterGreeting, saveStarterGreeting } from '../utils/starterCache';
 
 
 
 
 
 
 
 
6
  import '../styles/IntakePanel.css';
7
 
8
  const STATIC_GREETING = 'What cybersecurity problem should we work on first?';
9
  const STATIC_SUBHEADER =
10
- 'I can help with threats, controls, incidents, compliance, or your security career.';
11
 
12
- /**
13
- * First-session intake: goal-specific greeting + chips + optional free-text.
14
- * Chips come from /api/config → chat_page.intake, filtered by guest persona when set.
15
- */
16
  const IntakePanel = ({
17
- onSubmit,
18
  guestPersona = null,
19
  authToken = null,
20
  user = null,
21
  statedGoal = '',
 
 
 
 
 
 
 
 
22
  }) => {
23
  const { config } = useAppConfig();
24
  const intake = config?.chat_page?.intake || {};
25
  const yamlGreeting = intake.greeting || STATIC_GREETING;
26
  const yamlSubheader = intake.greeting_subheader || STATIC_SUBHEADER;
 
27
 
 
28
  const cacheKey = useMemo(
29
- () => goalCacheKey(statedGoal, user?.id || user?._id || user?.email || (user?.is_guest ? 'guest' : '')),
30
- [statedGoal, user],
31
  );
32
 
33
  const cached = useMemo(() => loadStarterGreeting(cacheKey), [cacheKey]);
34
 
35
  const [greeting, setGreeting] = useState(cached?.greeting || yamlGreeting);
36
  const [subheader, setSubheader] = useState(cached?.subheader || yamlSubheader);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  useEffect(() => {
39
  const stored = loadStarterGreeting(cacheKey);
@@ -82,73 +118,149 @@ const IntakePanel = ({
82
  };
83
  }, [authToken, cacheKey, yamlGreeting, yamlSubheader]);
84
 
85
- const chips = useMemo(() => {
86
- const byPersona = intake.by_persona || {};
87
- const personaKey = (guestPersona || '').toLowerCase();
88
- const personaChips = personaKey && Array.isArray(byPersona[personaKey])
89
- ? byPersona[personaKey]
90
- : null;
91
- if (personaChips && personaChips.length > 0) return personaChips;
92
- return Array.isArray(intake.chips) ? intake.chips : [];
93
- }, [intake, guestPersona]);
94
-
95
- const [freeText, setFreeText] = useState('');
96
- const [showFreeText, setShowFreeText] = useState(false);
97
-
98
- const handleChip = (chip) => {
99
- if (chip.free_text) {
100
- setShowFreeText(true);
101
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  }
103
- if (chip.prompt) onSubmit(chip.prompt);
104
- };
 
 
 
 
 
 
 
 
 
105
 
106
- const handleFreeSubmit = (e) => {
107
- e.preventDefault();
108
- const text = freeText.trim();
109
- if (!text) return;
110
- onSubmit(text);
111
- setFreeText('');
112
- };
113
 
114
  return (
115
- <div className="intake-panel" role="region" aria-label="Getting started">
116
  <div className="intake-avatar" aria-hidden="true">
117
  <SecurityChatBotIcon size={22} />
118
  </div>
119
  <h2 className="intake-greeting">{greeting}</h2>
120
  {subheader ? <p className="intake-sub">{subheader}</p> : null}
121
- <div className="intake-chips">
122
- {chips.map((chip) => (
123
- <button
124
- key={chip.id}
125
- type="button"
126
- className={`intake-chip${chip.free_text ? ' intake-chip--other' : ''}`}
127
- onClick={() => handleChip(chip)}
128
- >
129
- {chip.label}
130
- </button>
131
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  </div>
133
- {(showFreeText || chips.length === 0) && (
134
- <form className="intake-freetext" onSubmit={handleFreeSubmit}>
135
- <label htmlFor="intake-free" className="sr-only">
136
- Describe your cybersecurity need
137
- </label>
138
- <textarea
139
- id="intake-free"
140
- rows={3}
141
- value={freeText}
142
- onChange={(e) => setFreeText(e.target.value)}
143
- placeholder="Tell me about the cybersecurity problem or goal you have today…"
144
- />
145
- <button type="submit" className="intake-submit" disabled={!freeText.trim()}>
146
- Start
147
- </button>
148
- </form>
149
- )}
150
  </div>
151
  );
152
  };
153
 
 
 
 
 
 
154
  export default IntakePanel;
 
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import * as LucideIcons from 'lucide-react';
3
+ import { ChevronRight } from 'lucide-react';
4
  import { useAppConfig } from '../contexts/AppConfigContext';
5
  import SecurityChatBotIcon from './icons/SecurityChatBotIcon';
6
  import { goalCacheKey } from '../utils/statedGoal';
7
  import { loadStarterGreeting, saveStarterGreeting } from '../utils/starterCache';
8
+ import { PATH_SECTION_TITLE } from '../data/advisorPaths';
9
+ import {
10
+ getAvailableSteps,
11
+ loadPathProgress,
12
+ markStepComplete,
13
+ mergeAutoCompletions,
14
+ savePathProgress,
15
+ } from '../utils/advisorPathProgress';
16
  import '../styles/IntakePanel.css';
17
 
18
  const STATIC_GREETING = 'What cybersecurity problem should we work on first?';
19
  const STATIC_SUBHEADER =
20
+ 'Explore the panel step by step — or jump straight to a suggested question below.';
21
 
 
 
 
 
22
  const IntakePanel = ({
 
23
  guestPersona = null,
24
  authToken = null,
25
  user = null,
26
  statedGoal = '',
27
+ pathSignals = {},
28
+ onOpenProfile,
29
+ onNavigateToJourney,
30
+ onNavigateToCanvas,
31
+ onOpenUserGuide,
32
+ onOpenModelStatus,
33
+ onNavigateToSignup,
34
+ onPathStepComplete,
35
  }) => {
36
  const { config } = useAppConfig();
37
  const intake = config?.chat_page?.intake || {};
38
  const yamlGreeting = intake.greeting || STATIC_GREETING;
39
  const yamlSubheader = intake.greeting_subheader || STATIC_SUBHEADER;
40
+ const isGuest = Boolean(user?.is_guest);
41
 
42
+ const userKey = user?.id || user?._id || user?.email || (isGuest ? 'guest' : '');
43
  const cacheKey = useMemo(
44
+ () => goalCacheKey(statedGoal, userKey),
45
+ [statedGoal, userKey],
46
  );
47
 
48
  const cached = useMemo(() => loadStarterGreeting(cacheKey), [cacheKey]);
49
 
50
  const [greeting, setGreeting] = useState(cached?.greeting || yamlGreeting);
51
  const [subheader, setSubheader] = useState(cached?.subheader || yamlSubheader);
52
+ const [expandedId, setExpandedId] = useState(null);
53
+ const [progress, setProgress] = useState(() => loadPathProgress(userKey));
54
+
55
+ useEffect(() => {
56
+ setProgress(loadPathProgress(userKey));
57
+ }, [userKey]);
58
+
59
+ useEffect(() => {
60
+ setProgress((prev) => {
61
+ const merged = mergeAutoCompletions(prev.completed, pathSignals);
62
+ if (
63
+ merged.length === prev.completed.length
64
+ && merged.every((id, i) => id === prev.completed[i])
65
+ ) {
66
+ return prev;
67
+ }
68
+ const next = { ...prev, completed: merged };
69
+ savePathProgress(userKey, next);
70
+ return next;
71
+ });
72
+ }, [pathSignals, userKey]);
73
 
74
  useEffect(() => {
75
  const stored = loadStarterGreeting(cacheKey);
 
118
  };
119
  }, [authToken, cacheKey, yamlGreeting, yamlSubheader]);
120
 
121
+ const completed = progress.completed;
122
+ const nextSteps = useMemo(
123
+ () => getAvailableSteps({ completed, isGuest, limit: 4 }),
124
+ [completed, isGuest],
125
+ );
126
+
127
+ const completeStep = useCallback((stepId) => {
128
+ const nextCompleted = markStepComplete(completed, stepId);
129
+ const next = { completed: nextCompleted, activeId: null };
130
+ setProgress(next);
131
+ savePathProgress(userKey, next);
132
+ setExpandedId(null);
133
+ onPathStepComplete?.(stepId);
134
+ }, [completed, onPathStepComplete, userKey]);
135
+
136
+ const runAction = useCallback((node) => {
137
+ const { type, label } = node.action;
138
+ switch (type) {
139
+ case 'scroll_suggestions':
140
+ document.querySelector('.suggestions-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
141
+ break;
142
+ case 'open_profile':
143
+ onOpenProfile?.();
144
+ break;
145
+ case 'navigate_journey':
146
+ onNavigateToJourney?.();
147
+ break;
148
+ case 'navigate_canvas':
149
+ onNavigateToCanvas?.();
150
+ break;
151
+ case 'open_user_guide':
152
+ if (onOpenUserGuide) onOpenUserGuide();
153
+ else window.dispatchEvent(new CustomEvent('open-user-guide'));
154
+ break;
155
+ case 'open_model_status':
156
+ onOpenModelStatus?.();
157
+ break;
158
+ case 'navigate_signup':
159
+ onNavigateToSignup?.();
160
+ break;
161
+ case 'none':
162
+ default:
163
+ break;
164
  }
165
+ completeStep(node.id);
166
+ return label;
167
+ }, [
168
+ completeStep,
169
+ onNavigateToCanvas,
170
+ onNavigateToJourney,
171
+ onNavigateToSignup,
172
+ onOpenModelStatus,
173
+ onOpenProfile,
174
+ onOpenUserGuide,
175
+ ]);
176
 
177
+ const resolveIcon = (name) => LucideIcons[name] || LucideIcons.Circle;
 
 
 
 
 
 
178
 
179
  return (
180
+ <div className="intake-panel" role="region" aria-label="Get oriented">
181
  <div className="intake-avatar" aria-hidden="true">
182
  <SecurityChatBotIcon size={22} />
183
  </div>
184
  <h2 className="intake-greeting">{greeting}</h2>
185
  {subheader ? <p className="intake-sub">{subheader}</p> : null}
186
+
187
+ <div className="advisor-path">
188
+ <div className="advisor-path-header">
189
+ <h3 className="advisor-path-title">{PATH_SECTION_TITLE}</h3>
190
+ </div>
191
+
192
+ {nextSteps.length === 0 ? (
193
+ <p className="advisor-path-all-done">
194
+ You&apos;ve explored the main paths — use the suggestions below for your next question.
195
+ </p>
196
+ ) : (
197
+ <ul className="advisor-path-steps">
198
+ {nextSteps.map((node) => {
199
+ const Icon = resolveIcon(node.icon);
200
+ const expanded = expandedId === node.id;
201
+ return (
202
+ <li key={node.id} className={`advisor-path-step${expanded ? ' is-expanded' : ''}`}>
203
+ <button
204
+ type="button"
205
+ className="advisor-path-step-btn"
206
+ aria-expanded={expanded}
207
+ onClick={() => setExpandedId(expanded ? null : node.id)}
208
+ >
209
+ <span className="advisor-path-step-icon" aria-hidden="true">
210
+ <Icon size={16} />
211
+ </span>
212
+ <span className="advisor-path-step-text">
213
+ <span className="advisor-path-step-title">{node.title}</span>
214
+ <span className="advisor-path-step-tagline">{node.tagline}</span>
215
+ </span>
216
+ <ChevronRight
217
+ size={16}
218
+ className={`advisor-path-step-chevron${expanded ? ' is-open' : ''}`}
219
+ aria-hidden="true"
220
+ />
221
+ </button>
222
+ {expanded && (
223
+ <div className="advisor-path-step-detail">
224
+ <p>{node.guide}</p>
225
+ <button
226
+ type="button"
227
+ className="advisor-path-action"
228
+ onClick={() => runAction(node)}
229
+ >
230
+ {node.action.label || 'Continue'}
231
+ </button>
232
+ </div>
233
+ )}
234
+ </li>
235
+ );
236
+ })}
237
+ </ul>
238
+ )}
239
+
240
+ {isGuest && (
241
+ <p className="advisor-path-guest-note">
242
+ <KeyRoundIcon />
243
+ Guest mode — progress is saved in this browser only.
244
+ {' '}
245
+ <button
246
+ type="button"
247
+ className="advisor-path-guest-link"
248
+ onClick={() => onNavigateToSignup?.()}
249
+ >
250
+ Create an account
251
+ </button>
252
+ {' '}
253
+ to keep chats, profile, and journey across devices.
254
+ </p>
255
+ )}
256
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  </div>
258
  );
259
  };
260
 
261
+ function KeyRoundIcon() {
262
+ const Icon = LucideIcons.KeyRound;
263
+ return <Icon size={14} className="advisor-path-guest-icon" aria-hidden="true" />;
264
+ }
265
+
266
  export default IntakePanel;
frontend/src/components/SuggestionsPanel.js CHANGED
@@ -1,5 +1,6 @@
1
  // src/components/SuggestionsPanel.js
2
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 
3
  import { useAppConfig } from '../contexts/AppConfigContext';
4
  import { goalCacheKey } from '../utils/statedGoal';
5
  import { loadStarterSuggestions, saveStarterSuggestions } from '../utils/starterCache';
@@ -8,8 +9,14 @@ import {
8
  applyFallbackIfTimedOut,
9
  applySlotDelta,
10
  applySlotDone,
 
11
  buildStarterSlots,
12
- slotsToCategories,
 
 
 
 
 
13
  visiblePrompts,
14
  } from '../utils/starterSuggestionsUi';
15
 
@@ -18,6 +25,8 @@ async function fetchStarterSuggestions({
18
  count,
19
  exclude,
20
  categoryTitles,
 
 
21
  signal,
22
  }) {
23
  if (!authToken) return [];
@@ -34,6 +43,8 @@ async function fetchStarterSuggestions({
34
  count,
35
  exclude,
36
  category_titles: categoryTitles,
 
 
37
  }),
38
  signal,
39
  }
@@ -42,8 +53,8 @@ async function fetchStarterSuggestions({
42
  const data = await response.json();
43
  if (!Array.isArray(data.suggestions)) return [];
44
  return data.suggestions
45
- .map((item) => (typeof item === 'string' ? item.trim() : ''))
46
- .filter(Boolean);
47
  } catch (err) {
48
  if (err?.name === 'AbortError') return [];
49
  return [];
@@ -56,18 +67,77 @@ function restoreSlotsFromCache(examples, cached) {
56
  if (!Array.isArray(saved) || saved.length === 0) return null;
57
  saved.forEach((item) => {
58
  const idx = typeof item.slot === 'number' ? item.slot : -1;
59
- if (idx < 0 || idx >= slots.length || !item.text) return;
 
60
  slots[idx] = {
61
  ...slots[idx],
62
- text: item.text,
 
63
  pending: false,
64
  generated: item.generated !== false,
 
 
 
65
  };
66
  });
67
  if (!slots.some((s) => s.text)) return null;
68
  return slots;
69
  }
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  const SuggestionsPanel = ({
72
  onSuggestionClick,
73
  guestPersona = null,
@@ -76,7 +146,7 @@ const SuggestionsPanel = ({
76
  statedGoal = '',
77
  onSuggestionsReady = null,
78
  }) => {
79
- const { config, resolveIcon } = useAppConfig();
80
 
81
  const examples = useMemo(() => {
82
  const chatPage = config?.chat_page || {};
@@ -99,11 +169,9 @@ const SuggestionsPanel = ({
99
  const usedRef = useRef(new Set());
100
  slotsRef.current = slots;
101
  const readyNotified = useRef('');
 
102
 
103
- const categories = useMemo(
104
- () => slotsToCategories(examples, slots),
105
- [examples, slots],
106
- );
107
 
108
  useEffect(() => {
109
  const current = slotsRef.current || [];
@@ -184,7 +252,7 @@ const SuggestionsPanel = ({
184
  if (payload.type === 'delta') {
185
  setSlots((prev) => applySlotDelta(prev, payload.slot, payload.text || ''));
186
  } else if (payload.type === 'done') {
187
- setSlots((prev) => applySlotDone(prev, payload.slot, payload.text || ''));
188
  }
189
  }
190
  }
@@ -208,95 +276,88 @@ const SuggestionsPanel = ({
208
  slots: slots.map((s) => ({
209
  slot: s.slot,
210
  text: s.text,
 
211
  generated: s.generated,
 
212
  })),
213
  });
214
  }, [slots, cacheKey]);
215
 
216
- const handleChipClick = (categoryIndex, suggestionIndex, text) => {
217
- if (!text) return;
218
- onSuggestionClick(text);
219
- usedRef.current.add(text);
220
  if (!authToken) return;
 
 
 
 
 
 
 
 
 
221
 
222
- const visible = visiblePrompts(slotsRef.current);
223
- const exclude = [...new Set([...usedRef.current, ...visible])];
224
- const title = examples[categoryIndex]?.title;
225
- const slot = slotsRef.current.find(
226
- (s) => s.categoryIndex === categoryIndex && s.suggestionIndex === suggestionIndex,
227
  );
228
 
229
- fetchStarterSuggestions({
230
  authToken,
231
  count: 1,
232
  exclude,
233
- categoryTitles: title ? [title] : [],
234
- }).then((generated) => {
235
- const replacement = generated[0];
236
- if (!replacement || slot == null) return;
237
- setSlots((prev) => applySlotDone(prev, slot.slot, replacement));
238
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  };
240
 
 
 
241
  return (
242
  <div className="suggestions-panel">
243
  <div className="suggestions-header">
244
- <h2 className="suggestions-title">Getting Started</h2>
245
  </div>
246
-
247
- <div className="suggestions-grid">
248
- {categories.map((category, categoryIndex) => {
249
- const Icon = resolveIcon(category.icon);
250
- return (
251
- <div key={categoryIndex} className="suggestion-category">
252
- <div className="category-header">
253
- <div
254
- className="category-icon"
255
- style={{
256
- backgroundColor: category.bg_color || '#F3F4F6',
257
- color: category.color || '#6B7280'
258
- }}
259
- >
260
- <Icon size={16} />
261
- </div>
262
- <h3
263
- className="category-title"
264
- style={{ color: category.color || '#6B7280' }}
265
- >
266
- {category.title}
267
- </h3>
268
- </div>
269
-
270
- <div className="suggestion-buttons">
271
- {(category.suggestions || []).map((suggestion, suggestionIndex) => {
272
- const pending = category.pending?.[suggestionIndex];
273
- const label = suggestion || (pending ? 'Writing a question…' : '');
274
- return (
275
- <button
276
- key={`${categoryIndex}-${suggestionIndex}`}
277
- type="button"
278
- disabled={!suggestion}
279
- onClick={() => handleChipClick(categoryIndex, suggestionIndex, suggestion)}
280
- className={
281
- pending
282
- ? 'suggestion-button suggestion-pending'
283
- : 'suggestion-button'
284
- }
285
- style={{
286
- borderColor: (category.color || '#6B7280') + '20',
287
- '--hover-bg': category.bg_color || '#F3F4F6',
288
- '--hover-border': category.color || '#6B7280',
289
- '--hover-text': category.color || '#6B7280',
290
- }}
291
- >
292
- {label}
293
- </button>
294
- );
295
- })}
296
- </div>
297
- </div>
298
- );
299
- })}
300
  </div>
301
  </div>
302
  );
 
1
  // src/components/SuggestionsPanel.js
2
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { RotateCw } from 'lucide-react';
4
  import { useAppConfig } from '../contexts/AppConfigContext';
5
  import { goalCacheKey } from '../utils/statedGoal';
6
  import { loadStarterSuggestions, saveStarterSuggestions } from '../utils/starterCache';
 
9
  applyFallbackIfTimedOut,
10
  applySlotDelta,
11
  applySlotDone,
12
+ applySlotRefreshing,
13
  buildStarterSlots,
14
+ collectExcludeKeys,
15
+ nextPromptVariation,
16
+ normalizeSuggestion,
17
+ slotsToFlatList,
18
+ suggestionChatPrompt,
19
+ suggestionDisplayText,
20
  visiblePrompts,
21
  } from '../utils/starterSuggestionsUi';
22
 
 
25
  count,
26
  exclude,
27
  categoryTitles,
28
+ slotIndex,
29
+ promptVariation,
30
  signal,
31
  }) {
32
  if (!authToken) return [];
 
43
  count,
44
  exclude,
45
  category_titles: categoryTitles,
46
+ slot_index: slotIndex,
47
+ prompt_variation: promptVariation,
48
  }),
49
  signal,
50
  }
 
53
  const data = await response.json();
54
  if (!Array.isArray(data.suggestions)) return [];
55
  return data.suggestions
56
+ .map((item) => normalizeSuggestion(item))
57
+ .filter((item) => suggestionChatPrompt(item));
58
  } catch (err) {
59
  if (err?.name === 'AbortError') return [];
60
  return [];
 
67
  if (!Array.isArray(saved) || saved.length === 0) return null;
68
  saved.forEach((item) => {
69
  const idx = typeof item.slot === 'number' ? item.slot : -1;
70
+ if (idx < 0 || idx >= slots.length) return;
71
+ const suggestion = normalizeSuggestion(item.suggestion || item.text, slots[idx].fallback);
72
  slots[idx] = {
73
  ...slots[idx],
74
+ suggestion,
75
+ text: suggestionDisplayText(suggestion),
76
  pending: false,
77
  generated: item.generated !== false,
78
+ promptVariation: typeof item.promptVariation === 'number'
79
+ ? item.promptVariation
80
+ : slots[idx].promptVariation,
81
  };
82
  });
83
  if (!slots.some((s) => s.text)) return null;
84
  return slots;
85
  }
86
 
87
+ function SuggestionChip({
88
+ slotItem,
89
+ onSelect,
90
+ onRefresh,
91
+ }) {
92
+ const {
93
+ slot,
94
+ suggestion,
95
+ displayText,
96
+ isPending,
97
+ refreshing,
98
+ } = slotItem;
99
+ const question = (suggestion?.question || '').trim();
100
+ const disabled = !displayText && !refreshing;
101
+ const label = displayText || (isPending ? 'Writing a question…' : '');
102
+
103
+ return (
104
+ <div className="suggestion-chip-wrap">
105
+ <button
106
+ type="button"
107
+ disabled={disabled}
108
+ onClick={() => onSelect(slotItem)}
109
+ className={[
110
+ 'suggestion-button',
111
+ isPending ? 'suggestion-pending' : '',
112
+ refreshing ? 'suggestion-refreshing' : '',
113
+ ].filter(Boolean).join(' ')}
114
+ aria-label={displayText || 'Suggested question'}
115
+ >
116
+ <span className="suggestion-chip-text">
117
+ {question && !isPending ? (
118
+ <strong className="suggestion-chip-question">{question}</strong>
119
+ ) : (
120
+ label
121
+ )}
122
+ </span>
123
+ </button>
124
+ <button
125
+ type="button"
126
+ className="suggestion-refresh-btn"
127
+ aria-label="Refresh this suggestion"
128
+ title="Refresh suggestion"
129
+ disabled={refreshing || isPending}
130
+ onClick={(event) => {
131
+ event.stopPropagation();
132
+ onRefresh(slot);
133
+ }}
134
+ >
135
+ <RotateCw size={14} className={refreshing ? 'suggestion-refresh-spin' : ''} />
136
+ </button>
137
+ </div>
138
+ );
139
+ }
140
+
141
  const SuggestionsPanel = ({
142
  onSuggestionClick,
143
  guestPersona = null,
 
146
  statedGoal = '',
147
  onSuggestionsReady = null,
148
  }) => {
149
+ const { config } = useAppConfig();
150
 
151
  const examples = useMemo(() => {
152
  const chatPage = config?.chat_page || {};
 
169
  const usedRef = useRef(new Set());
170
  slotsRef.current = slots;
171
  const readyNotified = useRef('');
172
+ const refreshAbortRef = useRef(null);
173
 
174
+ const flatSlots = useMemo(() => slotsToFlatList(slots), [slots]);
 
 
 
175
 
176
  useEffect(() => {
177
  const current = slotsRef.current || [];
 
252
  if (payload.type === 'delta') {
253
  setSlots((prev) => applySlotDelta(prev, payload.slot, payload.text || ''));
254
  } else if (payload.type === 'done') {
255
+ setSlots((prev) => applySlotDone(prev, payload.slot, payload));
256
  }
257
  }
258
  }
 
276
  slots: slots.map((s) => ({
277
  slot: s.slot,
278
  text: s.text,
279
+ suggestion: s.suggestion,
280
  generated: s.generated,
281
+ promptVariation: s.promptVariation,
282
  })),
283
  });
284
  }, [slots, cacheKey]);
285
 
286
+ const regenerateSlot = useCallback(async (slotIndex, { markUsed = null } = {}) => {
 
 
 
287
  if (!authToken) return;
288
+ const slot = slotsRef.current.find((s) => s.slot === slotIndex);
289
+ if (!slot) return;
290
+
291
+ refreshAbortRef.current?.abort();
292
+ const controller = new AbortController();
293
+ refreshAbortRef.current = controller;
294
+
295
+ const variation = nextPromptVariation(slot.promptVariation);
296
+ setSlots((prev) => applySlotRefreshing(prev, slotIndex, true));
297
 
298
+ const exclude = collectExcludeKeys(
299
+ slotsRef.current.filter((s) => s.slot !== slotIndex),
300
+ markUsed ? [markUsed] : [...usedRef.current],
 
 
301
  );
302
 
303
+ const generated = await fetchStarterSuggestions({
304
  authToken,
305
  count: 1,
306
  exclude,
307
+ categoryTitles: slot.categoryTitle ? [slot.categoryTitle] : [],
308
+ slotIndex,
309
+ promptVariation: variation,
310
+ signal: controller.signal,
 
311
  });
312
+
313
+ const replacement = generated[0];
314
+ if (!replacement) {
315
+ setSlots((prev) => applySlotRefreshing(prev, slotIndex, false));
316
+ return;
317
+ }
318
+
319
+ setSlots((prev) => {
320
+ const next = applySlotDone(prev, slotIndex, { suggestion: replacement });
321
+ const updated = next[slotIndex];
322
+ if (updated) {
323
+ next[slotIndex] = {
324
+ ...updated,
325
+ promptVariation: variation,
326
+ };
327
+ }
328
+ return next;
329
+ });
330
+ }, [authToken]);
331
+
332
+ const handleChipClick = (slotItem) => {
333
+ const chatPrompt = suggestionChatPrompt(slotItem.suggestion);
334
+ if (!chatPrompt) return;
335
+ onSuggestionClick(chatPrompt);
336
+ usedRef.current.add(chatPrompt);
337
+ regenerateSlot(slotItem.slot, { markUsed: chatPrompt });
338
+ };
339
+
340
+ const handleRefreshClick = (slotIndex) => {
341
+ regenerateSlot(slotIndex);
342
  };
343
 
344
+ useEffect(() => () => refreshAbortRef.current?.abort(), []);
345
+
346
  return (
347
  <div className="suggestions-panel">
348
  <div className="suggestions-header">
349
+ <h2 className="suggestions-title">Practical Next Steps</h2>
350
  </div>
351
+
352
+ <div className="suggestions-flat-list">
353
+ {flatSlots.map((slotItem) => (
354
+ <SuggestionChip
355
+ key={slotItem.slot}
356
+ slotItem={slotItem}
357
+ onSelect={handleChipClick}
358
+ onRefresh={handleRefreshClick}
359
+ />
360
+ ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  </div>
362
  </div>
363
  );
frontend/src/data/advisorPaths.js ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Non-linear onboarding paths for the chat welcome screen.
3
+ * Only root nodes (tier 0) show initially; deeper steps unlock as prerequisites complete.
4
+ */
5
+
6
+ export const PATH_SECTION_TITLE = 'Find your way';
7
+
8
+ /** @typedef {'scroll_suggestions'|'open_profile'|'navigate_journey'|'navigate_canvas'|'open_user_guide'|'open_model_status'|'navigate_signup'|'none'} PathActionType */
9
+
10
+ /**
11
+ * @typedef {Object} PathNode
12
+ * @property {string} id
13
+ * @property {number} tier
14
+ * @property {string} title
15
+ * @property {string} tagline
16
+ * @property {string} guide
17
+ * @property {{ type: PathActionType, label?: string }} action
18
+ * @property {string[]} [requires] - all must be completed
19
+ * @property {string[]} [requiresAny] - at least one must be completed
20
+ * @property {string} icon - Lucide icon name
21
+ * @property {boolean} [guestOnly]
22
+ * @property {boolean} [signedInOnly]
23
+ */
24
+
25
+ /** @type {PathNode[]} */
26
+ export const PATH_NODES = [
27
+ {
28
+ id: 'first-question',
29
+ tier: 0,
30
+ title: 'Ask your first question',
31
+ tagline: 'Start with a suggested prompt',
32
+ guide:
33
+ 'Scroll to “Practical Next Steps” and tap any chip. Your panel answers in the carousel — no need to pick a topic up here.',
34
+ action: { type: 'scroll_suggestions', label: 'Show suggestions' },
35
+ icon: 'MessageCircle',
36
+ },
37
+ {
38
+ id: 'your-profile',
39
+ tier: 0,
40
+ title: 'Build your profile',
41
+ tagline: 'Role, org & context',
42
+ guide:
43
+ 'Open About You and add a few facts — who you are, what you protect, and what you’re worried about. Advisors use this in every reply.',
44
+ action: { type: 'open_profile', label: 'Open About You' },
45
+ icon: 'UserCircle',
46
+ },
47
+ {
48
+ id: 'your-journey',
49
+ tier: 0,
50
+ title: 'Set your journey',
51
+ tagline: 'Goal → milestones',
52
+ guide:
53
+ 'The Journey page turns a security goal into trackable steps — baseline hardening, audit prep, IR readiness, or career growth.',
54
+ action: { type: 'navigate_journey', label: 'Open Journey' },
55
+ icon: 'Map',
56
+ },
57
+ {
58
+ id: 'your-workspace',
59
+ tier: 0,
60
+ title: 'Open workspace',
61
+ tagline: 'Drafts & references',
62
+ guide:
63
+ 'Workspace is where you pin sources, build checklists, and draft policies. Search arXiv, NIST, NVD, and CISA without leaving the app.',
64
+ action: { type: 'navigate_canvas', label: 'Open workspace' },
65
+ icon: 'FileText',
66
+ },
67
+ {
68
+ id: 'stated-goal',
69
+ tier: 1,
70
+ title: 'Name your priority',
71
+ tagline: 'One line advisors remember',
72
+ guide:
73
+ 'Add a stated goal in About You — e.g. “SOC 2 for a 50-person SaaS” or “home network hardening.” Starters and follow-ups adapt to it.',
74
+ action: { type: 'open_profile', label: 'Set your goal' },
75
+ requiresAny: ['your-profile', 'your-journey'],
76
+ icon: 'Target',
77
+ },
78
+ {
79
+ id: 'add-references',
80
+ tier: 1,
81
+ title: 'Add reference material',
82
+ tagline: 'Upload or paste sources',
83
+ guide:
84
+ 'Attach PDFs in chat, paste from your clipboard into context, or pull citations from workspace search. Ground advice in your policies and standards.',
85
+ action: { type: 'open_user_guide', label: 'See how' },
86
+ requiresAny: ['your-workspace', 'first-question'],
87
+ icon: 'BookmarkPlus',
88
+ },
89
+ {
90
+ id: 'pick-advisors',
91
+ tier: 1,
92
+ title: 'Shape your panel',
93
+ tagline: 'Turn specialists on/off',
94
+ guide:
95
+ 'Use the advisor menu in the header to add or remove experts. Jerry stays as lead; invite compliance, IR, or career mentors when the topic fits.',
96
+ action: { type: 'none', label: 'Got it' },
97
+ requires: ['first-question'],
98
+ icon: 'Users',
99
+ },
100
+ {
101
+ id: 'explore-guide',
102
+ tier: 1,
103
+ title: 'Skim the user guide',
104
+ tagline: '2-minute feature map',
105
+ guide:
106
+ 'The guide covers chat, workspace widgets, Journey tracks, and profile memory — useful when you want the full layout before diving in.',
107
+ action: { type: 'open_user_guide', label: 'Open guide' },
108
+ requiresAny: ['first-question', 'your-workspace'],
109
+ icon: 'BookOpen',
110
+ },
111
+ {
112
+ id: 'model-status',
113
+ tier: 2,
114
+ title: 'Check advisor availability',
115
+ tagline: 'See who’s online',
116
+ guide:
117
+ 'Settings → Model Status probes each advisor endpoint. Unavailable models are hidden from the panel so you’re not waiting on a dead API.',
118
+ action: { type: 'open_model_status', label: 'Open model status' },
119
+ requires: ['first-question'],
120
+ signedInOnly: true,
121
+ icon: 'Activity',
122
+ },
123
+ {
124
+ id: 'save-progress',
125
+ tier: 2,
126
+ title: 'Create an account',
127
+ tagline: 'Keep chats & progress',
128
+ guide:
129
+ 'Guest mode is temporary. Sign up to save profile, Journey, workspace layout, and chat history across devices.',
130
+ action: { type: 'navigate_signup', label: 'Create account' },
131
+ requiresAny: ['your-profile', 'your-journey', 'first-question'],
132
+ guestOnly: true,
133
+ icon: 'KeyRound',
134
+ },
135
+ ];
136
+
137
+ export const ROOT_NODE_IDS = PATH_NODES.filter((n) => n.tier === 0).map((n) => n.id);
138
+
139
+ export function getPathNode(id) {
140
+ return PATH_NODES.find((n) => n.id === id) || null;
141
+ }
frontend/src/pages/ChatPage.js CHANGED
@@ -33,6 +33,13 @@ import {
33
  putPrefetchedStream,
34
  readNdjsonLines,
35
  } from '../utils/starterPrefetchCache';
 
 
 
 
 
 
 
36
 
37
  const ACTIVE_ADVISORS_STORAGE_KEY = 'cybersecurityActiveAdvisorIds';
38
 
@@ -55,6 +62,7 @@ const ChatPage = ({
55
  onNavigateToHome,
56
  onNavigateToCanvas,
57
  onNavigateToJourney,
 
58
  onSignOut,
59
  chatNavView = null,
60
  chatStarterNonce = 0,
@@ -122,6 +130,42 @@ const ChatPage = ({
122
  const prefetchBusyRef = useRef(false);
123
  const prefetchAbortRef = useRef(null);
124
  const prefetchUserKey = user?.id || user?._id || user?.email || (user?.is_guest ? 'guest' : 'user');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  const loadProfile = async () => {
127
  try {
@@ -722,6 +766,10 @@ const handleNewChat = async (sessionId = null) => {
722
  const handleSendMessage = async (inputMessage) => {
723
  if (!inputMessage.trim()) return;
724
 
 
 
 
 
725
  // Create user message
726
  const userMessage = {
727
  id: generateMessageId(),
@@ -1144,7 +1192,7 @@ const handleNewChat = async (sessionId = null) => {
1144
  onSidebarToggle={handleSidebarToggle}
1145
  isMobileOpen={isMobileMenuOpen}
1146
  onMobileToggle={setIsMobileMenuOpen}
1147
- onNavigateToCanvas={onNavigateToCanvas}
1148
  refreshTrigger={sidebarRefreshTrigger}
1149
  userAvatarId={userAvatarId}
1150
  onAvatarChange={handleAvatarChange}
@@ -1161,7 +1209,7 @@ const handleNewChat = async (sessionId = null) => {
1161
  setSettingsInitialTab('model-status');
1162
  setShowSettings(true);
1163
  }}
1164
- onNavigateToJourney={onNavigateToJourney}
1165
  statedGoal={statedGoal}
1166
  onRemoveSampleData={user?.is_guest ? async () => {
1167
  if (!window.confirm('Remove all sample demo data? You stay in guest mode with a clean slate.')) return;
@@ -1185,8 +1233,8 @@ const handleNewChat = async (sessionId = null) => {
1185
  currentPage="chat"
1186
  onNavigateToHome={onNavigateToHome}
1187
  onNavigateToChat={() => {}}
1188
- onNavigateToCanvas={onNavigateToCanvas}
1189
- onNavigateToJourney={onNavigateToJourney}
1190
  onMobileMenu={handleMobileMenuToggle}
1191
  >
1192
  <AdvisorStatusDropdown
@@ -1213,11 +1261,23 @@ const handleNewChat = async (sessionId = null) => {
1213
  {!hasMessages ? (
1214
  <div className="welcome-state">
1215
  <IntakePanel
1216
- onSubmit={handleSendMessage}
1217
  guestPersona={guestPersona}
1218
  authToken={authToken}
1219
  user={user}
1220
  statedGoal={statedGoal}
 
 
 
 
 
 
 
 
 
 
 
 
 
1221
  />
1222
  <SuggestionsPanel
1223
  onSuggestionClick={handleSendMessage}
 
33
  putPrefetchedStream,
34
  readNdjsonLines,
35
  } from '../utils/starterPrefetchCache';
36
+ import {
37
+ loadPathProgress,
38
+ loadVisits,
39
+ markStepComplete,
40
+ recordVisit,
41
+ savePathProgress,
42
+ } from '../utils/advisorPathProgress';
43
 
44
  const ACTIVE_ADVISORS_STORAGE_KEY = 'cybersecurityActiveAdvisorIds';
45
 
 
62
  onNavigateToHome,
63
  onNavigateToCanvas,
64
  onNavigateToJourney,
65
+ onNavigateToSignup,
66
  onSignOut,
67
  chatNavView = null,
68
  chatStarterNonce = 0,
 
130
  const prefetchBusyRef = useRef(false);
131
  const prefetchAbortRef = useRef(null);
132
  const prefetchUserKey = user?.id || user?._id || user?.email || (user?.is_guest ? 'guest' : 'user');
133
+ const pathUserKey = prefetchUserKey;
134
+
135
+ const pathSignals = useMemo(() => {
136
+ const visits = loadVisits(pathUserKey);
137
+ const profileFields = ['cyber_role', 'organization_type', 'current_goals', 'knowledge_level', 'compliance_focus'];
138
+ const hasProfileFacts = Boolean(
139
+ userProfile && profileFields.some((field) => {
140
+ const val = userProfile[field];
141
+ return Array.isArray(val) ? val.length > 0 : Boolean(String(val || '').trim());
142
+ }),
143
+ );
144
+ return {
145
+ hasChats: messages.some((m) => m.type === 'user' && !m.isReferenceSnippet),
146
+ hasProfileFacts,
147
+ hasStatedGoal: Boolean((statedGoal || '').trim()),
148
+ visitedJourney: Boolean(visits.journey),
149
+ visitedWorkspace: Boolean(visits.workspace),
150
+ };
151
+ }, [messages, pathUserKey, statedGoal, userProfile]);
152
+
153
+ const markPathStep = useCallback((stepId) => {
154
+ const prev = loadPathProgress(pathUserKey);
155
+ const completed = markStepComplete(prev.completed, stepId);
156
+ if (completed.length === prev.completed.length) return;
157
+ savePathProgress(pathUserKey, { ...prev, completed });
158
+ }, [pathUserKey]);
159
+
160
+ const handleNavigateToJourney = useCallback(() => {
161
+ recordVisit(pathUserKey, 'journey');
162
+ onNavigateToJourney?.();
163
+ }, [onNavigateToJourney, pathUserKey]);
164
+
165
+ const handleNavigateToCanvas = useCallback(() => {
166
+ recordVisit(pathUserKey, 'workspace');
167
+ onNavigateToCanvas?.();
168
+ }, [onNavigateToCanvas, pathUserKey]);
169
 
170
  const loadProfile = async () => {
171
  try {
 
766
  const handleSendMessage = async (inputMessage) => {
767
  if (!inputMessage.trim()) return;
768
 
769
+ if (messages.length === 0) {
770
+ markPathStep('first-question');
771
+ }
772
+
773
  // Create user message
774
  const userMessage = {
775
  id: generateMessageId(),
 
1192
  onSidebarToggle={handleSidebarToggle}
1193
  isMobileOpen={isMobileMenuOpen}
1194
  onMobileToggle={setIsMobileMenuOpen}
1195
+ onNavigateToCanvas={handleNavigateToCanvas}
1196
  refreshTrigger={sidebarRefreshTrigger}
1197
  userAvatarId={userAvatarId}
1198
  onAvatarChange={handleAvatarChange}
 
1209
  setSettingsInitialTab('model-status');
1210
  setShowSettings(true);
1211
  }}
1212
+ onNavigateToJourney={handleNavigateToJourney}
1213
  statedGoal={statedGoal}
1214
  onRemoveSampleData={user?.is_guest ? async () => {
1215
  if (!window.confirm('Remove all sample demo data? You stay in guest mode with a clean slate.')) return;
 
1233
  currentPage="chat"
1234
  onNavigateToHome={onNavigateToHome}
1235
  onNavigateToChat={() => {}}
1236
+ onNavigateToCanvas={handleNavigateToCanvas}
1237
+ onNavigateToJourney={handleNavigateToJourney}
1238
  onMobileMenu={handleMobileMenuToggle}
1239
  >
1240
  <AdvisorStatusDropdown
 
1261
  {!hasMessages ? (
1262
  <div className="welcome-state">
1263
  <IntakePanel
 
1264
  guestPersona={guestPersona}
1265
  authToken={authToken}
1266
  user={user}
1267
  statedGoal={statedGoal}
1268
+ pathSignals={pathSignals}
1269
+ onOpenProfile={() => {
1270
+ setAboutYouInitialTab('about');
1271
+ setShowAboutYou(true);
1272
+ }}
1273
+ onNavigateToJourney={handleNavigateToJourney}
1274
+ onNavigateToCanvas={handleNavigateToCanvas}
1275
+ onOpenModelStatus={() => {
1276
+ setSettingsInitialTab('model-status');
1277
+ setShowSettings(true);
1278
+ }}
1279
+ onNavigateToSignup={onNavigateToSignup}
1280
+ onPathStepComplete={markPathStep}
1281
  />
1282
  <SuggestionsPanel
1283
  onSuggestionClick={handleSendMessage}
frontend/src/styles/ChatPage.css CHANGED
@@ -226,9 +226,28 @@
226
  }
227
 
228
  .welcome-state .suggestions-grid {
 
 
 
 
 
 
 
 
 
229
  display: grid;
230
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
231
  gap: 8px;
 
 
 
 
 
 
 
 
 
 
232
  }
233
 
234
  .welcome-state .suggestion-category {
@@ -260,6 +279,24 @@
260
  padding: 8px 12px;
261
  font-size: 13px;
262
  border-radius: 8px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  }
264
 
265
  .welcome-state .suggestion-button.suggestion-pending {
@@ -288,6 +325,11 @@
288
  padding: 6px 10px;
289
  font-size: 12px;
290
  }
 
 
 
 
 
291
  }
292
 
293
  .suggestions-container {
@@ -1372,7 +1414,10 @@
1372
  font-weight: 500;
1373
  transition: all 0.2s ease;
1374
  position: relative;
1375
- overflow: hidden;
 
 
 
1376
  }
1377
 
1378
  .suggestion-button::before {
 
226
  }
227
 
228
  .welcome-state .suggestions-grid {
229
+ display: flex;
230
+ flex-direction: column;
231
+ gap: 8px;
232
+ max-width: 980px;
233
+ width: 100%;
234
+ margin: 0 auto;
235
+ }
236
+
237
+ .welcome-state .suggestions-flat-list {
238
  display: grid;
239
+ grid-template-columns: 1fr;
240
  gap: 8px;
241
+ max-width: 980px;
242
+ width: 100%;
243
+ margin: 0 auto;
244
+ }
245
+
246
+ @media (min-width: 768px) {
247
+ .welcome-state .suggestions-flat-list {
248
+ grid-template-columns: repeat(2, minmax(0, 1fr));
249
+ gap: 10px 12px;
250
+ }
251
  }
252
 
253
  .welcome-state .suggestion-category {
 
279
  padding: 8px 12px;
280
  font-size: 13px;
281
  border-radius: 8px;
282
+ white-space: normal;
283
+ overflow: visible;
284
+ text-overflow: unset;
285
+ }
286
+
287
+ .welcome-state .suggestion-chip-wrap .suggestion-button {
288
+ padding: 10px 40px 32px 12px;
289
+ height: auto;
290
+ max-height: none;
291
+ overflow: visible;
292
+ white-space: normal;
293
+ text-overflow: unset;
294
+ -webkit-line-clamp: unset;
295
+ line-clamp: unset;
296
+ word-break: break-word;
297
+ overflow-wrap: break-word;
298
+ font-weight: 700;
299
+ line-height: 1.5;
300
  }
301
 
302
  .welcome-state .suggestion-button.suggestion-pending {
 
325
  padding: 6px 10px;
326
  font-size: 12px;
327
  }
328
+
329
+ .welcome-state .suggestion-chip-wrap .suggestion-button {
330
+ padding: 10px 40px 32px 12px;
331
+ font-size: 13px;
332
+ }
333
  }
334
 
335
  .suggestions-container {
 
1414
  font-weight: 500;
1415
  transition: all 0.2s ease;
1416
  position: relative;
1417
+ overflow: visible;
1418
+ white-space: normal;
1419
+ text-overflow: unset;
1420
+ word-break: break-word;
1421
  }
1422
 
1423
  .suggestion-button::before {
frontend/src/styles/IntakePanel.css CHANGED
@@ -43,6 +43,194 @@
43
  margin-bottom: 0;
44
  }
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  .intake-chip {
47
  appearance: none;
48
  border: 1px solid var(--border, #e2e8f0);
@@ -131,21 +319,67 @@
131
  }
132
  }
133
 
134
- @media (max-width: 768px) {
 
135
  .intake-panel {
136
- margin: 0 auto;
137
- padding: 0 8px 8px;
 
 
 
 
 
 
 
138
  }
139
 
140
- .intake-chips {
 
141
  flex-direction: column;
 
142
  }
143
 
144
- .intake-chip {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  width: 100%;
146
  }
147
  }
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  @media (prefers-reduced-motion: reduce) {
150
  .intake-chip {
151
  transition: none;
 
43
  margin-bottom: 0;
44
  }
45
 
46
+ /* Progressive path navigator (replaces topic chips) */
47
+ .advisor-path {
48
+ text-align: left;
49
+ margin-top: 4px;
50
+ }
51
+
52
+ .advisor-path-header {
53
+ display: flex;
54
+ flex-wrap: wrap;
55
+ align-items: baseline;
56
+ justify-content: center;
57
+ gap: 8px 12px;
58
+ margin-bottom: 10px;
59
+ }
60
+
61
+ .advisor-path-title {
62
+ margin: 0;
63
+ font-size: 0.95rem;
64
+ font-weight: 650;
65
+ color: var(--text-primary, #0f172a);
66
+ }
67
+
68
+ .advisor-path-steps {
69
+ list-style: none;
70
+ margin: 0;
71
+ padding: 0;
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: 8px;
75
+ }
76
+
77
+ .advisor-path-step {
78
+ border: 1px solid var(--border, #e2e8f0);
79
+ border-radius: 12px;
80
+ background: var(--surface, #fff);
81
+ overflow: hidden;
82
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
83
+ }
84
+
85
+ .advisor-path-step.is-expanded {
86
+ border-color: color-mix(in srgb, var(--accent, #0B7A8A) 45%, var(--border, #e2e8f0));
87
+ box-shadow: 0 2px 12px color-mix(in srgb, var(--accent, #0B7A8A) 12%, transparent);
88
+ }
89
+
90
+ .advisor-path-step-btn {
91
+ appearance: none;
92
+ width: 100%;
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 10px;
96
+ padding: 10px 12px;
97
+ border: none;
98
+ background: transparent;
99
+ cursor: pointer;
100
+ text-align: left;
101
+ color: inherit;
102
+ min-height: 44px;
103
+ }
104
+
105
+ .advisor-path-step-btn:hover,
106
+ .advisor-path-step-btn:focus-visible {
107
+ background: var(--accent-soft, #DCEEF1);
108
+ outline: none;
109
+ }
110
+
111
+ .advisor-path-step-icon {
112
+ flex-shrink: 0;
113
+ display: inline-flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ width: 2rem;
117
+ height: 2rem;
118
+ border-radius: 8px;
119
+ background: var(--accent-soft, #DCEEF1);
120
+ color: var(--accent, #0B7A8A);
121
+ }
122
+
123
+ .advisor-path-step-text {
124
+ flex: 1;
125
+ min-width: 0;
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: 2px;
129
+ }
130
+
131
+ .advisor-path-step-title {
132
+ font-size: 0.875rem;
133
+ font-weight: 600;
134
+ color: var(--text-primary, #0f172a);
135
+ line-height: 1.25;
136
+ }
137
+
138
+ .advisor-path-step-tagline {
139
+ font-size: 0.78rem;
140
+ color: var(--text-secondary, #64748b);
141
+ line-height: 1.3;
142
+ }
143
+
144
+ .advisor-path-step-chevron {
145
+ flex-shrink: 0;
146
+ color: var(--text-secondary, #64748b);
147
+ transition: transform 0.15s ease;
148
+ }
149
+
150
+ .advisor-path-step-chevron.is-open {
151
+ transform: rotate(90deg);
152
+ }
153
+
154
+ .advisor-path-step-detail {
155
+ padding: 0 12px 12px 3.25rem;
156
+ border-top: 1px solid var(--border, #e2e8f0);
157
+ margin-top: -1px;
158
+ }
159
+
160
+ .advisor-path-step-detail p {
161
+ margin: 10px 0 12px;
162
+ font-size: 0.8125rem;
163
+ line-height: 1.45;
164
+ color: var(--text-secondary, #64748b);
165
+ }
166
+
167
+ .advisor-path-action {
168
+ appearance: none;
169
+ border: none;
170
+ border-radius: 8px;
171
+ padding: 8px 14px;
172
+ min-height: 40px;
173
+ font-size: 0.8125rem;
174
+ font-weight: 600;
175
+ cursor: pointer;
176
+ background: var(--accent-fill, var(--accent-primary, #0B7A8A));
177
+ color: var(--accent-on-accent, #fff);
178
+ }
179
+
180
+ .advisor-path-action:hover,
181
+ .advisor-path-action:focus-visible {
182
+ filter: brightness(1.05);
183
+ outline: none;
184
+ }
185
+
186
+ .advisor-path-all-done {
187
+ text-align: center;
188
+ margin: 0;
189
+ padding: 8px 4px;
190
+ font-size: 0.8125rem;
191
+ color: var(--text-secondary, #64748b);
192
+ }
193
+
194
+ .advisor-path-guest-note {
195
+ display: flex;
196
+ flex-wrap: wrap;
197
+ align-items: center;
198
+ justify-content: center;
199
+ gap: 4px;
200
+ margin: 12px 0 0;
201
+ padding: 10px 12px;
202
+ border-radius: 10px;
203
+ font-size: 0.75rem;
204
+ line-height: 1.45;
205
+ color: var(--text-secondary, #64748b);
206
+ background: color-mix(in srgb, var(--accent-soft, #DCEEF1) 70%, transparent);
207
+ border: 1px dashed color-mix(in srgb, var(--accent, #0B7A8A) 35%, transparent);
208
+ }
209
+
210
+ .advisor-path-guest-icon {
211
+ flex-shrink: 0;
212
+ color: var(--accent, #0B7A8A);
213
+ }
214
+
215
+ .advisor-path-guest-link {
216
+ appearance: none;
217
+ border: none;
218
+ padding: 0;
219
+ background: none;
220
+ font: inherit;
221
+ font-weight: 600;
222
+ color: var(--accent, #0B7A8A);
223
+ cursor: pointer;
224
+ text-decoration: underline;
225
+ text-underline-offset: 2px;
226
+ }
227
+
228
+ .advisor-path-guest-link:hover,
229
+ .advisor-path-guest-link:focus-visible {
230
+ outline: none;
231
+ color: var(--accent-primary, #0B7A8A);
232
+ }
233
+
234
  .intake-chip {
235
  appearance: none;
236
  border: 1px solid var(--border, #e2e8f0);
 
319
  }
320
  }
321
 
322
+ /* Wide welcome layout — match suggestions-panel width (980px) */
323
+ @media (min-width: 768px) {
324
  .intake-panel {
325
+ max-width: 980px;
326
+ width: 100%;
327
+ }
328
+
329
+ .advisor-path-steps {
330
+ display: grid;
331
+ grid-template-columns: repeat(2, minmax(0, 1fr));
332
+ gap: 10px;
333
+ align-items: start;
334
  }
335
 
336
+ .advisor-path-step {
337
+ display: flex;
338
  flex-direction: column;
339
+ height: 100%;
340
  }
341
 
342
+ .advisor-path-step-btn {
343
+ flex: 1 1 auto;
344
+ align-items: flex-start;
345
+ padding: 12px 10px;
346
+ }
347
+
348
+ .advisor-path-step-chevron {
349
+ align-self: center;
350
+ margin-top: 2px;
351
+ }
352
+
353
+ .advisor-path-step-detail {
354
+ padding: 0 10px 12px;
355
+ }
356
+
357
+ .advisor-path-step-detail p {
358
+ margin-top: 8px;
359
+ }
360
+
361
+ .advisor-path-action {
362
  width: 100%;
363
  }
364
  }
365
 
366
+ @media (min-width: 900px) {
367
+ .advisor-path-steps {
368
+ grid-template-columns: repeat(4, minmax(0, 1fr));
369
+ }
370
+ }
371
+
372
+ @media (max-width: 767px) {
373
+ .intake-panel {
374
+ margin: 0 auto;
375
+ padding: 0 8px 8px;
376
+ }
377
+
378
+ .advisor-path-step-detail {
379
+ padding-left: 12px;
380
+ }
381
+ }
382
+
383
  @media (prefers-reduced-motion: reduce) {
384
  .intake-chip {
385
  transition: none;
frontend/src/styles/components.css CHANGED
@@ -474,7 +474,7 @@ body {
474
  /* Suggestions Panel */
475
  .suggestions-panel {
476
  padding: 8px 0 0;
477
- max-width: 900px;
478
  margin: 0 auto;
479
  }
480
 
@@ -502,6 +502,121 @@ body {
502
  gap: 8px;
503
  }
504
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  .suggestion-category {
506
  background-color: var(--bg-primary);
507
  border-radius: 12px;
@@ -561,6 +676,15 @@ body {
561
  line-height: 1.35;
562
  cursor: pointer;
563
  transition: all 0.2s ease;
 
 
 
 
 
 
 
 
 
564
  }
565
 
566
  .suggestion-button:hover {
 
474
  /* Suggestions Panel */
475
  .suggestions-panel {
476
  padding: 8px 0 0;
477
+ max-width: 980px;
478
  margin: 0 auto;
479
  }
480
 
 
502
  gap: 8px;
503
  }
504
 
505
+ .suggestions-flat-list {
506
+ display: grid;
507
+ grid-template-columns: 1fr;
508
+ gap: 8px;
509
+ max-width: 980px;
510
+ width: 100%;
511
+ margin: 0 auto;
512
+ }
513
+
514
+ @media (min-width: 768px) {
515
+ .suggestions-flat-list {
516
+ grid-template-columns: repeat(2, minmax(0, 1fr));
517
+ gap: 10px 12px;
518
+ }
519
+ }
520
+
521
+ .suggestion-chip-wrap {
522
+ position: relative;
523
+ width: 100%;
524
+ min-width: 0;
525
+ overflow: visible;
526
+ height: 100%;
527
+ display: flex;
528
+ }
529
+
530
+ .suggestion-chip-wrap .suggestion-button {
531
+ display: block;
532
+ flex: 1 1 auto;
533
+ width: 100%;
534
+ min-width: 0;
535
+ height: auto;
536
+ max-height: none;
537
+ min-height: 0;
538
+ white-space: normal;
539
+ overflow: visible;
540
+ text-overflow: unset;
541
+ -webkit-line-clamp: unset;
542
+ line-clamp: unset;
543
+ word-break: break-word;
544
+ overflow-wrap: break-word;
545
+ /* Reserve the lower-right corner so copy never sits under the refresh icon */
546
+ padding: 10px 40px 32px 12px;
547
+ box-sizing: border-box;
548
+ font-weight: 700;
549
+ line-height: 1.5;
550
+ }
551
+
552
+ .suggestion-chip-text {
553
+ display: block;
554
+ white-space: normal;
555
+ overflow: visible;
556
+ text-overflow: unset;
557
+ -webkit-line-clamp: unset;
558
+ line-clamp: unset;
559
+ max-height: none;
560
+ word-break: break-word;
561
+ overflow-wrap: break-word;
562
+ line-height: 1.5;
563
+ padding: 0;
564
+ }
565
+
566
+ .suggestion-chip-question {
567
+ font-weight: 700;
568
+ white-space: normal;
569
+ overflow: visible;
570
+ display: inline;
571
+ line-height: inherit;
572
+ }
573
+
574
+ .suggestion-refresh-btn {
575
+ position: absolute;
576
+ right: 8px;
577
+ bottom: 8px;
578
+ display: inline-flex;
579
+ align-items: center;
580
+ justify-content: center;
581
+ width: 24px;
582
+ height: 24px;
583
+ padding: 0;
584
+ border: none;
585
+ border-radius: 6px;
586
+ background: transparent;
587
+ color: var(--text-tertiary, var(--text-secondary));
588
+ cursor: pointer;
589
+ opacity: 0.75;
590
+ z-index: 2;
591
+ flex-shrink: 0;
592
+ transition: opacity 0.15s ease, color 0.15s ease, background-color 0.15s ease;
593
+ }
594
+
595
+ .suggestion-chip-wrap:hover .suggestion-refresh-btn,
596
+ .suggestion-refresh-btn:focus-visible {
597
+ opacity: 1;
598
+ }
599
+
600
+ .suggestion-refresh-btn:hover:not(:disabled) {
601
+ opacity: 1;
602
+ color: var(--accent-primary, var(--text-primary));
603
+ background-color: var(--bg-secondary, rgba(0, 0, 0, 0.04));
604
+ }
605
+
606
+ .suggestion-refresh-btn:disabled {
607
+ cursor: not-allowed;
608
+ opacity: 0.35;
609
+ }
610
+
611
+ .suggestion-refresh-spin {
612
+ animation: suggestion-refresh-spin 0.8s linear infinite;
613
+ }
614
+
615
+ @keyframes suggestion-refresh-spin {
616
+ from { transform: rotate(0deg); }
617
+ to { transform: rotate(360deg); }
618
+ }
619
+
620
  .suggestion-category {
621
  background-color: var(--bg-primary);
622
  border-radius: 12px;
 
676
  line-height: 1.35;
677
  cursor: pointer;
678
  transition: all 0.2s ease;
679
+ width: 100%;
680
+ white-space: normal;
681
+ overflow: visible;
682
+ text-overflow: unset;
683
+ word-break: break-word;
684
+ }
685
+
686
+ .suggestion-button.suggestion-refreshing {
687
+ opacity: 0.85;
688
  }
689
 
690
  .suggestion-button:hover {
frontend/src/utils/advisorPathProgress.js ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PATH_NODES, getPathNode } from '../data/advisorPaths';
2
+
3
+ const STORAGE_PREFIX = 'advisor-path-progress::';
4
+
5
+ export function pathProgressKey(userId) {
6
+ const uid = userId || 'anon';
7
+ return `${STORAGE_PREFIX}${uid}`;
8
+ }
9
+
10
+ /** @returns {{ completed: string[], activeId: string|null, updatedAt: number }} */
11
+ export function loadPathProgress(userId) {
12
+ try {
13
+ const raw = localStorage.getItem(pathProgressKey(userId));
14
+ if (!raw) return { completed: [], activeId: null, updatedAt: 0 };
15
+ const parsed = JSON.parse(raw);
16
+ const completed = Array.isArray(parsed.completed)
17
+ ? parsed.completed.filter((id) => getPathNode(id))
18
+ : [];
19
+ const activeId = parsed.activeId && getPathNode(parsed.activeId) ? parsed.activeId : null;
20
+ return {
21
+ completed,
22
+ activeId,
23
+ updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : 0,
24
+ };
25
+ } catch {
26
+ return { completed: [], activeId: null, updatedAt: 0 };
27
+ }
28
+ }
29
+
30
+ export function savePathProgress(userId, state) {
31
+ try {
32
+ localStorage.setItem(
33
+ pathProgressKey(userId),
34
+ JSON.stringify({
35
+ completed: state.completed || [],
36
+ activeId: state.activeId || null,
37
+ updatedAt: Date.now(),
38
+ }),
39
+ );
40
+ } catch {
41
+ /* quota / private mode */
42
+ }
43
+ }
44
+
45
+ function prerequisitesMet(node, completedSet) {
46
+ if (node.requires?.length) {
47
+ if (!node.requires.every((id) => completedSet.has(id))) return false;
48
+ }
49
+ if (node.requiresAny?.length) {
50
+ if (!node.requiresAny.some((id) => completedSet.has(id))) return false;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ export function isNodeVisible(node, { completedSet, isGuest }) {
56
+ if (node.guestOnly && !isGuest) return false;
57
+ if (node.signedInOnly && isGuest) return false;
58
+ if (completedSet.has(node.id)) return false;
59
+ return prerequisitesMet(node, completedSet);
60
+ }
61
+
62
+ /**
63
+ * Next steps to show (max `limit`), preferring lower tier then definition order.
64
+ */
65
+ export function getAvailableSteps({ completed = [], isGuest = false, limit = 4 } = {}) {
66
+ const completedSet = new Set(completed);
67
+ return PATH_NODES.filter((node) => isNodeVisible(node, { completedSet, isGuest }))
68
+ .sort((a, b) => a.tier - b.tier || PATH_NODES.indexOf(a) - PATH_NODES.indexOf(b))
69
+ .slice(0, limit);
70
+ }
71
+
72
+ /** Highest tier among completed nodes, or 0 if none. */
73
+ export function currentTier(completed = []) {
74
+ let max = 0;
75
+ completed.forEach((id) => {
76
+ const node = getPathNode(id);
77
+ if (node && node.tier > max) max = node.tier;
78
+ });
79
+ return max;
80
+ }
81
+
82
+ /** Most recently completed node id for the “current focus” strip. */
83
+ export function latestCompleted(completed = []) {
84
+ if (!completed.length) return null;
85
+ return completed[completed.length - 1];
86
+ }
87
+
88
+ export function markStepComplete(completed, stepId) {
89
+ if (!getPathNode(stepId) || completed.includes(stepId)) return completed;
90
+ return [...completed, stepId];
91
+ }
92
+
93
+ /** Auto-detect completions from app state (does not remove manual completions). */
94
+ export function mergeAutoCompletions(completed, signals = {}) {
95
+ const next = new Set(completed);
96
+ const {
97
+ hasChats,
98
+ hasProfileFacts,
99
+ hasStatedGoal,
100
+ visitedJourney,
101
+ visitedWorkspace,
102
+ } = signals;
103
+
104
+ if (hasChats) next.add('first-question');
105
+ if (hasProfileFacts) next.add('your-profile');
106
+ if (hasStatedGoal) next.add('stated-goal');
107
+ if (visitedJourney) next.add('your-journey');
108
+ if (visitedWorkspace) next.add('your-workspace');
109
+
110
+ return PATH_NODES.map((n) => n.id).filter((id) => next.has(id));
111
+ }
112
+
113
+ export function recordVisit(userId, area) {
114
+ const key = `${STORAGE_PREFIX}visits::${userId || 'anon'}`;
115
+ try {
116
+ const raw = localStorage.getItem(key);
117
+ const visits = raw ? JSON.parse(raw) : {};
118
+ visits[area] = true;
119
+ localStorage.setItem(key, JSON.stringify(visits));
120
+ } catch {
121
+ /* ignore */
122
+ }
123
+ }
124
+
125
+ export function loadVisits(userId) {
126
+ try {
127
+ const raw = localStorage.getItem(`${STORAGE_PREFIX}visits::${userId || 'anon'}`);
128
+ return raw ? JSON.parse(raw) : {};
129
+ } catch {
130
+ return {};
131
+ }
132
+ }
frontend/src/utils/starterSuggestionsUi.js CHANGED
@@ -1,7 +1,125 @@
1
- /** Pure helpers for Getting Started chip slots, 5s fallback, and stable order. */
2
 
3
  export const STARTER_FALLBACK_MS = 5000;
4
  export const MAX_STARTER_SLOTS = 12;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  /**
7
  * Flatten example categories into stable slots 0..n (category-major order).
@@ -12,15 +130,19 @@ export function buildStarterSlots(examples, maxSlots = MAX_STARTER_SLOTS) {
12
  (examples || []).forEach((category, categoryIndex) => {
13
  (category.suggestions || []).forEach((text, suggestionIndex) => {
14
  if (slots.length >= maxSlots) return;
 
15
  slots.push({
16
  slot: slots.length,
17
  categoryIndex,
18
  suggestionIndex,
19
  categoryTitle: category.title || `Starter ${slots.length + 1}`,
20
- fallback: typeof text === 'string' ? text : '',
 
21
  text: '',
22
  generated: false,
23
  pending: true,
 
 
24
  });
25
  });
26
  });
@@ -29,35 +151,56 @@ export function buildStarterSlots(examples, maxSlots = MAX_STARTER_SLOTS) {
29
 
30
  export function applySlotDelta(slots, slotIndex, chunk) {
31
  if (!Array.isArray(slots) || slotIndex < 0 || slotIndex >= slots.length) return slots;
 
 
32
  const next = slots.slice();
33
  const current = next[slotIndex];
34
  next[slotIndex] = {
35
  ...current,
36
- text: `${current.text || ''}${chunk || ''}`,
 
 
 
 
 
37
  pending: false,
38
  generated: true,
39
  };
40
  return next;
41
  }
42
 
43
- export function applySlotDone(slots, slotIndex, text) {
44
  if (!Array.isArray(slots) || slotIndex < 0 || slotIndex >= slots.length) return slots;
45
  const next = slots.slice();
46
  const current = next[slotIndex];
47
- const finalText = (text || current.text || '').trim();
 
 
 
 
48
  next[slotIndex] = {
49
  ...current,
 
50
  text: finalText,
51
  pending: false,
 
52
  generated: Boolean(finalText) && finalText !== current.fallback,
53
  };
54
- if (finalText && finalText === current.fallback) {
55
- next[slotIndex].generated = true;
56
- }
57
  if (finalText) next[slotIndex].generated = true;
58
  return next;
59
  }
60
 
 
 
 
 
 
 
 
 
 
 
 
61
  /** If a slot has no tokens after timeout, fill with YAML fallback. Later LLM text may replace it. */
62
  export function applyFallbackIfTimedOut(slots, startedAt, now, timeoutMs = STARTER_FALLBACK_MS) {
63
  if (!Array.isArray(slots) || now - startedAt < timeoutMs) return slots;
@@ -67,9 +210,11 @@ export function applyFallbackIfTimedOut(slots, startedAt, now, timeoutMs = START
67
  if (slot.text) return { ...slot, pending: false, generated: true };
68
  if (!slot.fallback) return { ...slot, pending: false };
69
  changed = true;
 
70
  return {
71
  ...slot,
72
- text: slot.fallback,
 
73
  pending: false,
74
  generated: false,
75
  };
@@ -77,6 +222,19 @@ export function applyFallbackIfTimedOut(slots, startedAt, now, timeoutMs = START
77
  return changed ? next : slots;
78
  }
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  export function slotsToCategories(examples, slots) {
81
  return (examples || []).map((category, categoryIndex) => {
82
  const owned = (slots || []).filter((s) => s.categoryIndex === categoryIndex);
@@ -86,13 +244,34 @@ export function slotsToCategories(examples, slots) {
86
  generated: owned.map((s) => Boolean(s.generated)),
87
  pending: owned.map((s) => Boolean(s.pending && !s.text)),
88
  };
89
- }).filter((category, categoryIndex) => (
90
  (slots || []).some((s) => s.categoryIndex === categoryIndex)
91
  ));
92
  }
93
 
94
  export function visiblePrompts(slots) {
95
  return (slots || [])
96
- .map((s) => (s.text || '').trim())
97
  .filter(Boolean);
98
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Pure helpers for Practical Next Steps chip slots, fallback, and stable order. */
2
 
3
  export const STARTER_FALLBACK_MS = 5000;
4
  export const MAX_STARTER_SLOTS = 12;
5
+ export const PROMPT_VARIATION_COUNT = 6;
6
+
7
+ const FILLER_OPENER_RE = /^(?:to begin(?:\s+with)?|to shape(?:\s+your\s+path)?|to start(?:\s+with)?|to identify|to dissect|to analyze|to explore|to consider|to examine|to discuss|let'?s|first off|as a first step|i'?d like|i would like|so,?|now,?|here,?|for(?:\s+a)?\s+start|getting started|to get started|to understand|in order to|when it comes to)\b[\s,:-]*/i;
8
+ const TOPIC_COMMA_LEAD_RE = /^for\s+[^?]{1,48}?,[\s]*/i;
9
+ const ADVISOR_TO_USER_RE = /\b(?:let'?s|let us|have you considered|have you thought|have you looked|you should|you might|you need to|you can start|for instance|this idea blends|the core concept|to identify the|which creates a unique)\b/i;
10
+ const FIRST_PERSON_RE = /\b(?:i|i'm|i'd|i've|i'll|my|mine)\b/i;
11
+ const SECOND_PERSON_YOUR_RE = /\byour\b/i;
12
+
13
+ function looksLikeJsonPayload(text) {
14
+ const trimmed = String(text || '').trim();
15
+ return trimmed.startsWith('{') || trimmed.startsWith('[');
16
+ }
17
+
18
+ function extractQuestionFromPayload(raw) {
19
+ const text = String(raw || '').trim();
20
+ if (!looksLikeJsonPayload(text)) return text;
21
+ try {
22
+ const parsed = JSON.parse(text);
23
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
24
+ return String(parsed.question || parsed.text || '').trim();
25
+ }
26
+ if (Array.isArray(parsed) && parsed[0]) {
27
+ return extractQuestionFromPayload(parsed[0]);
28
+ }
29
+ } catch {
30
+ const match = text.match(/"question"\s*:\s*"((?:\\.|[^"\\])*)/);
31
+ if (match) {
32
+ try {
33
+ return JSON.parse(`"${match[1]}"`);
34
+ } catch {
35
+ return match[1].replace(/\\"/g, '"');
36
+ }
37
+ }
38
+ }
39
+ return '';
40
+ }
41
+
42
+ function isMultiSentence(text) {
43
+ let body = String(text || '').trim();
44
+ if (body.endsWith('?')) body = body.slice(0, -1);
45
+ body = body.replace(/\b(?:e\.g|i\.e|etc)\./gi, ' ').replace(/\.{2,}/g, ' ');
46
+ return /[.!?]/.test(body);
47
+ }
48
+
49
+ export function looksLikeAdvisorToUser(text) {
50
+ const cleaned = String(text || '').replace(/\s+/g, ' ').trim();
51
+ if (!cleaned) return false;
52
+ if (ADVISOR_TO_USER_RE.test(cleaned)) return true;
53
+ if (isMultiSentence(cleaned)) return true;
54
+ if (SECOND_PERSON_YOUR_RE.test(cleaned) && !FIRST_PERSON_RE.test(cleaned)) return true;
55
+ return false;
56
+ }
57
+
58
+ function userFacingChatPrompt(chatPrompt, question) {
59
+ const chat = String(chatPrompt || '').trim();
60
+ const q = String(question || '').trim();
61
+ if (chat && !looksLikeAdvisorToUser(chat)) return chat;
62
+ if (q && !looksLikeAdvisorToUser(q)) return q;
63
+ return q || chat;
64
+ }
65
+
66
+ export function stripForbiddenOpeners(text) {
67
+ let cleaned = extractQuestionFromPayload(text);
68
+ cleaned = String(cleaned || '').trim().replace(/^["']+|["']+$/g, '');
69
+ cleaned = cleaned.replace(/\s+/g, ' ').trim();
70
+ if (looksLikeJsonPayload(cleaned)) return '';
71
+ let prev = null;
72
+ while (cleaned && cleaned !== prev) {
73
+ prev = cleaned;
74
+ cleaned = cleaned.replace(FILLER_OPENER_RE, '').replace(TOPIC_COMMA_LEAD_RE, '').trim();
75
+ }
76
+ if (!cleaned) return '';
77
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
78
+ }
79
+
80
+ export function fallbackToSuggestion(text) {
81
+ const question = stripForbiddenOpeners(text) || String(text || '').trim();
82
+ return {
83
+ lead: '',
84
+ question,
85
+ chat_prompt: question,
86
+ };
87
+ }
88
+
89
+ export function normalizeSuggestion(raw, fallbackText = '') {
90
+ if (raw && typeof raw === 'object') {
91
+ const question = stripForbiddenOpeners(raw.question || raw.text || '');
92
+ const chatPrompt = String(raw.chat_prompt || raw.chatPrompt || '').trim();
93
+ if (question || chatPrompt) {
94
+ const display = question || stripForbiddenOpeners(chatPrompt);
95
+ return {
96
+ lead: '',
97
+ question: display,
98
+ chat_prompt: userFacingChatPrompt(chatPrompt, display),
99
+ };
100
+ }
101
+ }
102
+ const text = String(raw || fallbackText || '').trim();
103
+ return fallbackToSuggestion(text);
104
+ }
105
+
106
+ export function suggestionDisplayText(suggestion) {
107
+ return (suggestion?.question || '').trim();
108
+ }
109
+
110
+ export function suggestionChatPrompt(suggestion) {
111
+ const question = (suggestion?.question || suggestionDisplayText(suggestion) || '').trim();
112
+ return userFacingChatPrompt(suggestion?.chat_prompt, question);
113
+ }
114
+
115
+ export function suggestionExcludeKeys(suggestion) {
116
+ const keys = [
117
+ suggestionChatPrompt(suggestion),
118
+ suggestionDisplayText(suggestion),
119
+ suggestion?.question,
120
+ ];
121
+ return [...new Set(keys.map((k) => String(k || '').trim()).filter(Boolean))];
122
+ }
123
 
124
  /**
125
  * Flatten example categories into stable slots 0..n (category-major order).
 
130
  (examples || []).forEach((category, categoryIndex) => {
131
  (category.suggestions || []).forEach((text, suggestionIndex) => {
132
  if (slots.length >= maxSlots) return;
133
+ const fallback = typeof text === 'string' ? text : '';
134
  slots.push({
135
  slot: slots.length,
136
  categoryIndex,
137
  suggestionIndex,
138
  categoryTitle: category.title || `Starter ${slots.length + 1}`,
139
+ fallback,
140
+ suggestion: fallbackToSuggestion(fallback),
141
  text: '',
142
  generated: false,
143
  pending: true,
144
+ promptVariation: slots.length % PROMPT_VARIATION_COUNT,
145
+ refreshing: false,
146
  });
147
  });
148
  });
 
151
 
152
  export function applySlotDelta(slots, slotIndex, chunk) {
153
  if (!Array.isArray(slots) || slotIndex < 0 || slotIndex >= slots.length) return slots;
154
+ const question = stripForbiddenOpeners(String(chunk || ''));
155
+ if (!question) return slots;
156
  const next = slots.slice();
157
  const current = next[slotIndex];
158
  next[slotIndex] = {
159
  ...current,
160
+ text: question,
161
+ suggestion: {
162
+ lead: '',
163
+ question,
164
+ chat_prompt: current.suggestion?.chat_prompt || question,
165
+ },
166
  pending: false,
167
  generated: true,
168
  };
169
  return next;
170
  }
171
 
172
+ export function applySlotDone(slots, slotIndex, payload) {
173
  if (!Array.isArray(slots) || slotIndex < 0 || slotIndex >= slots.length) return slots;
174
  const next = slots.slice();
175
  const current = next[slotIndex];
176
+ const suggestion = normalizeSuggestion(
177
+ payload?.suggestion ?? payload,
178
+ typeof payload === 'string' ? payload : current.text || current.fallback,
179
+ );
180
+ const finalText = suggestionDisplayText(suggestion) || (current.text || '').trim();
181
  next[slotIndex] = {
182
  ...current,
183
+ suggestion,
184
  text: finalText,
185
  pending: false,
186
+ refreshing: false,
187
  generated: Boolean(finalText) && finalText !== current.fallback,
188
  };
 
 
 
189
  if (finalText) next[slotIndex].generated = true;
190
  return next;
191
  }
192
 
193
+ export function applySlotRefreshing(slots, slotIndex, refreshing = true) {
194
+ if (!Array.isArray(slots) || slotIndex < 0 || slotIndex >= slots.length) return slots;
195
+ const next = slots.slice();
196
+ next[slotIndex] = {
197
+ ...next[slotIndex],
198
+ refreshing,
199
+ pending: refreshing,
200
+ };
201
+ return next;
202
+ }
203
+
204
  /** If a slot has no tokens after timeout, fill with YAML fallback. Later LLM text may replace it. */
205
  export function applyFallbackIfTimedOut(slots, startedAt, now, timeoutMs = STARTER_FALLBACK_MS) {
206
  if (!Array.isArray(slots) || now - startedAt < timeoutMs) return slots;
 
210
  if (slot.text) return { ...slot, pending: false, generated: true };
211
  if (!slot.fallback) return { ...slot, pending: false };
212
  changed = true;
213
+ const suggestion = fallbackToSuggestion(slot.fallback);
214
  return {
215
  ...slot,
216
+ suggestion,
217
+ text: suggestionDisplayText(suggestion),
218
  pending: false,
219
  generated: false,
220
  };
 
222
  return changed ? next : slots;
223
  }
224
 
225
+ export function slotsToFlatList(slots) {
226
+ return (slots || [])
227
+ .slice()
228
+ .sort((a, b) => a.slot - b.slot)
229
+ .map((slot) => ({
230
+ ...slot,
231
+ suggestion: slot.suggestion || fallbackToSuggestion(slot.fallback),
232
+ displayText: slot.text || suggestionDisplayText(slot.suggestion) || slot.fallback || '',
233
+ isPending: Boolean(slot.pending && !slot.text && !slot.refreshing),
234
+ }));
235
+ }
236
+
237
+ /** @deprecated Use slotsToFlatList — kept for tests migrating off category grid. */
238
  export function slotsToCategories(examples, slots) {
239
  return (examples || []).map((category, categoryIndex) => {
240
  const owned = (slots || []).filter((s) => s.categoryIndex === categoryIndex);
 
244
  generated: owned.map((s) => Boolean(s.generated)),
245
  pending: owned.map((s) => Boolean(s.pending && !s.text)),
246
  };
247
+ }).filter((_category, categoryIndex) => (
248
  (slots || []).some((s) => s.categoryIndex === categoryIndex)
249
  ));
250
  }
251
 
252
  export function visiblePrompts(slots) {
253
  return (slots || [])
254
+ .map((s) => suggestionChatPrompt(s.suggestion || fallbackToSuggestion(s.text || s.fallback)))
255
  .filter(Boolean);
256
  }
257
+
258
+ export function collectExcludeKeys(slots, extra = []) {
259
+ const normalize = (value) => String(value || '').trim().replace(/[.?!]+$/g, '').toLowerCase();
260
+ const fromSlots = (slots || []).flatMap((slot) => suggestionExcludeKeys(
261
+ slot.suggestion || fallbackToSuggestion(slot.text || slot.fallback),
262
+ ));
263
+ const merged = [...(extra || []), ...fromSlots]
264
+ .map((s) => String(s || '').trim())
265
+ .filter(Boolean);
266
+ const seen = new Set();
267
+ return merged.filter((item) => {
268
+ const key = normalize(item);
269
+ if (!key || seen.has(key)) return false;
270
+ seen.add(key);
271
+ return true;
272
+ });
273
+ }
274
+
275
+ export function nextPromptVariation(current = 0) {
276
+ return (Number(current) + 1) % PROMPT_VARIATION_COUNT;
277
+ }
multi_llm_chatbot_backend/app/api/routes/chat.py CHANGED
@@ -138,13 +138,22 @@ class SwitchChatRequest(BaseModel):
138
  class NewChatRequest(BaseModel):
139
  title: Optional[str] = "New Chat"
140
 
 
 
 
 
 
 
141
  class StarterSuggestionsRequest(BaseModel):
142
  count: int = Field(2, ge=1, le=12)
143
  exclude: List[str] = Field(default_factory=list)
144
  category_titles: List[str] = Field(default_factory=list)
 
 
 
145
 
146
  class StarterSuggestionsResponse(BaseModel):
147
- suggestions: List[str]
148
 
149
 
150
  class StarterGreetingResponse(BaseModel):
@@ -547,14 +556,20 @@ async def starter_suggestions(
547
  titles = [t.strip() for t in (body.category_titles or []) if isinstance(t, str) and t.strip()]
548
  n = len(titles) if titles else body.count
549
  n = max(1, min(int(n), 12))
 
 
 
550
  suggestions = await generate_starter_suggestions(
551
  llm,
552
  user_context,
553
  count=n,
554
  exclude=body.exclude or [],
555
  category_titles=titles,
 
 
 
 
556
  )
557
- return StarterSuggestionsResponse(suggestions=suggestions)
558
  except Exception as exc:
559
  logger.warning("Starter suggestion endpoint failed: %s", exc)
560
  return StarterSuggestionsResponse(suggestions=[])
 
138
  class NewChatRequest(BaseModel):
139
  title: Optional[str] = "New Chat"
140
 
141
+ class StarterSuggestionItem(BaseModel):
142
+ lead: str = ""
143
+ question: str = ""
144
+ chat_prompt: str = ""
145
+
146
+
147
  class StarterSuggestionsRequest(BaseModel):
148
  count: int = Field(2, ge=1, le=12)
149
  exclude: List[str] = Field(default_factory=list)
150
  category_titles: List[str] = Field(default_factory=list)
151
+ slot_index: Optional[int] = Field(None, ge=0, le=11)
152
+ prompt_variation: Optional[int] = Field(None, ge=0, le=11)
153
+
154
 
155
  class StarterSuggestionsResponse(BaseModel):
156
+ suggestions: List[StarterSuggestionItem] = Field(default_factory=list)
157
 
158
 
159
  class StarterGreetingResponse(BaseModel):
 
556
  titles = [t.strip() for t in (body.category_titles or []) if isinstance(t, str) and t.strip()]
557
  n = len(titles) if titles else body.count
558
  n = max(1, min(int(n), 12))
559
+ variation = body.prompt_variation
560
+ if variation is None and body.slot_index is not None:
561
+ variation = int(body.slot_index)
562
  suggestions = await generate_starter_suggestions(
563
  llm,
564
  user_context,
565
  count=n,
566
  exclude=body.exclude or [],
567
  category_titles=titles,
568
+ prompt_variation=variation,
569
+ )
570
+ return StarterSuggestionsResponse(
571
+ suggestions=[StarterSuggestionItem(**item) for item in suggestions],
572
  )
 
573
  except Exception as exc:
574
  logger.warning("Starter suggestion endpoint failed: %s", exc)
575
  return StarterSuggestionsResponse(suggestions=[])
multi_llm_chatbot_backend/app/core/improved_orchestrator.py CHANGED
@@ -1,5 +1,5 @@
1
  from typing import Dict, List, Optional, Any
2
- from app.models.persona import Persona, _finalize_compact
3
  from app.core.session_manager import ConversationContext, get_session_manager
4
  from app.core.context_manager import get_context_manager
5
  from app.core.rag_manager import get_rag_manager
@@ -938,6 +938,13 @@ Use this context to inform your response, and cite specific documents when refer
938
  if getattr(session, "datetime_context", ""):
939
  system_message += f"\n\n{session.datetime_context}"
940
 
 
 
 
 
 
 
 
941
  enhanced_context.append({
942
  "role": "system",
943
  "content": system_message,
 
1
  from typing import Dict, List, Optional, Any
2
+ from app.models.persona import Persona, _finalize_compact, panel_lens_reminder
3
  from app.core.session_manager import ConversationContext, get_session_manager
4
  from app.core.context_manager import get_context_manager
5
  from app.core.rag_manager import get_rag_manager
 
938
  if getattr(session, "datetime_context", ""):
939
  system_message += f"\n\n{session.datetime_context}"
940
 
941
+ lens = panel_lens_reminder(
942
+ getattr(persona, "name", ""),
943
+ getattr(persona, "role", ""),
944
+ )
945
+ if lens:
946
+ system_message += f"\n\n{lens}"
947
+
948
  enhanced_context.append({
949
  "role": "system",
950
  "content": system_message,
multi_llm_chatbot_backend/app/core/starter_suggestions.py CHANGED
@@ -1,4 +1,4 @@
1
- """Goal-grounded Getting Started chip and greeting generation."""
2
 
3
  from __future__ import annotations
4
 
@@ -6,7 +6,8 @@ import asyncio
6
  import json
7
  import logging
8
  import re
9
- from typing import Any, AsyncIterator, Dict, List, Optional, Sequence
 
10
 
11
  from app.core.user_context import extract_stated_goal, has_stated_goal_or_summary
12
 
@@ -14,37 +15,286 @@ logger = logging.getLogger(__name__)
14
 
15
  _MAX_STARTERS = 12
16
  _JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL)
 
17
  _SLOT_STAGGER_SECONDS = 0.08
18
  _SLOT_CONCURRENCY = 3
 
 
19
 
20
  DEFAULT_GREETING = "What cybersecurity problem should we work on first?"
21
  DEFAULT_SUBHEADER = (
22
  "I can help with threats, controls, incidents, compliance, or your security career."
23
  )
24
 
 
 
 
 
 
 
 
 
 
25
 
26
- def _normalize_starter(text: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  cleaned = " ".join((text or "").strip().strip("\"'").split())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  cleaned = re.sub(r"^\d+[\.)]\s*", "", cleaned)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  return cleaned
30
 
31
 
32
- def _parse_suggestions(raw: str) -> List[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  cleaned = (raw or "").strip()
34
  cleaned = re.sub(r"```(?:json)?", "", cleaned).strip()
35
  match = _JSON_OBJECT_RE.search(cleaned)
36
  if match:
37
  cleaned = match.group(0)
38
- parsed = json.loads(cleaned)
39
- items = parsed.get("suggestions") if isinstance(parsed, dict) else parsed
40
- if not isinstance(items, list):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  return []
42
- out: List[str] = []
 
 
 
 
43
  for item in items:
44
- if isinstance(item, str):
45
- normalized = _normalize_starter(item)
46
- if normalized:
47
- out.append(normalized)
48
  return out
49
 
50
 
@@ -111,25 +361,80 @@ async def generate_starter_greeting(llm, user_context: str) -> Dict[str, str]:
111
  return fallback
112
 
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  def _one_slot_prompts(
115
  user_context: str,
116
- category_title: str,
117
  exclude: Sequence[str],
 
 
 
118
  ) -> tuple[str, str]:
119
  exclude_block = "\n".join(f"- {item}" for item in exclude if str(item).strip())
120
  if not exclude_block:
121
  exclude_block = "(none)"
 
 
 
 
 
 
 
122
  system_prompt = (
123
- "You write one Getting Started chip for a cybersecurity advisor chat.\n"
124
- "Output ONLY the question: first person, one sentence, at most 22 words.\n"
125
- "No numbering, quotes, JSON, or preamble.\n"
126
- "Ground it in the user's stated goal and profile. Never invent generic "
127
- "enterprise/compliance topics unless that is actually their goal.\n"
128
- f"Match this category: {category_title}"
129
  )
130
  user_prompt = (
 
 
 
131
  "--- User security profile / knowledge ---\n"
132
  f"{(user_context or '').strip()}\n\n"
 
133
  "--- Do not repeat ---\n"
134
  f"{exclude_block}"
135
  )
@@ -143,50 +448,80 @@ async def generate_starter_suggestions(
143
  count: int = 2,
144
  exclude: Optional[Sequence[str]] = None,
145
  category_titles: Optional[Sequence[str]] = None,
146
- ) -> List[str]:
147
- """Return one clickable starter question per requested slot.
148
-
149
- Falls back to an empty list when there is no usable goal/profile, no LLM,
150
- or generation fails — the UI then keeps its static YAML chips.
151
- """
152
  if llm is None or not has_stated_goal_or_summary(user_context):
153
  return []
154
 
 
155
  titles = [str(t).strip() for t in (category_titles or []) if str(t).strip()]
156
  n = len(titles) if titles else int(count or 0)
157
  n = max(1, min(n, _MAX_STARTERS))
158
  if titles:
159
  titles = titles[:n]
160
  else:
161
- titles = [f"Starter {i + 1}" for i in range(n)]
162
 
163
  blocked = {
164
- _normalize_starter(item).lower()
165
  for item in (exclude or [])
166
- if _normalize_starter(str(item))
167
  }
168
 
169
- numbered = "\n".join(f"{i + 1}. {title}" for i, title in enumerate(titles))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  exclude_block = "\n".join(f"- {item}" for item in (exclude or []) if str(item).strip())
171
  if not exclude_block:
172
  exclude_block = "(none)"
 
 
 
173
 
174
  system_prompt = (
175
- "You write Getting Started chips for a cybersecurity advisor chat.\n"
176
- "Each chip is one first-person question the user can click and send.\n"
177
- "Ground every question in the user's stated goal and profile. Never "
178
- "invent generic enterprise/compliance topics (GDPR, HIPAA, SOC 2, org "
179
- "architecture) unless that is actually their goal.\n"
180
- "Each question is one sentence, at most 22 words, no numbering or quotes.\n"
181
- "Make each chip distinct and matched to its category title "
182
- "(directional vs practical, etc.).\n"
183
- f'Respond ONLY with valid JSON: {{"suggestions": [{n} strings]}}'
184
  )
185
  user_prompt = (
 
 
 
186
  "--- User security profile / knowledge ---\n"
187
  f"{user_context.strip()}\n\n"
188
- "--- Categories (one question each, same order) ---\n"
189
- f"{numbered}\n\n"
190
  "--- Do not repeat any of these ---\n"
191
  f"{exclude_block}"
192
  )
@@ -195,59 +530,89 @@ async def generate_starter_suggestions(
195
  raw = await llm.generate(
196
  system_prompt=system_prompt,
197
  context=[{"role": "user", "content": user_prompt}],
198
- temperature=0.6,
199
- max_tokens=max(256, n * 80),
200
  response_mime_type="application/json",
201
  )
202
- suggestions = _parse_suggestions(raw)
203
  except Exception as exc:
204
  logger.warning("Starter suggestion generation failed: %s", exc)
205
  return []
206
 
207
- unique: List[str] = []
208
  seen = set(blocked)
209
- for text in suggestions:
210
- key = text.lower()
211
  if key in seen:
212
  continue
213
  seen.add(key)
214
- unique.append(text)
215
  if len(unique) >= n:
216
  break
217
  return unique
218
 
219
 
 
 
 
 
 
 
 
 
 
 
220
  async def iter_one_starter_suggestion(
221
  llm,
222
  user_context: str,
223
  *,
224
- category_title: str,
225
  exclude: Optional[Sequence[str]] = None,
226
- ) -> AsyncIterator[str]:
227
- """Yield token chunks for a single Getting Started question."""
 
 
228
  system_prompt, user_prompt = _one_slot_prompts(
229
- user_context, category_title, exclude or [],
 
 
 
230
  )
231
  stream = getattr(llm, "generate_stream", None)
 
232
  if stream is None:
233
  text = await llm.generate(
234
  system_prompt=system_prompt,
235
  context=[{"role": "user", "content": user_prompt}],
236
- temperature=0.6,
237
- max_tokens=80,
 
238
  )
239
- cleaned = _normalize_starter(text)
240
- if cleaned:
241
- yield cleaned
242
  return
 
243
  async for chunk in stream(
244
  system_prompt=system_prompt,
245
  context=[{"role": "user", "content": user_prompt}],
246
- temperature=0.6,
247
- max_tokens=80,
 
248
  ):
249
- if chunk:
250
- yield chunk
 
 
 
 
 
 
 
 
 
 
 
251
 
252
 
253
  async def iter_starter_suggestion_events(
@@ -260,18 +625,20 @@ async def iter_starter_suggestion_events(
260
  stagger_seconds: float = _SLOT_STAGGER_SECONDS,
261
  ) -> AsyncIterator[Dict[str, Any]]:
262
  """Yield NDJSON-ready events for stable slots 0..n with limited concurrency."""
263
- titles = [str(t).strip() or f"Starter {i + 1}" for i, t in enumerate(category_titles)]
 
 
264
  titles = titles[:_MAX_STARTERS]
265
- if not titles or llm is None or not has_stated_goal_or_summary(user_context):
266
  return
267
 
268
  queue: asyncio.Queue = asyncio.Queue()
269
  sem = asyncio.Semaphore(max(1, int(concurrency or 1)))
270
  lock = asyncio.Lock()
271
  blocked = [
272
- _normalize_starter(item)
273
  for item in (exclude or [])
274
- if _normalize_starter(str(item))
275
  ]
276
  finished = 0
277
  n = len(titles)
@@ -280,7 +647,8 @@ async def iter_starter_suggestion_events(
280
  nonlocal finished
281
  if slot:
282
  await asyncio.sleep(stagger_seconds * min(slot, 4))
283
- buf: List[str] = []
 
284
  try:
285
  async with sem:
286
  async for chunk in iter_one_starter_suggestion(
@@ -288,11 +656,26 @@ async def iter_starter_suggestion_events(
288
  user_context,
289
  category_title=title,
290
  exclude=blocked,
 
291
  ):
292
- buf.append(chunk)
293
- await queue.put({"type": "delta", "slot": slot, "text": chunk})
294
- final = _normalize_starter("".join(buf))
295
- await queue.put({"type": "done", "slot": slot, "text": final})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  except Exception as exc:
297
  logger.warning("Starter slot %s failed: %s", slot, exc)
298
  await queue.put({"type": "error", "slot": slot})
 
1
+ """Goal-grounded Practical Next Steps chip and greeting generation."""
2
 
3
  from __future__ import annotations
4
 
 
6
  import json
7
  import logging
8
  import re
9
+ from dataclasses import dataclass
10
+ from typing import Any, AsyncIterator, Dict, List, Optional, Sequence, Union
11
 
12
  from app.core.user_context import extract_stated_goal, has_stated_goal_or_summary
13
 
 
15
 
16
  _MAX_STARTERS = 12
17
  _JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL)
18
+ _JSON_ARRAY_RE = re.compile(r"\[.*\]", re.DOTALL)
19
  _SLOT_STAGGER_SECONDS = 0.08
20
  _SLOT_CONCURRENCY = 3
21
+ _MAX_QUESTION_WORDS = 14
22
+ _MAX_CHAT_PROMPT_WORDS = 28
23
 
24
  DEFAULT_GREETING = "What cybersecurity problem should we work on first?"
25
  DEFAULT_SUBHEADER = (
26
  "I can help with threats, controls, incidents, compliance, or your security career."
27
  )
28
 
29
+ # Filler preambles that create a wall-of-text feel on chips.
30
+ _FILLER_OPENER_RE = re.compile(
31
+ r"^(?:to begin(?:\s+with)?|to shape(?:\s+your\s+path)?|to start(?:\s+with)?|"
32
+ r"to identify|to dissect|to analyze|to explore|to consider|to examine|"
33
+ r"to discuss|let'?s|first off|as a first step|i'?d like|i would like|"
34
+ r"so,?|now,?|here,?|for(?:\s+a)?\s+start|getting started|"
35
+ r"to get started|to understand|in order to|when it comes to)\b[\s,:-]*",
36
+ re.IGNORECASE,
37
+ )
38
 
39
+ # Advisor-to-user lecturing that must never be inserted into chat as the user turn.
40
+ _ADVISOR_TO_USER_RE = re.compile(
41
+ r"(?:"
42
+ r"\blet'?s\b|"
43
+ r"\blet us\b|"
44
+ r"\bhave you considered\b|"
45
+ r"\bhave you thought\b|"
46
+ r"\bhave you looked\b|"
47
+ r"\byou should\b|"
48
+ r"\byou might\b|"
49
+ r"\byou need to\b|"
50
+ r"\byou can start\b|"
51
+ r"\bfor instance\b|"
52
+ r"\bthis idea blends\b|"
53
+ r"\bthe core concept\b|"
54
+ r"\bto identify the\b|"
55
+ r"\bwhich creates a unique\b"
56
+ r")",
57
+ re.IGNORECASE,
58
+ )
59
+
60
+ _FIRST_PERSON_RE = re.compile(r"\b(?:i|i'm|i'd|i've|i'll|my|mine)\b", re.IGNORECASE)
61
+ _SECOND_PERSON_YOUR_RE = re.compile(r"\byour\b", re.IGNORECASE)
62
+
63
+ # "For MFA,", "For backups,", "For my incident response plan,"
64
+ _TOPIC_COMMA_LEAD_RE = re.compile(
65
+ r"^for\s+[^?]{1,48}?,\s*",
66
+ re.IGNORECASE,
67
+ )
68
+
69
+ _PROMPT_VARIATIONS: List[str] = [
70
+ "Start the question with How. Ask the advisor panel about the single highest-priority next step for my stated goal.",
71
+ "Start the question with What. Ask the advisor panel about the biggest security risk I should address next.",
72
+ "Start the question with Which. Ask the advisor panel to choose between two practical approaches for my goal.",
73
+ "Start the question with Should. Ask the advisor panel about one concrete action I can take this week.",
74
+ "Start the question with Where. Ask the advisor panel about a specific control, tool, or practice that fits my situation.",
75
+ "Start the question with Can. Ask the advisor panel to explain one concept I should understand before moving forward.",
76
+ ]
77
+
78
+
79
+ def _exclude_key(text: str) -> str:
80
+ cleaned = _normalize_starter(str(text or "")).lower()
81
+ return cleaned.rstrip(".?! ").strip()
82
+
83
+
84
+ @dataclass
85
+ class StarterSuggestion:
86
+ lead: str
87
+ question: str
88
+ chat_prompt: str
89
+
90
+ def to_dict(self) -> Dict[str, str]:
91
+ return {
92
+ "lead": self.lead,
93
+ "question": self.question,
94
+ "chat_prompt": self.chat_prompt,
95
+ }
96
+
97
+ def display_text(self) -> str:
98
+ return self.question.strip()
99
+
100
+ def exclude_key(self) -> str:
101
+ return _exclude_key(self.chat_prompt.strip() or self.display_text())
102
+
103
+
104
+ def _capitalize_start(text: str) -> str:
105
+ cleaned = (text or "").strip()
106
+ if not cleaned:
107
+ return ""
108
+ return cleaned[0].upper() + cleaned[1:]
109
+
110
+
111
+ def _strip_filler_openers(text: str) -> str:
112
+ """Remove To begin / Let's / First off style clutter only (keep goal context)."""
113
  cleaned = " ".join((text or "").strip().strip("\"'").split())
114
+ prev = None
115
+ while cleaned and cleaned != prev:
116
+ prev = cleaned
117
+ cleaned = _FILLER_OPENER_RE.sub("", cleaned).strip()
118
+ return cleaned
119
+
120
+
121
+ def _strip_forbidden_openers(text: str) -> str:
122
+ """Strip filler openers and 'For <topic>,' leads from chip display questions."""
123
+ cleaned = _strip_filler_openers(text)
124
+ prev = None
125
+ while cleaned and cleaned != prev:
126
+ prev = cleaned
127
+ cleaned = _TOPIC_COMMA_LEAD_RE.sub("", cleaned).strip()
128
+ cleaned = _FILLER_OPENER_RE.sub("", cleaned).strip()
129
+ return _capitalize_start(cleaned)
130
+
131
+
132
+ def _normalize_starter(text: str) -> str:
133
+ cleaned = _strip_forbidden_openers(text)
134
  cleaned = re.sub(r"^\d+[\.)]\s*", "", cleaned)
135
+ return _capitalize_start(cleaned)
136
+
137
+
138
+ def _looks_like_json_payload(text: str) -> bool:
139
+ stripped = (text or "").lstrip()
140
+ return stripped.startswith("{") or stripped.startswith("[")
141
+
142
+
143
+ def _ensure_question(text: str) -> str:
144
+ if _looks_like_json_payload(text):
145
+ return ""
146
+ cleaned = _normalize_starter(text)
147
+ if not cleaned or _looks_like_json_payload(cleaned):
148
+ return ""
149
+ if not cleaned.endswith("?"):
150
+ cleaned = cleaned.rstrip(".! ") + "?"
151
+ return cleaned
152
+
153
+
154
+ def _word_count(text: str) -> int:
155
+ return len((text or "").split())
156
+
157
+
158
+ def _is_multi_sentence(text: str) -> bool:
159
+ """True when the text has more than one sentence (lectures, not one ask)."""
160
+ body = (text or "").strip()
161
+ if body.endswith("?"):
162
+ body = body[:-1]
163
+ body = re.sub(r"\b(?:e\.g|i\.e|etc)\.", " ", body, flags=re.IGNORECASE)
164
+ body = re.sub(r"\.{2,}", " ", body)
165
+ return bool(re.search(r"[.!?]", body))
166
+
167
+
168
+ def _looks_like_advisor_to_user(text: str) -> bool:
169
+ """True when text is an advisor lecturing/interviewing the user."""
170
+ cleaned = " ".join((text or "").split()).strip()
171
+ if not cleaned:
172
+ return False
173
+ if _ADVISOR_TO_USER_RE.search(cleaned):
174
+ return True
175
+ if _is_multi_sentence(cleaned):
176
+ return True
177
+ if _SECOND_PERSON_YOUR_RE.search(cleaned) and not _FIRST_PERSON_RE.search(cleaned):
178
+ return True
179
+ return False
180
+
181
+
182
+ def _acceptable_user_chat_prompt(text: str) -> bool:
183
+ """Accept only a single first-person question the user would send to the panel."""
184
+ cleaned = " ".join((text or "").split()).strip()
185
+ if not cleaned or not cleaned.endswith("?"):
186
+ return False
187
+ if _looks_like_advisor_to_user(cleaned):
188
+ return False
189
+ if _word_count(cleaned) > _MAX_CHAT_PROMPT_WORDS * 2:
190
+ return False
191
+ return True
192
+
193
+
194
+ def _ensure_chat_question(text: str) -> str:
195
+ if _looks_like_json_payload(text):
196
+ return ""
197
+ cleaned = _strip_filler_openers(text)
198
+ if not cleaned or _looks_like_json_payload(cleaned):
199
+ return ""
200
+ if not cleaned.endswith("?"):
201
+ cleaned = cleaned.rstrip(".! ") + "?"
202
+ if not _acceptable_user_chat_prompt(cleaned):
203
+ return ""
204
  return cleaned
205
 
206
 
207
+ def _synthesize_user_chat_prompt(question: str, goal: str = "") -> str:
208
+ """Build a first-person user question to the advisor panel from chip + goal."""
209
+ question = _ensure_question(question)
210
+ if not question:
211
+ return ""
212
+ if goal:
213
+ snippet = goal.strip().rstrip(".")
214
+ if len(snippet) > 60:
215
+ snippet = snippet[:57].rstrip() + "..."
216
+ lowered_q = question[0].lower() + question[1:]
217
+ lowered_g = snippet[0].lower() + snippet[1:]
218
+ synthesized = f"Given my goal to {lowered_g}, {lowered_q}"
219
+ if _looks_like_advisor_to_user(synthesized) and _looks_like_advisor_to_user(question):
220
+ return f"Given my goal to {lowered_g}, what should I ask the advisor panel first?"
221
+ return synthesized
222
+ if _looks_like_advisor_to_user(question):
223
+ return ""
224
+ return question
225
+
226
+
227
+ def _build_chat_prompt(question: str, goal: str = "", provided: str = "") -> str:
228
+ question = _ensure_question(question)
229
+ if not question:
230
+ return ""
231
+ provided_chat = _ensure_chat_question(provided) if provided else ""
232
+ if provided_chat:
233
+ return provided_chat
234
+ return _synthesize_user_chat_prompt(question, goal)
235
+
236
+
237
+ def _normalize_suggestion_obj(raw: Any, goal: str = "") -> Optional[StarterSuggestion]:
238
+ if isinstance(raw, str):
239
+ stripped = raw.strip()
240
+ if _looks_like_json_payload(stripped):
241
+ partial_q = _partial_question_from_stream(stripped)
242
+ if not partial_q:
243
+ return None
244
+ stripped = partial_q
245
+ question = _ensure_question(stripped)
246
+ if not question:
247
+ return None
248
+ chat = _build_chat_prompt(question, goal)
249
+ return StarterSuggestion(lead="", question=question, chat_prompt=chat)
250
+
251
+ if not isinstance(raw, dict):
252
+ return None
253
+
254
+ question = _ensure_question(str(raw.get("question") or raw.get("text") or ""))
255
+ if not question:
256
+ return None
257
+ chat = _build_chat_prompt(
258
+ question,
259
+ goal,
260
+ provided=str(raw.get("chat_prompt") or ""),
261
+ )
262
+ return StarterSuggestion(lead="", question=question, chat_prompt=chat)
263
+
264
+
265
+ def _parse_suggestions(raw: str, goal: str = "") -> List[StarterSuggestion]:
266
  cleaned = (raw or "").strip()
267
  cleaned = re.sub(r"```(?:json)?", "", cleaned).strip()
268
  match = _JSON_OBJECT_RE.search(cleaned)
269
  if match:
270
  cleaned = match.group(0)
271
+ try:
272
+ parsed = json.loads(cleaned)
273
+ except json.JSONDecodeError:
274
+ array_match = _JSON_ARRAY_RE.search(cleaned)
275
+ if not array_match:
276
+ return []
277
+ try:
278
+ parsed = json.loads(array_match.group(0))
279
+ except json.JSONDecodeError:
280
+ return []
281
+
282
+ items: Any
283
+ if isinstance(parsed, dict):
284
+ items = parsed.get("suggestions") or parsed.get("items") or [parsed]
285
+ elif isinstance(parsed, list):
286
+ items = parsed
287
+ else:
288
  return []
289
+
290
+ if not isinstance(items, list):
291
+ items = [items]
292
+
293
+ out: List[StarterSuggestion] = []
294
  for item in items:
295
+ suggestion = _normalize_suggestion_obj(item, goal)
296
+ if suggestion:
297
+ out.append(suggestion)
 
298
  return out
299
 
300
 
 
361
  return fallback
362
 
363
 
364
+ def _variation_prompt(variation_index: int) -> str:
365
+ return _PROMPT_VARIATIONS[variation_index % len(_PROMPT_VARIATIONS)]
366
+
367
+
368
+ def _voice_rules_block() -> str:
369
+ return (
370
+ "You write questions the USER will send to a panel of expert "
371
+ "cybersecurity advisors. You are not the advisor.\n"
372
+ "Both question and chat_prompt MUST be in the user's first-person voice "
373
+ "(I/my), addressed to the expert panel.\n"
374
+ "Do not analyze, lecture, interview, or ask the user anything.\n"
375
+ "GOOD chat_prompt: \"What is the most critical security risk if feline "
376
+ "hackers in my sci-fi novel have cybernetic enhancements?\"\n"
377
+ "BAD chat_prompt: \"To identify the risk in your novel, let's dissect "
378
+ "feline hackers. Have you considered how this hybrid nature might "
379
+ "reshape your universe?\""
380
+ )
381
+
382
+
383
+ def _structured_rules_block() -> str:
384
+ return (
385
+ f"{_voice_rules_block()}\n"
386
+ "Each suggestion is JSON with:\n"
387
+ '- "lead": always an empty string. Do not write a lead-in sentence.\n'
388
+ '- "question": the chip text. A concise first-person question the user '
389
+ f"asks the advisor panel, {_MAX_QUESTION_WORDS - 6}-{_MAX_QUESTION_WORDS} words, "
390
+ "MUST end with ?. Jump straight into the question — no preamble.\n"
391
+ '- "chat_prompt": the message inserted into chat when the chip is clicked. '
392
+ "Write it as the user speaking to the expert advisor panel, with brief "
393
+ f"context from the stated goal (I/my), max {_MAX_CHAT_PROMPT_WORDS} words. "
394
+ "Exactly one sentence ending in ?.\n"
395
+ "The question MUST start with How, What, Which, Should, Where, or Can.\n"
396
+ "NEVER start question or chat_prompt with: To begin, To shape, To start, "
397
+ "To identify, To dissect, Let's, First off, I'd like, For <topic>, "
398
+ "As a first step, or similar filler.\n"
399
+ "Do not write topic prefixes like 'For MFA,' or 'For backups,'.\n"
400
+ "NEVER write as the advisor talking to the user: no let's, "
401
+ "Have you considered, you should, your novel, analysis, or follow-up "
402
+ "questions directed at the user.\n"
403
+ "NO rhetorical fluff. Ground every suggestion in the user's stated goal."
404
+ )
405
+
406
+
407
  def _one_slot_prompts(
408
  user_context: str,
 
409
  exclude: Sequence[str],
410
+ *,
411
+ variation_index: int = 0,
412
+ category_title: str = "",
413
  ) -> tuple[str, str]:
414
  exclude_block = "\n".join(f"- {item}" for item in exclude if str(item).strip())
415
  if not exclude_block:
416
  exclude_block = "(none)"
417
+ angle = _variation_prompt(variation_index)
418
+ category_hint = ""
419
+ if category_title and not category_title.lower().startswith("starter"):
420
+ category_hint = (
421
+ f"\nOptional theme hint (do not mention this title; do not use it as a prefix): "
422
+ f"{category_title}"
423
+ )
424
  system_prompt = (
425
+ "You write one Practical Next Steps chip: a question the USER will send "
426
+ "to a panel of expert cybersecurity advisors.\n"
427
+ f"Angle: {angle}\n"
428
+ f"{_structured_rules_block()}\n"
429
+ 'Respond ONLY with JSON: {"lead": "", "question": "...", "chat_prompt": "..."}'
 
430
  )
431
  user_prompt = (
432
+ "Write a question I would type to the expert advisor panel about my "
433
+ "stated goal. First person (I/my). Do not write analysis or questions "
434
+ "directed at me.\n\n"
435
  "--- User security profile / knowledge ---\n"
436
  f"{(user_context or '').strip()}\n\n"
437
+ f"{category_hint}\n"
438
  "--- Do not repeat ---\n"
439
  f"{exclude_block}"
440
  )
 
448
  count: int = 2,
449
  exclude: Optional[Sequence[str]] = None,
450
  category_titles: Optional[Sequence[str]] = None,
451
+ prompt_variation: Optional[int] = None,
452
+ ) -> List[Dict[str, str]]:
453
+ """Return structured starter suggestions for requested slots."""
 
 
 
454
  if llm is None or not has_stated_goal_or_summary(user_context):
455
  return []
456
 
457
+ goal = extract_stated_goal(user_context or "")
458
  titles = [str(t).strip() for t in (category_titles or []) if str(t).strip()]
459
  n = len(titles) if titles else int(count or 0)
460
  n = max(1, min(n, _MAX_STARTERS))
461
  if titles:
462
  titles = titles[:n]
463
  else:
464
+ titles = ["" for _ in range(n)]
465
 
466
  blocked = {
467
+ _exclude_key(item)
468
  for item in (exclude or [])
469
+ if _exclude_key(item)
470
  }
471
 
472
+ # Single-slot refresh path
473
+ if n == 1:
474
+ variation = int(prompt_variation if prompt_variation is not None else 0)
475
+ system_prompt, user_prompt = _one_slot_prompts(
476
+ user_context,
477
+ exclude or [],
478
+ variation_index=variation,
479
+ category_title=titles[0],
480
+ )
481
+ try:
482
+ raw = await llm.generate(
483
+ system_prompt=system_prompt,
484
+ context=[{"role": "user", "content": user_prompt}],
485
+ temperature=0.65,
486
+ max_tokens=220,
487
+ response_mime_type="application/json",
488
+ )
489
+ parsed = _parse_suggestions(raw, goal)
490
+ except Exception as exc:
491
+ logger.warning("Starter suggestion generation failed: %s", exc)
492
+ return []
493
+ unique: List[Dict[str, str]] = []
494
+ for suggestion in parsed:
495
+ key = _exclude_key(suggestion.exclude_key())
496
+ if key in blocked:
497
+ continue
498
+ blocked.add(key)
499
+ unique.append(suggestion.to_dict())
500
+ break
501
+ return unique
502
+
503
  exclude_block = "\n".join(f"- {item}" for item in (exclude or []) if str(item).strip())
504
  if not exclude_block:
505
  exclude_block = "(none)"
506
+ angles = "\n".join(
507
+ f"{i + 1}. {_variation_prompt(i)}" for i in range(n)
508
+ )
509
 
510
  system_prompt = (
511
+ "You write Practical Next Steps chips: questions the USER will send "
512
+ "to a panel of expert cybersecurity advisors.\n"
513
+ f"{_structured_rules_block()}\n"
514
+ "Each chip must use the angle listed for its slot index.\n"
515
+ f'Respond ONLY with valid JSON: {{"suggestions": [{n} objects]}}'
 
 
 
 
516
  )
517
  user_prompt = (
518
+ "Write questions I would type to the expert advisor panel about my "
519
+ "stated goal. First person (I/my). Do not write analysis or questions "
520
+ "directed at me.\n\n"
521
  "--- User security profile / knowledge ---\n"
522
  f"{user_context.strip()}\n\n"
523
+ "--- Slot angles (one suggestion each, same order) ---\n"
524
+ f"{angles}\n\n"
525
  "--- Do not repeat any of these ---\n"
526
  f"{exclude_block}"
527
  )
 
530
  raw = await llm.generate(
531
  system_prompt=system_prompt,
532
  context=[{"role": "user", "content": user_prompt}],
533
+ temperature=0.65,
534
+ max_tokens=max(480, n * 140),
535
  response_mime_type="application/json",
536
  )
537
+ suggestions = _parse_suggestions(raw, goal)
538
  except Exception as exc:
539
  logger.warning("Starter suggestion generation failed: %s", exc)
540
  return []
541
 
542
+ unique: List[Dict[str, str]] = []
543
  seen = set(blocked)
544
+ for suggestion in suggestions:
545
+ key = _exclude_key(suggestion.exclude_key())
546
  if key in seen:
547
  continue
548
  seen.add(key)
549
+ unique.append(suggestion.to_dict())
550
  if len(unique) >= n:
551
  break
552
  return unique
553
 
554
 
555
+ def _partial_question_from_stream(buf: str) -> str:
556
+ match = re.search(r'"question"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)', buf)
557
+ if not match:
558
+ return ""
559
+ try:
560
+ return json.loads(f'"{match.group(1)}"')
561
+ except json.JSONDecodeError:
562
+ return match.group(1).replace("\\\"", "\"")
563
+
564
+
565
  async def iter_one_starter_suggestion(
566
  llm,
567
  user_context: str,
568
  *,
569
+ category_title: str = "",
570
  exclude: Optional[Sequence[str]] = None,
571
+ variation_index: int = 0,
572
+ ) -> AsyncIterator[Union[str, StarterSuggestion]]:
573
+ """Yield token chunks, then the parsed structured suggestion."""
574
+ goal = extract_stated_goal(user_context or "")
575
  system_prompt, user_prompt = _one_slot_prompts(
576
+ user_context,
577
+ exclude or [],
578
+ variation_index=variation_index,
579
+ category_title=category_title,
580
  )
581
  stream = getattr(llm, "generate_stream", None)
582
+ buf = ""
583
  if stream is None:
584
  text = await llm.generate(
585
  system_prompt=system_prompt,
586
  context=[{"role": "user", "content": user_prompt}],
587
+ temperature=0.65,
588
+ max_tokens=220,
589
+ response_mime_type="application/json",
590
  )
591
+ parsed = _parse_suggestions(text, goal)
592
+ if parsed:
593
+ yield parsed[0]
594
  return
595
+
596
  async for chunk in stream(
597
  system_prompt=system_prompt,
598
  context=[{"role": "user", "content": user_prompt}],
599
+ temperature=0.65,
600
+ max_tokens=220,
601
+ response_mime_type="application/json",
602
  ):
603
+ if not chunk:
604
+ continue
605
+ buf += chunk
606
+ partial = _partial_question_from_stream(buf)
607
+ if partial:
608
+ yield partial
609
+ parsed = _parse_suggestions(buf, goal)
610
+ if parsed:
611
+ yield parsed[0]
612
+ elif buf.strip():
613
+ fallback = _normalize_suggestion_obj(buf.strip(), goal)
614
+ if fallback:
615
+ yield fallback
616
 
617
 
618
  async def iter_starter_suggestion_events(
 
625
  stagger_seconds: float = _SLOT_STAGGER_SECONDS,
626
  ) -> AsyncIterator[Dict[str, Any]]:
627
  """Yield NDJSON-ready events for stable slots 0..n with limited concurrency."""
628
+ titles = [str(t).strip() for t in category_titles]
629
+ if not titles:
630
+ titles = ["Starter 1"]
631
  titles = titles[:_MAX_STARTERS]
632
+ if llm is None or not has_stated_goal_or_summary(user_context):
633
  return
634
 
635
  queue: asyncio.Queue = asyncio.Queue()
636
  sem = asyncio.Semaphore(max(1, int(concurrency or 1)))
637
  lock = asyncio.Lock()
638
  blocked = [
639
+ _exclude_key(item)
640
  for item in (exclude or [])
641
+ if _exclude_key(item)
642
  ]
643
  finished = 0
644
  n = len(titles)
 
647
  nonlocal finished
648
  if slot:
649
  await asyncio.sleep(stagger_seconds * min(slot, 4))
650
+ last_partial = ""
651
+ final: Optional[StarterSuggestion] = None
652
  try:
653
  async with sem:
654
  async for chunk in iter_one_starter_suggestion(
 
656
  user_context,
657
  category_title=title,
658
  exclude=blocked,
659
+ variation_index=slot,
660
  ):
661
+ if isinstance(chunk, StarterSuggestion):
662
+ final = chunk
663
+ elif isinstance(chunk, str) and chunk != last_partial:
664
+ last_partial = chunk
665
+ await queue.put({"type": "delta", "slot": slot, "text": chunk})
666
+ if final is None and last_partial:
667
+ goal = extract_stated_goal(user_context or "")
668
+ final = _normalize_suggestion_obj(last_partial, goal)
669
+ if final:
670
+ blocked.append(final.exclude_key())
671
+ await queue.put({
672
+ "type": "done",
673
+ "slot": slot,
674
+ "text": final.display_text(),
675
+ "suggestion": final.to_dict(),
676
+ })
677
+ else:
678
+ await queue.put({"type": "error", "slot": slot})
679
  except Exception as exc:
680
  logger.warning("Starter slot %s failed: %s", slot, exc)
681
  await queue.put({"type": "error", "slot": slot})
multi_llm_chatbot_backend/app/models/persona.py CHANGED
@@ -5,20 +5,23 @@ import re
5
  SENTINEL = "</END>"
6
 
7
  # Shared compact formatting contract applied to all personas.
 
8
  COMPACT_MARKDOWN_V1 = (
9
  "You must format your answer using GitHub-Flavored Markdown and exactly these three sections in this order:\n"
10
  "### Thought\n"
11
- "- 1–2 complete sentences of reasoning/context only. Do not put actions here.\n"
12
  "- Finish each sentence; never cut a sentence short mid-phrase.\n"
 
13
  "\n"
14
  "### What to do\n"
15
  "- Exactly 3 bullet points, one concrete action each. Use '-' as the bullet. Do not use unicode bullets.\n"
16
  "- Write each bullet as a complete imperative sentence (plain text, no bold title prefixes).\n"
17
- "- Bullets must be actionable steps, not leftover reasoning from Thought.\n"
 
18
  "- If you would use an ordered list, keep text on the same line as the number (e.g., '1. Do X').\n"
19
  "\n"
20
  "### Next step\n"
21
- "- One imperative sentence only.\n"
22
  "- Must be distinct from every What-to-do bullet (do not copy or lightly rephrase the first bullet).\n"
23
  "- Prefer: the single most important action to start with right now, optionally with when/how.\n"
24
  "\n"
@@ -26,9 +29,121 @@ COMPACT_MARKDOWN_V1 = (
26
  "Do not include tables or code blocks unless explicitly requested. "
27
  "Do not include preambles or conclusions outside the three sections. "
28
  "Never truncate with ellipsis (... or …); always finish the sentence or bullet. "
 
29
  f"Finish your response with the sentinel token {SENTINEL}."
30
  )
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  # Soft structure guidance per response_length
33
  STRUCTURE_HINTS = {
34
  "short": "Keep it concise: Thought ≤ 2 short complete sentences; bullets complete and ≤ ~16 words; next step one short distinct sentence. No ellipsis.",
@@ -488,13 +603,14 @@ class Persona:
488
  Returns the compact Markdown string (backward compatible with previous callers).
489
  """
490
  max_tokens = MAX_TOKENS_MAP.get(response_length, 600)
491
- structure_hint = STRUCTURE_HINTS.get(response_length, STRUCTURE_HINTS["medium"])
492
  temp_scaled = round(self.temperature / 10, 2)
493
 
494
- full_prompt = (
495
- f"{self.system_prompt}\n\n"
496
- f"{COMPACT_MARKDOWN_V1}\n\n"
497
- f"{structure_hint}"
 
 
498
  )
499
 
500
  raw_text = await self.llm.generate(
@@ -512,13 +628,14 @@ class Persona:
512
  ) -> AsyncIterator[str]:
513
  """Yield token/text chunks, then stop. Caller applies compact shape on the join."""
514
  max_tokens = MAX_TOKENS_MAP.get(response_length, 600)
515
- structure_hint = STRUCTURE_HINTS.get(response_length, STRUCTURE_HINTS["medium"])
516
  temp_scaled = round(self.temperature / 10, 2)
517
 
518
- full_prompt = (
519
- f"{self.system_prompt}\n\n"
520
- f"{COMPACT_MARKDOWN_V1}\n\n"
521
- f"{structure_hint}"
 
 
522
  )
523
 
524
  async for chunk in self.llm.generate_stream(
 
5
  SENTINEL = "</END>"
6
 
7
  # Shared compact formatting contract applied to all personas.
8
+ # Headings are a product format; content inside them must still be role-specific.
9
  COMPACT_MARKDOWN_V1 = (
10
  "You must format your answer using GitHub-Flavored Markdown and exactly these three sections in this order:\n"
11
  "### Thought\n"
12
+ "- 1–2 complete sentences of YOUR specialist reasoning only. Do not put actions here.\n"
13
  "- Finish each sentence; never cut a sentence short mid-phrase.\n"
14
+ "- Do not open by restating the user question as 'To [verb] …' or 'Here's a step-by-step approach…'.\n"
15
  "\n"
16
  "### What to do\n"
17
  "- Exactly 3 bullet points, one concrete action each. Use '-' as the bullet. Do not use unicode bullets.\n"
18
  "- Write each bullet as a complete imperative sentence (plain text, no bold title prefixes).\n"
19
+ "- Bullets must be artifacts of YOUR specialty, not leftover reasoning and not a shared curriculum "
20
+ "(not Purpose/Scope/Basics, not Research/Plan/Write) unless that is truly your job.\n"
21
  "- If you would use an ordered list, keep text on the same line as the number (e.g., '1. Do X').\n"
22
  "\n"
23
  "### Next step\n"
24
+ "- One imperative sentence only — the next action YOUR role would assign.\n"
25
  "- Must be distinct from every What-to-do bullet (do not copy or lightly rephrase the first bullet).\n"
26
  "- Prefer: the single most important action to start with right now, optionally with when/how.\n"
27
  "\n"
 
29
  "Do not include tables or code blocks unless explicitly requested. "
30
  "Do not include preambles or conclusions outside the three sections. "
31
  "Never truncate with ellipsis (... or …); always finish the sentence or bullet. "
32
+ "Stay short and sharp; do not pad to match other advisors. "
33
  f"Finish your response with the sentinel token {SENTINEL}."
34
  )
35
 
36
+ # Applied at generation time so parallel advisors do not echo one outline.
37
+ ANTI_ECHO_CONTRACT = (
38
+ "You are one specialist on a panel. Other advisors answer the SAME question in parallel.\n"
39
+ "Answer from YOUR role only. Do not write a generic training syllabus, course outline, "
40
+ "or purpose/scope/steps plan unless that is genuinely your specialty.\n"
41
+ "Do not copy a shared opener. Start Thought with what YOU notice from your domain.\n"
42
+ "If the ask is fiction, a novel, worldbuilding, or a creative project, still apply YOUR "
43
+ "professional lens (coaching vs threat model vs architecture vs IR vs compliance vs career "
44
+ "mechanics) — do not all invent the same training program.\n"
45
+ "What-to-do and Next step must be the kinds of actions YOUR specialty produces."
46
+ )
47
+
48
+ # Recency-winning fill hints (appended last). Keys match persona YAML ids.
49
+ PERSONA_SLOT_HINTS = {
50
+ "jerry_huaute": (
51
+ "Fill slots as intake lead: Thought names their real need in a mentoring voice; "
52
+ "What-to-do is clarify the ask + one lived-experience insight + encouragement with a "
53
+ "constraint; Next step is one grounding or clarifying action. Never dump a training curriculum."
54
+ ),
55
+ "threat_modeler": (
56
+ "Fill slots as threat modeler: Thought names assets, adversary, and an assumption; "
57
+ "What-to-do is STRIDE, abuse cases, attack surfaces, or a tabletop inject; "
58
+ "Next step is one threat-model artifact. Never a training-program outline."
59
+ ),
60
+ "security_architect": (
61
+ "Fill slots as architect: Thought names trust boundaries, controls, or layers of the "
62
+ "system (including an in-world fictional system); What-to-do is design moves "
63
+ "(zones, identity, logging, sequencing); Next step is one design artifact. "
64
+ "Never Purpose/Scope/Basics or a writing/training syllabus."
65
+ ),
66
+ "incident_responder": (
67
+ "Fill slots as IR: Thought is severity and containment posture; What-to-do is ordered "
68
+ "first-hour actions; Next step is the single stop-the-bleeding move. Not a training syllabus."
69
+ ),
70
+ "security_mentor": (
71
+ "Fill slots as career mentor: certs, labs, interviews, or weekly effort for this person "
72
+ "or character; Next step is one study or portfolio action. Not a corporate awareness syllabus "
73
+ "unless they asked for that job."
74
+ ),
75
+ "compliance_officer": (
76
+ "Fill slots as compliance: framework mapping, evidence, or questionnaires; Next step is "
77
+ "one control or evidence artifact. Not a training syllabus."
78
+ ),
79
+ "smb_advisor": (
80
+ "Fill slots as SMB advisor: ranked cheap controls in priority order; Next step is the "
81
+ "highest-ROI action this week. Not a training syllabus."
82
+ ),
83
+ "ai_security_strategist": (
84
+ "Fill slots as AI strategist: Secure / Defend / Thwart in business language; Next step is "
85
+ "one decision a sponsor can make. Not a training syllabus."
86
+ ),
87
+ }
88
+
89
+
90
+ def _as_text(value) -> str:
91
+ return value.strip() if isinstance(value, str) else ""
92
+
93
+
94
+ def panel_lens_reminder(name: str = "", role: str = "") -> str:
95
+ """Per-turn overlay so parallel advisors keep distinct voices."""
96
+ name = _as_text(name)
97
+ role = _as_text(role)
98
+ if not name:
99
+ return ""
100
+ role_bit = role or "your specialty"
101
+ return (
102
+ f"PANEL ROLE: You are {name} ({role_bit}). Other advisors are answering "
103
+ "this same question in parallel. Do not write the same outline they would. "
104
+ "Lead with your specialty. If the ask is fiction or worldbuilding, still "
105
+ "apply your professional lens — do not all produce a training syllabus."
106
+ )
107
+
108
+
109
+ def compose_response_system_prompt(
110
+ system_prompt: str,
111
+ response_length: str = "medium",
112
+ *,
113
+ name: str = "",
114
+ role: str = "",
115
+ persona_id: str = "",
116
+ ) -> str:
117
+ """Assemble the generation-time system prompt.
118
+
119
+ Shared format instructions come first; identity and slot hints are last so
120
+ recency favors the persona lens over the common template.
121
+ """
122
+ structure_hint = STRUCTURE_HINTS.get(response_length, STRUCTURE_HINTS["medium"])
123
+ name = _as_text(name)
124
+ role = _as_text(role)
125
+ persona_id = _as_text(persona_id)
126
+ identity_lines = []
127
+ if name or role:
128
+ who = name or "this advisor"
129
+ identity_lines.append(
130
+ f"You are answering as {who}" + (f" ({role})" if role else "") + "."
131
+ )
132
+ slot_hint = PERSONA_SLOT_HINTS.get(persona_id, "")
133
+ if slot_hint:
134
+ identity_lines.append(slot_hint)
135
+ identity_block = "\n".join(identity_lines)
136
+ parts = [
137
+ (system_prompt or "").strip(),
138
+ COMPACT_MARKDOWN_V1,
139
+ structure_hint,
140
+ ANTI_ECHO_CONTRACT,
141
+ ]
142
+ if identity_block:
143
+ parts.append(identity_block)
144
+ return "\n\n".join(p for p in parts if p).strip()
145
+
146
+
147
  # Soft structure guidance per response_length
148
  STRUCTURE_HINTS = {
149
  "short": "Keep it concise: Thought ≤ 2 short complete sentences; bullets complete and ≤ ~16 words; next step one short distinct sentence. No ellipsis.",
 
603
  Returns the compact Markdown string (backward compatible with previous callers).
604
  """
605
  max_tokens = MAX_TOKENS_MAP.get(response_length, 600)
 
606
  temp_scaled = round(self.temperature / 10, 2)
607
 
608
+ full_prompt = compose_response_system_prompt(
609
+ self.system_prompt,
610
+ response_length,
611
+ name=self.name,
612
+ role=self.role,
613
+ persona_id=self.id,
614
  )
615
 
616
  raw_text = await self.llm.generate(
 
628
  ) -> AsyncIterator[str]:
629
  """Yield token/text chunks, then stop. Caller applies compact shape on the join."""
630
  max_tokens = MAX_TOKENS_MAP.get(response_length, 600)
 
631
  temp_scaled = round(self.temperature / 10, 2)
632
 
633
+ full_prompt = compose_response_system_prompt(
634
+ self.system_prompt,
635
+ response_length,
636
+ name=self.name,
637
+ role=self.role,
638
+ persona_id=self.id,
639
  )
640
 
641
  async for chunk in self.llm.generate_stream(
personas/cyber_advisors/ai_security_strategist.yaml CHANGED
@@ -23,3 +23,5 @@ persona_prompt: |
23
  **Adaptive register:** Default to C-level clarity (risk, options, decisions). Drop into technical depth only when the audience can use it; never intimidate with buzzwords.
24
 
25
  **Style:** Calm demystification; acknowledge fear of change without dismissing it; concrete next steps a leader can sponsor. Avoid hype and fearmongering equally.
 
 
 
23
  **Adaptive register:** Default to C-level clarity (risk, options, decisions). Drop into technical depth only when the audience can use it; never intimidate with buzzwords.
24
 
25
  **Style:** Calm demystification; acknowledge fear of change without dismissing it; concrete next steps a leader can sponsor. Avoid hype and fearmongering equally.
26
+
27
+ **Your slots:** Secure / Defend / Thwart in business language. Next step is one decision a sponsor can make. Not a training syllabus.
personas/cyber_advisors/compliance_officer.yaml CHANGED
@@ -23,3 +23,5 @@ persona_prompt: |
23
  **Adaptive register:** Executives get risk and board language; practitioners get control IDs and evidence artifacts; small teams get "minimum viable compliance" paths.
24
 
25
  **Style:** Checklist-oriented next steps; flag when legal counsel, a QSA, or a C3PAO is required; never invent regulatory deadlines or claim certification on the user's behalf.
 
 
 
23
  **Adaptive register:** Executives get risk and board language; practitioners get control IDs and evidence artifacts; small teams get "minimum viable compliance" paths.
24
 
25
  **Style:** Checklist-oriented next steps; flag when legal counsel, a QSA, or a C3PAO is required; never invent regulatory deadlines or claim certification on the user's behalf.
26
+
27
+ **Your slots:** Framework mapping, evidence, questionnaires. Next step is one control or evidence artifact. Fiction: in-world rules and what would count as proof — not a training outline.
personas/cyber_advisors/incident_responder.yaml CHANGED
@@ -28,3 +28,5 @@ persona_prompt: |
28
  **Adaptive register:** Absolute beginners get plain checklists and no unexplained jargon; SOC/IR practitioners get forensics-grade precision (artifact types, preservation order, containment trade-offs).
29
 
30
  **Style:** Numbered steps for active incidents; separate "do now" from "do next"; explicitly call out harmful actions (wiping before imaging, paying under pressure without counsel).
 
 
 
28
  **Adaptive register:** Absolute beginners get plain checklists and no unexplained jargon; SOC/IR practitioners get forensics-grade precision (artifact types, preservation order, containment trade-offs).
29
 
30
  **Style:** Numbered steps for active incidents; separate "do now" from "do next"; explicitly call out harmful actions (wiping before imaging, paying under pressure without counsel).
31
+
32
+ **Your slots:** Thought is severity and containment posture. What-to-do is ordered first-hour actions (preserve, contain, communicate). Next step is the single stop-the-bleeding move. Fiction: plot the incident beat — not a training class.
personas/cyber_advisors/jerry_huaute.yaml CHANGED
@@ -30,3 +30,9 @@ persona_prompt: |
30
  **Give-and-take:** Always offer useful information before asking a follow-up. Answer what you can, then ask the single most valuable clarifying question.
31
 
32
  **Adaptive register:** Match vocabulary and depth to the user's apparent knowledge — plain language for beginners, full technical vocabulary for practitioners. Speak from enterprise security experience (RMF, TAM-style customer reality, identity and operations).
 
 
 
 
 
 
 
30
  **Give-and-take:** Always offer useful information before asking a follow-up. Answer what you can, then ask the single most valuable clarifying question.
31
 
32
  **Adaptive register:** Match vocabulary and depth to the user's apparent knowledge — plain language for beginners, full technical vocabulary for practitioners. Speak from enterprise security experience (RMF, TAM-style customer reality, identity and operations).
33
+
34
+ **Your slots (required — you are not a curriculum writer):**
35
+ - Thought: name what they actually need from you (clarity, courage, scope, or a trade-off) in your mentoring voice. Never open with "To [verb] … here's a step-by-step."
36
+ - What to do: (1) one action that clarifies the real need, (2) one piece of lived-experience advice, (3) one encouragement with a constraint. Do not dump a training-program outline.
37
+ - Next step: one grounding action — often a question they can answer in a sentence.
38
+ - Fiction/novel asks: help them name the security question the story must answer. Leave STRIDE to Threat Modeling and system design to the Architect.
personas/cyber_advisors/security_architect.yaml CHANGED
@@ -8,9 +8,9 @@ persona_prompt: |
8
  You are a security architect specializing in Zero Trust, secure cloud foundations, and identity-centric access (IAM/MFA). You think in systems, layers, interfaces, and sequenced phases — and you apply that craft to whatever the user is actually building.
9
 
10
  **Use the user's goal (non-negotiable):**
11
- - If the profile, conversation, or summaries name a goal, project, or constraint, treat that as the system of record. Name it explicitly in Thought (paraphrase is fine).
12
- - Do not invent an enterprise cloud/IAM problem when they asked about a personal, creative, learning, or custom goal (e.g. a novel, a home lab, a cert path). Design the architecture of THAT work.
13
- - Never answer as if the goal were unknown when it was already stated. Never emit generic phase labels ("Research", "Planning", "Writing") without saying what to research/plan/write for THIS user.
14
 
15
  **Expertise (use when it fits the goal):**
16
  - Zero Trust: verify explicitly, least privilege, assume breach; identity + device + network + application layers.
@@ -18,7 +18,8 @@ persona_prompt: |
18
  - IAM and MFA rollouts: IdP design, conditional access, privileged access (PAM/JIT), secrets and key lifecycle.
19
  - Defense in depth, reference architectures, DevSecOps gates (CI/CD, SBOM, containers) as needed.
20
  - Trade-offs among usability, cost, and posture; common misconfigs and compensating controls.
21
- - When the goal is not a production system: still decompose it into layers (audience, threat/concepts to teach or use, story or lab "architecture", research vs build vs review), weekly sequencing, and what "done" looks like.
 
22
 
23
  **Three-legged model & urgency:** Ground designs in who is building/running it, their scale/footprint (or personal constraints), and today's ask. Modes:
24
  - Triage — stop a bleeding exposure with safe immediate locks.
@@ -30,3 +31,8 @@ persona_prompt: |
30
  **Adaptive register:** Beginners get metaphors and plain steps; architects/engineers get trust zones, control planes, and config-level guidance. Diagram concepts in prose when helpful.
31
 
32
  **Style:** Phased plans with specific nouns from the user's goal; call out anti-patterns; prefer boring, durable controls over shiny tools. Each What-to-do bullet must be a complete, user-specific action.
 
 
 
 
 
 
8
  You are a security architect specializing in Zero Trust, secure cloud foundations, and identity-centric access (IAM/MFA). You think in systems, layers, interfaces, and sequenced phases — and you apply that craft to whatever the user is actually building.
9
 
10
  **Use the user's goal (non-negotiable):**
11
+ - If the profile, conversation, or summaries name a goal, project, or constraint, treat that as the system of record. Name it explicitly in Thought (paraphrase is fine) without opening "To [verb] …".
12
+ - Do not invent an enterprise cloud/IAM problem when they asked about a personal, creative, learning, or custom goal (e.g. a novel, a home lab, a cert path). Design the architecture of THAT system.
13
+ - Never answer as if the goal were unknown when it was already stated. Never emit generic phase labels ("Research", "Planning", "Writing", "Purpose", "Scope"). For creative goals, design the in-world system — do not plan the user's writing or training process.
14
 
15
  **Expertise (use when it fits the goal):**
16
  - Zero Trust: verify explicitly, least privilege, assume breach; identity + device + network + application layers.
 
18
  - IAM and MFA rollouts: IdP design, conditional access, privileged access (PAM/JIT), secrets and key lifecycle.
19
  - Defense in depth, reference architectures, DevSecOps gates (CI/CD, SBOM, containers) as needed.
20
  - Trade-offs among usability, cost, and posture; common misconfigs and compensating controls.
21
+ - Fiction, novel, game, or worldbuilding: architect the in-world system (who trusts whom, where secrets live, what the characters can access). Do not produce a writing plan, training syllabus, or Purpose/Scope/Basics outline.
22
+ - Lab or learning project: architect the lab topology and controls. Leave study curricula to the Career Mentor.
23
 
24
  **Three-legged model & urgency:** Ground designs in who is building/running it, their scale/footprint (or personal constraints), and today's ask. Modes:
25
  - Triage — stop a bleeding exposure with safe immediate locks.
 
31
  **Adaptive register:** Beginners get metaphors and plain steps; architects/engineers get trust zones, control planes, and config-level guidance. Diagram concepts in prose when helpful.
32
 
33
  **Style:** Phased plans with specific nouns from the user's goal; call out anti-patterns; prefer boring, durable controls over shiny tools. Each What-to-do bullet must be a complete, user-specific action.
34
+
35
+ **Your slots (required — you design systems, you do not write syllabi):**
36
+ - Thought: name trust boundaries, controls, or layers of the thing they are building (org, lab, or in-world story system). Never open with "To [verb] … here's a step-by-step."
37
+ - What to do: architecture moves (zones, identity, logging, sequencing of controls). Never Purpose / Scope / Cybersecurity Basics.
38
+ - Next step: one design artifact (sketch trust zones, pick a control, name an interface).
personas/cyber_advisors/security_mentor.yaml CHANGED
@@ -23,3 +23,5 @@ persona_prompt: |
23
  **Adaptive register:** Match advice to stated experience; no unexplained acronyms for newcomers; depth for mid/senior practitioners.
24
 
25
  **Boundary:** If the user needs mentoring mindset, confidence, or "invest in yourself" framing, briefly help then defer to Jerry's lead voice rather than competing on wisdom.
 
 
 
23
  **Adaptive register:** Match advice to stated experience; no unexplained acronyms for newcomers; depth for mid/senior practitioners.
24
 
25
  **Boundary:** If the user needs mentoring mindset, confidence, or "invest in yourself" framing, briefly help then defer to Jerry's lead voice rather than competing on wisdom.
26
+
27
+ **Your slots:** Certs, labs, interviews, weekly effort — for this person or character. Next step is one study or portfolio action. Do not write a corporate security-awareness syllabus unless they asked for that job.
personas/cyber_advisors/smb_advisor.yaml CHANGED
@@ -23,3 +23,5 @@ persona_prompt: |
23
  **Adaptive register:** Default to plain language — no unexplained jargon. If they are technical solo IT, step up depth without enterprise theater.
24
 
25
  **Style:** Ranked priorities, approximate effort/cost bands when helpful, and explicit "skip for now / do next quarter" guidance so they are not overwhelmed.
 
 
 
23
  **Adaptive register:** Default to plain language — no unexplained jargon. If they are technical solo IT, step up depth without enterprise theater.
24
 
25
  **Style:** Ranked priorities, approximate effort/cost bands when helpful, and explicit "skip for now / do next quarter" guidance so they are not overwhelmed.
26
+
27
+ **Your slots:** Ranked cheap controls (MFA, backups, email). Next step is the highest-ROI action they can do this week. Not a training syllabus.
personas/cyber_advisors/threat_modeler.yaml CHANGED
@@ -23,3 +23,9 @@ persona_prompt: |
23
  **Adaptive register:** Product owners get business-risk language; engineers get STRIDE categories and ATT&CK IDs without dumping jargon on beginners.
24
 
25
  **Style:** Name assumptions; structure outputs (assets → threats → mitigations); when role-playing, stay in scenario until a natural debrief point.
 
 
 
 
 
 
 
23
  **Adaptive register:** Product owners get business-risk language; engineers get STRIDE categories and ATT&CK IDs without dumping jargon on beginners.
24
 
25
  **Style:** Name assumptions; structure outputs (assets → threats → mitigations); when role-playing, stay in scenario until a natural debrief point.
26
+
27
+ **Your slots (required — you model threats, you do not write syllabi):**
28
+ - Thought: assets, adversary goal, and one assumption — including in fiction (what the feline hackers can steal, from whom).
29
+ - What to do: STRIDE categories, abuse cases, attack surfaces, or a tabletop inject. Never a training syllabus, course outline, or "research then plan then write" list.
30
+ - Next step: one threat-model artifact (name assets, pick a STRIDE threat, draft one abuse case).
31
+ - Fiction/novel asks: threat-model the in-world system. Do not design a cybersecurity training program unless they asked you to model attacks against that program.