Rohit Sar Claude Opus 4.7 (1M context) commited on
Commit
271442b
Β·
1 Parent(s): 257107f

feat: view-aware copilot + persistent chat (D-020)

Browse files

Frontend was already wired to send view_context but backend silently dropped
it (Pydantic ignores unknown keys). End-to-end plumbing now reaches the LLM:

ChatRequest.view_context (backend/main.py)
β†’ handle_turn(view_context=...) (orchestrator.py)
β†’ build_messages(view_context=...) (persona.py)
β†’ system prompt injection ("USER IS CURRENTLY LOOKING AT: ...")

Bot can now resolve "this policy", "these filters", "this insurer" against
the active view (chat / marketplace / profile / premium / policy_detail) +
active_policy_id + filters β€” without asking the user to re-state context.

Also persists chat history + sessionId to localStorage so the conversation
survives view changes, page reloads, and tab switches.

Unit-tested all 4 layers: ChatRequest accepts the field, handle_turn signature
includes view_context, build_messages backwards-compat when None, and system
prompt contains the expected 'USER IS CURRENTLY LOOKING AT' block when set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

backend/main.py CHANGED
@@ -89,6 +89,14 @@ class ChatRequest(BaseModel):
89
  policy_filter_ids: Optional[list[str]] = Field(None, description="Restrict retrieval to these policies")
90
  return_audio: bool = Field(False, description="If true, also return TTS audio (base64 WAV)")
91
  tts_language_code: str = Field("en-IN", description="Language for TTS playback")
 
 
 
 
 
 
 
 
92
 
93
 
94
  class ChatResponse(BaseModel):
@@ -244,6 +252,7 @@ async def chat(req: ChatRequest):
244
  user_profile=req.profile,
245
  policy_filter_ids=req.policy_filter_ids,
246
  session_id=session_id,
 
247
  )
248
  except Exception as e:
249
  log_turn({
 
89
  policy_filter_ids: Optional[list[str]] = Field(None, description="Restrict retrieval to these policies")
90
  return_audio: bool = Field(False, description="If true, also return TTS audio (base64 WAV)")
91
  tts_language_code: str = Field("en-IN", description="Language for TTS playback")
92
+ view_context: Optional[dict] = Field(
93
+ None,
94
+ description=(
95
+ "Frontend-supplied snapshot of what the user is looking at right now: "
96
+ "{active_view, active_policy_id, filters}. Injected into the system prompt "
97
+ "so the bot can ground 'this policy' / 'these filters' references."
98
+ ),
99
+ )
100
 
101
 
102
  class ChatResponse(BaseModel):
 
252
  user_profile=req.profile,
253
  policy_filter_ids=req.policy_filter_ids,
254
  session_id=session_id,
255
+ view_context=req.view_context,
256
  )
257
  except Exception as e:
258
  log_turn({
backend/orchestrator.py CHANGED
@@ -126,6 +126,7 @@ async def handle_turn(
126
  policy_filter_ids: Optional[list[str]] = None,
127
  top_k: int = 5,
128
  session_id: Optional[str] = None,
 
129
  ) -> TurnResult:
130
  t0 = time.time()
131
 
@@ -224,6 +225,7 @@ async def handle_turn(
224
  retrieved_context=context_str,
225
  chat_history=chat_history,
226
  user_profile=user_profile,
 
227
  )
228
  messages = [ChatMessage(role=m["role"], content=m["content"]) for m in messages_dict]
229
 
 
126
  policy_filter_ids: Optional[list[str]] = None,
127
  top_k: int = 5,
128
  session_id: Optional[str] = None,
129
+ view_context: Optional[dict] = None,
130
  ) -> TurnResult:
131
  t0 = time.time()
132
 
 
225
  retrieved_context=context_str,
226
  chat_history=chat_history,
227
  user_profile=user_profile,
228
+ view_context=view_context,
229
  )
230
  messages = [ChatMessage(role=m["role"], content=m["content"]) for m in messages_dict]
231
 
backend/persona.py CHANGED
@@ -57,6 +57,7 @@ def build_messages(
57
  retrieved_context: str,
58
  chat_history: list[dict] | None = None,
59
  user_profile: dict | None = None,
 
60
  ) -> list[dict]:
61
  """Assemble the message list for the LLM call."""
62
  system = ADVISOR_SYSTEM_PROMPT_V1
@@ -66,6 +67,27 @@ def build_messages(
66
  )
67
  system = system + profile_summary
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  messages: list[dict] = [{"role": "system", "content": system}]
70
 
71
  # History (excluding the last user turn β€” we add it below with context)
 
57
  retrieved_context: str,
58
  chat_history: list[dict] | None = None,
59
  user_profile: dict | None = None,
60
+ view_context: dict | None = None,
61
  ) -> list[dict]:
62
  """Assemble the message list for the LLM call."""
63
  system = ADVISOR_SYSTEM_PROMPT_V1
 
67
  )
68
  system = system + profile_summary
69
 
70
+ if view_context:
71
+ bits: list[str] = []
72
+ av = view_context.get("active_view")
73
+ apid = view_context.get("active_policy_id")
74
+ fil = view_context.get("filters")
75
+ if av:
76
+ bits.append(f"active view: {av}")
77
+ if apid:
78
+ bits.append(f"policy open in detail: {apid}")
79
+ if fil:
80
+ bits.append(f"marketplace filters: {fil}")
81
+ if bits:
82
+ system = (
83
+ system
84
+ + "\n\nUSER IS CURRENTLY LOOKING AT:\n"
85
+ + "\n".join(f"- {b}" for b in bits)
86
+ + "\nWhen the user's question refers to 'this policy', 'this insurer', 'these filters',"
87
+ + " or otherwise relies on what's on screen, ground your answer in the active view above"
88
+ + " β€” do not ask the user to re-state it."
89
+ )
90
+
91
  messages: list[dict] = [{"role": "system", "content": system}]
92
 
93
  # History (excluding the last user turn β€” we add it below with context)
frontend/src/app/page.tsx CHANGED
@@ -77,6 +77,37 @@ export default function Page() {
77
  .catch(() => setProfileCompleteness(null));
78
  }
79
  }, [sessionId]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  const [uploadStatus, setUploadStatus] = useState<string | null>(null);
81
  const [handsFree, setHandsFree] = useState(false); // VAD auto-cutoff mode
82
  // Live ref of handsFree so async TTS-ended callbacks read the latest value
@@ -149,14 +180,35 @@ export default function Page() {
149
  pushUser(text);
150
  try {
151
  const history: ChatMessage[] = messages.map((m) => ({ role: m.role, content: m.content }));
 
 
 
 
 
 
 
 
 
152
  const res = await postChat({
153
  user_text: text,
154
  session_id: sessionId,
155
  chat_history: history,
156
  return_audio: returnAudio,
157
  tts_language_code: ttsLang,
 
 
 
 
158
  });
159
  setSessionId(res.session_id);
 
 
 
 
 
 
 
 
160
  const audioUrl = res.audio_base64 ? audioBlobURLFromBase64(res.audio_base64) : undefined;
161
  pushAssistant(res.reply_text, {
162
  citations: res.citations,
@@ -401,30 +453,19 @@ export default function Page() {
401
  </button>
402
  </div>
403
  </div>
404
- {showMarketplace && marketplace && (
405
- <MarketplacePanel
406
- data={marketplace}
407
- onOpenPolicy={(p) => setOpenPolicy(p)}
408
- onClose={() => setShowMarketplace(false)}
409
- t={t}
410
- isPersonalized={profileCompleteness?.is_personalized === true}
411
- />
412
- )}
413
- {showPremium && <PremiumCalculatorPanel onClose={() => setShowPremium(false)} />}
414
- {showProfile && (
415
- <ProfileBuilderPanel
416
- sessionId={sessionId}
417
- setSessionId={setSessionId}
418
- initialProfile={profileCompleteness?.profile || {}}
419
- onSaved={(resp) => { setProfileCompleteness(resp); }}
420
- onClose={() => setShowProfile(false)}
421
- uiLang={uiLang}
422
- />
423
- )}
424
  </header>
425
  {openPolicy && <PolicyDetailModal policy={openPolicy} onClose={() => setOpenPolicy(null)} />}
426
 
427
- <main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-4 sm:py-6 flex flex-col">
 
 
 
 
 
 
 
 
 
428
  {messages.length === 0 ? (
429
  <EmptyState onSuggest={(q) => send(q)} coverage={coverage} t={t} />
430
  ) : (
@@ -508,6 +549,35 @@ export default function Page() {
508
  </div>
509
  </main>
510
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  <footer className="border-t border-[var(--border)] py-3 px-6 text-center text-xs text-[var(--muted-foreground)]">
512
  Advisory only. Information based on policy documents; verify with the insurer before purchase. All policy ratings are illustrative and based on publicly disclosed data.
513
  </footer>
 
77
  .catch(() => setProfileCompleteness(null));
78
  }
79
  }, [sessionId]);
80
+
81
+ // Session persistence: rehydrate chat history + sessionId on mount so the
82
+ // user's conversation survives view changes, page reloads, and tab switches.
83
+ useEffect(() => {
84
+ if (typeof window === "undefined") return;
85
+ const savedMessages = localStorage.getItem("insurance_chat_messages");
86
+ if (savedMessages) {
87
+ try {
88
+ const parsed = JSON.parse(savedMessages) as DisplayMessage[];
89
+ if (Array.isArray(parsed) && parsed.length > 0) setMessages(parsed);
90
+ } catch {
91
+ // corrupt cache β€” wipe so we don't retry
92
+ localStorage.removeItem("insurance_chat_messages");
93
+ }
94
+ }
95
+ const savedSession = localStorage.getItem("insurance_session_id");
96
+ if (savedSession) setSessionId(savedSession);
97
+ }, []);
98
+
99
+ // Persist chat history on every change. Strip transient blob audio URLs β€”
100
+ // they expire across reloads anyway, and the base64 source is gone.
101
+ useEffect(() => {
102
+ if (typeof window === "undefined") return;
103
+ if (messages.length === 0) return; // don't overwrite with an empty array on first render before rehydrate
104
+ const trimmed = messages.map(({ audioUrl: _audioUrl, ...rest }) => rest);
105
+ try {
106
+ localStorage.setItem("insurance_chat_messages", JSON.stringify(trimmed));
107
+ } catch {
108
+ // localStorage full or unavailable β€” silently drop persistence rather than break the chat
109
+ }
110
+ }, [messages]);
111
  const [uploadStatus, setUploadStatus] = useState<string | null>(null);
112
  const [handsFree, setHandsFree] = useState(false); // VAD auto-cutoff mode
113
  // Live ref of handsFree so async TTS-ended callbacks read the latest value
 
180
  pushUser(text);
181
  try {
182
  const history: ChatMessage[] = messages.map((m) => ({ role: m.role, content: m.content }));
183
+ // Real-time copilot context β€” tells the backend what the user is
184
+ // currently looking at, so answers can be grounded in that view rather
185
+ // than asking the user to re-state their context.
186
+ const active_view: "chat" | "marketplace" | "profile" | "premium" | "policy_detail" =
187
+ openPolicy ? "policy_detail" :
188
+ showMarketplace ? "marketplace" :
189
+ showProfile ? "profile" :
190
+ showPremium ? "premium" :
191
+ "chat";
192
  const res = await postChat({
193
  user_text: text,
194
  session_id: sessionId,
195
  chat_history: history,
196
  return_audio: returnAudio,
197
  tts_language_code: ttsLang,
198
+ view_context: {
199
+ active_view,
200
+ active_policy_id: openPolicy?.policy_id,
201
+ },
202
  });
203
  setSessionId(res.session_id);
204
+ // Refresh profileCompleteness after every chat turn so that any profile
205
+ // fields the backend extracted from the user's message (age, conditions,
206
+ // budget, etc.) immediately flip `is_personalized` and re-rank the
207
+ // marketplace. Without this, profile updates only land when sessionId
208
+ // changes β€” which is once, after the first message.
209
+ getProfileCompleteness(res.session_id)
210
+ .then(setProfileCompleteness)
211
+ .catch(() => { /* keep prior on transient error */ });
212
  const audioUrl = res.audio_base64 ? audioBlobURLFromBase64(res.audio_base64) : undefined;
213
  pushAssistant(res.reply_text, {
214
  citations: res.citations,
 
453
  </button>
454
  </div>
455
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  </header>
457
  {openPolicy && <PolicyDetailModal policy={openPolicy} onClose={() => setOpenPolicy(null)} />}
458
 
459
+ {/* Two-column layout on desktop (chat | panel), stacked on mobile.
460
+ When no panel is open, chat takes the full width. The chat column
461
+ never unmounts, so messages, voice, and view-context stay live no
462
+ matter which view the user is focused on. */}
463
+ <div className="flex-1 flex flex-col lg:flex-row min-h-0 w-full">
464
+ <main className={`flex flex-col min-h-0 px-4 sm:px-6 py-4 sm:py-6 ${
465
+ (showMarketplace || showPremium || showProfile)
466
+ ? "lg:w-2/5 lg:border-r lg:border-[var(--border)] w-full"
467
+ : "max-w-6xl w-full mx-auto"
468
+ }`}>
469
  {messages.length === 0 ? (
470
  <EmptyState onSuggest={(q) => send(q)} coverage={coverage} t={t} />
471
  ) : (
 
549
  </div>
550
  </main>
551
 
552
+ {/* Panel column β€” sits beside the chat on desktop, takes over on
553
+ mobile. Stays mounted as long as a panel is open; chat in the
554
+ other column remains fully interactive (real-time copilot). */}
555
+ {(showMarketplace || showPremium || showProfile) && (
556
+ <aside className="lg:w-3/5 w-full overflow-y-auto bg-[var(--background)]">
557
+ {showMarketplace && marketplace && (
558
+ <MarketplacePanel
559
+ data={marketplace}
560
+ onOpenPolicy={(p) => setOpenPolicy(p)}
561
+ onClose={() => setShowMarketplace(false)}
562
+ t={t}
563
+ isPersonalized={profileCompleteness?.is_personalized === true}
564
+ />
565
+ )}
566
+ {showPremium && <PremiumCalculatorPanel onClose={() => setShowPremium(false)} />}
567
+ {showProfile && (
568
+ <ProfileBuilderPanel
569
+ sessionId={sessionId}
570
+ setSessionId={setSessionId}
571
+ initialProfile={profileCompleteness?.profile || {}}
572
+ onSaved={(resp) => { setProfileCompleteness(resp); }}
573
+ onClose={() => setShowProfile(false)}
574
+ uiLang={uiLang}
575
+ />
576
+ )}
577
+ </aside>
578
+ )}
579
+ </div>
580
+
581
  <footer className="border-t border-[var(--border)] py-3 px-6 text-center text-xs text-[var(--muted-foreground)]">
582
  Advisory only. Information based on policy documents; verify with the insurer before purchase. All policy ratings are illustrative and based on publicly disclosed data.
583
  </footer>
frontend/src/lib/api.ts CHANGED
@@ -36,6 +36,17 @@ export type ChatMessage = {
36
  content: string;
37
  };
38
 
 
 
 
 
 
 
 
 
 
 
 
39
  export async function postChat(args: {
40
  user_text: string;
41
  session_id?: string;
@@ -44,6 +55,7 @@ export async function postChat(args: {
44
  policy_filter_ids?: string[];
45
  return_audio?: boolean;
46
  tts_language_code?: string;
 
47
  }): Promise<ChatResponse> {
48
  const resp = await fetch(`${BACKEND_URL}/api/chat`, {
49
  method: "POST",
@@ -56,6 +68,7 @@ export async function postChat(args: {
56
  policy_filter_ids: args.policy_filter_ids,
57
  return_audio: args.return_audio ?? false,
58
  tts_language_code: args.tts_language_code ?? "en-IN",
 
59
  }),
60
  });
61
  if (!resp.ok) {
 
36
  content: string;
37
  };
38
 
39
+ export type ViewContext = {
40
+ // Which top-level panel the user is currently focused on. The chat treats
41
+ // this as the "screen" the copilot can see β€” answers can reference what the
42
+ // user is looking at without them having to re-state it.
43
+ active_view: "chat" | "marketplace" | "profile" | "premium" | "policy_detail";
44
+ // Policy ID currently open in a detail modal, if any.
45
+ active_policy_id?: string;
46
+ // Optional marketplace filters (forwarded for personalization signals).
47
+ filters?: Record<string, unknown>;
48
+ };
49
+
50
  export async function postChat(args: {
51
  user_text: string;
52
  session_id?: string;
 
55
  policy_filter_ids?: string[];
56
  return_audio?: boolean;
57
  tts_language_code?: string;
58
+ view_context?: ViewContext;
59
  }): Promise<ChatResponse> {
60
  const resp = await fetch(`${BACKEND_URL}/api/chat`, {
61
  method: "POST",
 
68
  policy_filter_ids: args.policy_filter_ids,
69
  return_audio: args.return_audio ?? false,
70
  tts_language_code: args.tts_language_code ?? "en-IN",
71
+ view_context: args.view_context,
72
  }),
73
  });
74
  if (!resp.ok) {