NeonClary Cursor commited on
Commit
f13098c
·
1 Parent(s): 689d159

Wire LLM failover for the BrainForge Cybersecurity endpoint

Browse files

- Add HTTP Basic auth support to ImprovedVllmClient via the
Authorization default_header so HANA-style credentials
(guest:HANA_KLATCHAT_PASSWORD) authenticate against the
4090-x1-3 BrainForge/Security vLLM endpoint.
- Have bootstrap load HANA_USERNAME_KLATCHAT / HANA_KLATCHAT_PASSWORD
from ~/.secrets/shared.env when api_username is configured, and opt
cybersecurity_config.yaml in via api_username: "guest".
- Stop provider.py from re-registering personas at module import time
with a raw ImprovedVllmClient, which silently overwrote bootstrap's
ResilientLLMClient wrapping and disabled OpenAI fallback.
switch_provider now reuses bootstrap.create_persona_llm and
create_orchestrator_llm to preserve the wrap.
- Rewrite ResilientLLMClient._race_or_fallback per spec: try primary;
on error run fallback sequentially; on >race_timeout_seconds run
fallback in parallel and return whichever finishes first. Bump
default race_timeout_seconds to 60s.
- Fix OpenAIFallbackClient for gpt-5 / o-series: use
max_completion_tokens and normalize persona-id author roles to
"assistant" so OpenAI accepts the orchestrator context.
- Centralize env loading via app/core/env_loader.py and use it from
main.py + bootstrap so the backend always picks up shared.env.
- Polish header theming + branding (Cybersecurity Advisor), restore
the cybersecurity user-profile UX touches, and drop the chat title
from the floating header.
- Add app/core/_debug_log.py NDJSON logger and runtime probes in
chat / resilient / vLLM / OpenAI clients to keep collecting
evidence until the failover path is verified end-to-end.

Co-authored-by: Cursor <cursoragent@cursor.com>

cybersecurity_config.yaml CHANGED
@@ -1,5 +1,5 @@
1
  # ============================================================================
2
- # Cybersecurity Advisor Canvas — Application Configuration
3
  # ============================================================================
4
 
5
  app:
@@ -25,7 +25,7 @@ homepage:
25
  Get practical guidance on threats, compliance, incident response, architecture,
26
  and career growth from a panel of cybersecurity-focused AI advisors — each
27
  bringing a distinct lens to your questions.
28
- features_title: "Why Use Cybersecurity Advisor Canvas?"
29
  features:
30
  - title: "Defense in Depth"
31
  description: "Receive layered perspectives on risk, controls, detection, and response"
@@ -176,6 +176,7 @@ llm:
176
  vllm:
177
  api_url: "https://4090-x1-3.neonaiservices2.com/vllm0"
178
  api_key: ""
 
179
  model_id: "BrainForge/Security@2026.03.18"
180
  neon_persona_orchestrator: "vanilla"
181
  neon_persona_advisors: "CybersecurityExpert"
@@ -185,7 +186,7 @@ llm:
185
  orchestrator_reasoning_effort: "low"
186
  persona_reasoning_effort: "none"
187
  resilient:
188
- race_timeout_seconds: 3
189
 
190
  rag:
191
  embedding_model: "all-MiniLM-L6-v2"
 
1
  # ============================================================================
2
+ # Cybersecurity Advisor — Application Configuration
3
  # ============================================================================
4
 
5
  app:
 
25
  Get practical guidance on threats, compliance, incident response, architecture,
26
  and career growth from a panel of cybersecurity-focused AI advisors — each
27
  bringing a distinct lens to your questions.
28
+ features_title: "Why Use Cybersecurity Advisor?"
29
  features:
30
  - title: "Defense in Depth"
31
  description: "Receive layered perspectives on risk, controls, detection, and response"
 
176
  vllm:
177
  api_url: "https://4090-x1-3.neonaiservices2.com/vllm0"
178
  api_key: ""
179
+ api_username: "guest"
180
  model_id: "BrainForge/Security@2026.03.18"
181
  neon_persona_orchestrator: "vanilla"
182
  neon_persona_advisors: "CybersecurityExpert"
 
186
  orchestrator_reasoning_effort: "low"
187
  persona_reasoning_effort: "none"
188
  resilient:
189
+ race_timeout_seconds: 60
190
 
191
  rag:
192
  embedding_model: "all-MiniLM-L6-v2"
multi_llm_chatbot_backend/app/api/routes/chat.py CHANGED
@@ -166,15 +166,64 @@ async def chat_stream(
166
  done_queue: asyncio.Queue = asyncio.Queue()
167
 
168
  async def _run(pid: str) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  try:
170
  persona = chat_orchestrator.get_persona(pid)
171
  result = await chat_orchestrator.generate_single_persona_response(
172
  session, persona,
173
  message.response_length or "medium",
174
  )
 
 
 
 
 
 
 
175
  session.append_message(pid, result["response"])
176
  await done_queue.put(result)
177
  except Exception as e:
 
 
 
 
 
 
 
178
  logger.exception(f"chat-stream _run failed for {pid}: {e}")
179
  failed_persona = chat_orchestrator.get_persona(pid)
180
  await done_queue.put({
 
166
  done_queue: asyncio.Queue = asyncio.Queue()
167
 
168
  async def _run(pid: str) -> None:
169
+ # #region agent log
170
+ import os as _os
171
+ from app.core._debug_log import dlog
172
+ _probe_persona = chat_orchestrator.get_persona(pid)
173
+ _llm = getattr(_probe_persona, "llm", None) if _probe_persona else None
174
+ _llm_type = type(_llm).__name__ if _llm is not None else None
175
+ _primary_type = type(getattr(_llm, "primary", None)).__name__ if _llm is not None else None
176
+ _fallback_type = type(getattr(_llm, "fallback", None)).__name__ if _llm is not None else None
177
+ _primary_key = getattr(getattr(_llm, "primary", None), "api_key", None)
178
+ _primary_username = getattr(getattr(_llm, "primary", None), "api_username", None)
179
+ _primary_apiurl = getattr(getattr(_llm, "primary", None), "api_url", None)
180
+ _fallback_key = getattr(getattr(_llm, "fallback", None), "api_key", None)
181
+ _fallback_model = getattr(getattr(_llm, "fallback", None), "model", None)
182
+ if not _primary_key and _llm is not None:
183
+ _primary_key = getattr(_llm, "api_key", None)
184
+ _vllm_env = _os.environ.get("VLLM_API_KEY", "")
185
+ _openai_env = _os.environ.get("OPENAI_API_KEY", "")
186
+ dlog("chat.py:_run.entry", f"persona task started", {
187
+ "pid": pid,
188
+ "llm_type": _llm_type,
189
+ "primary_type": _primary_type,
190
+ "primary_api_url": _primary_apiurl,
191
+ "primary_api_username": _primary_username,
192
+ "primary_key_prefix": (_primary_key[:8] + "…") if _primary_key else None,
193
+ "primary_key_len": len(_primary_key) if _primary_key else 0,
194
+ "fallback_type": _fallback_type,
195
+ "fallback_model": _fallback_model,
196
+ "fallback_key_prefix": (_fallback_key[:8] + "…") if _fallback_key else None,
197
+ "fallback_key_len": len(_fallback_key) if _fallback_key else 0,
198
+ "env_vllm_prefix": (_vllm_env[:8] + "…") if _vllm_env else None,
199
+ "env_vllm_len": len(_vllm_env),
200
+ "env_openai_prefix": (_openai_env[:8] + "…") if _openai_env else None,
201
+ "env_openai_len": len(_openai_env),
202
+ }, "F")
203
+ # #endregion
204
  try:
205
  persona = chat_orchestrator.get_persona(pid)
206
  result = await chat_orchestrator.generate_single_persona_response(
207
  session, persona,
208
  message.response_length or "medium",
209
  )
210
+ # #region agent log
211
+ dlog("chat.py:_run.result", "persona response", {
212
+ "pid": pid,
213
+ "response_preview": (result.get("response") or "")[:200],
214
+ "used_documents": result.get("used_documents"),
215
+ }, "B")
216
+ # #endregion
217
  session.append_message(pid, result["response"])
218
  await done_queue.put(result)
219
  except Exception as e:
220
+ # #region agent log
221
+ dlog("chat.py:_run.exception", "persona task exception", {
222
+ "pid": pid,
223
+ "exc_type": type(e).__name__,
224
+ "exc_msg": str(e)[:500],
225
+ }, "B")
226
+ # #endregion
227
  logger.exception(f"chat-stream _run failed for {pid}: {e}")
228
  failed_persona = chat_orchestrator.get_persona(pid)
229
  await done_queue.put({
multi_llm_chatbot_backend/app/api/routes/provider.py CHANGED
@@ -37,11 +37,12 @@ def create_llm_client(provider: str = None):
37
  else:
38
  raise ValueError(f"Unknown provider: {provider}")
39
 
40
- # Initialize LLM and personas
41
- llm = create_llm_client(current_provider)
42
- DEFAULT_PERSONAS = get_default_personas(llm)
43
- for persona in DEFAULT_PERSONAS:
44
- chat_orchestrator.register_persona(persona)
 
45
 
46
  class ProviderSwitch(BaseModel):
47
  provider: str
@@ -65,13 +66,19 @@ async def switch_provider(provider_data: ProviderSwitch):
65
  raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_data.provider}. Available: {available_providers}")
66
 
67
  try:
 
 
 
 
 
68
  current_provider = provider_data.provider
69
- new_llm = create_llm_client(current_provider)
70
  llm = new_llm
71
 
72
  chat_orchestrator.llm_client = new_llm
73
 
74
- new_personas = get_default_personas(new_llm)
 
75
  chat_orchestrator.personas.clear()
76
  for persona in new_personas:
77
  chat_orchestrator.register_persona(persona)
 
37
  else:
38
  raise ValueError(f"Unknown provider: {provider}")
39
 
40
+ # NOTE: Personas and `llm` are already created and registered by
41
+ # app/core/bootstrap.py with the correct ResilientLLMClient wrapping
42
+ # (primary vLLM + OpenAI fallback). Re-registering them here at module
43
+ # import time replaces them with a raw vLLM client that lacks the
44
+ # fallback wrapper AND lacks the api_username/api_key wiring, breaking
45
+ # both auth and failover. Bootstrap already handles initial setup.
46
 
47
  class ProviderSwitch(BaseModel):
48
  provider: str
 
66
  raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_data.provider}. Available: {available_providers}")
67
 
68
  try:
69
+ from app.core.bootstrap import (
70
+ create_orchestrator_llm,
71
+ create_persona_llm,
72
+ )
73
+
74
  current_provider = provider_data.provider
75
+ new_llm = create_orchestrator_llm()
76
  llm = new_llm
77
 
78
  chat_orchestrator.llm_client = new_llm
79
 
80
+ persona_llm = create_persona_llm()
81
+ new_personas = get_default_personas(persona_llm)
82
  chat_orchestrator.personas.clear()
83
  for persona in new_personas:
84
  chat_orchestrator.register_persona(persona)
multi_llm_chatbot_backend/app/config.py CHANGED
@@ -273,6 +273,7 @@ class OllamaConfig(BaseModel):
273
  class VllmConfig(BaseModel):
274
  api_url: str = ""
275
  api_key: str = Field(default=os.getenv("VLLM_API_KEY", ""))
 
276
  model_id: str = ""
277
  neon_persona_orchestrator: str = "vanilla"
278
  neon_persona_advisors: str = "CybersecurityExpert"
 
273
  class VllmConfig(BaseModel):
274
  api_url: str = ""
275
  api_key: str = Field(default=os.getenv("VLLM_API_KEY", ""))
276
+ api_username: str = Field(default=os.getenv("VLLM_API_USERNAME", ""))
277
  model_id: str = ""
278
  neon_persona_orchestrator: str = "vanilla"
279
  neon_persona_advisors: str = "CybersecurityExpert"
multi_llm_chatbot_backend/app/core/_debug_log.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Debug-mode NDJSON logger. Writes to the agent debug log file."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ _LOG_PATH = Path(r"C:\Users\dream\.cursor\projects\Ask-A-Neon-LLM-Demos\debug-1d5c0f.log")
10
+ _SESSION_ID = "1d5c0f"
11
+
12
+
13
+ def dlog(location: str, message: str, data: Any = None, hypothesis_id: str = "") -> None:
14
+ try:
15
+ entry = {
16
+ "sessionId": _SESSION_ID,
17
+ "timestamp": int(time.time() * 1000),
18
+ "location": location,
19
+ "message": message,
20
+ "data": data or {},
21
+ }
22
+ if hypothesis_id:
23
+ entry["hypothesisId"] = hypothesis_id
24
+ _LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
25
+ with _LOG_PATH.open("a", encoding="utf-8") as fh:
26
+ fh.write(json.dumps(entry, default=str) + "\n")
27
+ except Exception:
28
+ pass
multi_llm_chatbot_backend/app/core/bootstrap.py CHANGED
@@ -36,7 +36,35 @@ def _load_shared_env_var(name: str) -> str:
36
  return ""
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  def _vllm_api_key() -> str:
 
 
 
 
 
 
 
 
 
 
 
 
40
  return (
41
  settings.llm.vllm.api_key
42
  or os.getenv("VLLM_API_KEY", "")
@@ -62,6 +90,7 @@ def _build_neon_vllm(neon_persona: str | None) -> ImprovedVllmClient:
62
  api_key=_vllm_api_key(),
63
  model_name=model_name,
64
  neon_persona=neon_persona,
 
65
  )
66
 
67
 
 
36
  return ""
37
 
38
 
39
+ def _vllm_api_username() -> str:
40
+ """Optional HTTP Basic auth username for the vLLM endpoint.
41
+
42
+ The Neon BrainForge/Security endpoint at 4090-x1-3 requires HTTP Basic
43
+ auth using HANA_USERNAME_KLATCHAT / HANA_KLATCHAT_PASSWORD from
44
+ ~/.secrets/shared.env. We allow explicit override via api_username
45
+ in config or VLLM_API_USERNAME env var, falling back to the shared
46
+ HANA_USERNAME_KLATCHAT entry.
47
+ """
48
+ return (
49
+ settings.llm.vllm.api_username
50
+ or os.getenv("VLLM_API_USERNAME", "")
51
+ or _load_shared_env_var("HANA_USERNAME_KLATCHAT")
52
+ )
53
+
54
+
55
  def _vllm_api_key() -> str:
56
+ """vLLM key/password. If api_username is set (HANA Basic auth), prefer
57
+ HANA_KLATCHAT_PASSWORD; otherwise use the generic VLLM_API_KEY Bearer
58
+ token. This matches the dual-auth nature of the Neon endpoints.
59
+ """
60
+ if _vllm_api_username():
61
+ return (
62
+ settings.llm.vllm.api_key
63
+ or os.getenv("HANA_KLATCHAT_PASSWORD", "")
64
+ or _load_shared_env_var("HANA_KLATCHAT_PASSWORD")
65
+ or os.getenv("VLLM_API_KEY", "")
66
+ or _load_shared_env_var("VLLM_API_KEY")
67
+ )
68
  return (
69
  settings.llm.vllm.api_key
70
  or os.getenv("VLLM_API_KEY", "")
 
90
  api_key=_vllm_api_key(),
91
  model_name=model_name,
92
  neon_persona=neon_persona,
93
+ api_username=_vllm_api_username() or None,
94
  )
95
 
96
 
multi_llm_chatbot_backend/app/llm/improved_vllm_client.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import json
2
  import logging
3
  from typing import Any, Callable, Dict, List, Optional
@@ -19,17 +20,33 @@ class ImprovedVllmClient(LLMClient):
19
  model_name: str = None,
20
  neon_persona: str | None = None,
21
  model_revision: str | None = None,
 
22
  ):
23
  self.api_url = api_url
24
  self.api_key = api_key
 
25
  self.model_name = model_name
26
  self.neon_persona = neon_persona
27
  self.model_revision = model_revision
28
- self.client = AsyncOpenAI(
29
- base_url=f"{api_url}/v1",
30
- api_key=api_key or "not-needed",
31
- timeout=90.0,
32
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  self.context_manager = get_context_manager()
34
 
35
  def _resolve_model_revision(self) -> str | None:
@@ -100,16 +117,39 @@ class ImprovedVllmClient(LLMClient):
100
  text = response.choices[0].message.content.strip()
101
  return self._clean_response(text)
102
 
103
- except APIConnectionError:
 
 
 
 
 
 
 
104
  logger.error(f"Unable to connect to vLLM at {self.api_url}")
105
  return "I'm unable to connect to the AI service. Please ensure the vLLM endpoint is available."
106
  except APIStatusError as e:
 
 
 
 
 
 
 
 
 
107
  logger.error(f"vLLM API error: {e.status_code} - {e.message}")
108
  if e.status_code == 404:
109
  logger.info("Model not found, will re-discover on next request")
110
  self.model_name = None
111
  return "The AI service encountered an error. Please try again."
112
  except Exception as e:
 
 
 
 
 
 
 
113
  logger.error(f"Unexpected error in vLLM client: {str(e)}")
114
  return "I encountered an unexpected error. Please try again."
115
 
 
1
+ import base64
2
  import json
3
  import logging
4
  from typing import Any, Callable, Dict, List, Optional
 
20
  model_name: str = None,
21
  neon_persona: str | None = None,
22
  model_revision: str | None = None,
23
+ api_username: str | None = None,
24
  ):
25
  self.api_url = api_url
26
  self.api_key = api_key
27
+ self.api_username = api_username or None
28
  self.model_name = model_name
29
  self.neon_persona = neon_persona
30
  self.model_revision = model_revision
31
+
32
+ client_kwargs: dict = {
33
+ "base_url": f"{api_url}/v1",
34
+ "api_key": api_key or "not-needed",
35
+ "timeout": 90.0,
36
+ }
37
+
38
+ if self.api_username:
39
+ # Some Neon endpoints (e.g. BrainForge/Security at 4090-x1-3)
40
+ # require HTTP Basic auth using HANA-style credentials rather
41
+ # than the regular Bearer token. The OpenAI SDK injects its own
42
+ # Authorization: Bearer <api_key> header on every request, so we
43
+ # override it via default_headers to ensure Basic auth wins.
44
+ basic = base64.b64encode(
45
+ f"{self.api_username}:{api_key or ''}".encode("utf-8")
46
+ ).decode("ascii")
47
+ client_kwargs["default_headers"] = {"Authorization": f"Basic {basic}"}
48
+
49
+ self.client = AsyncOpenAI(**client_kwargs)
50
  self.context_manager = get_context_manager()
51
 
52
  def _resolve_model_revision(self) -> str | None:
 
117
  text = response.choices[0].message.content.strip()
118
  return self._clean_response(text)
119
 
120
+ except APIConnectionError as e:
121
+ # #region agent log
122
+ from app.core._debug_log import dlog
123
+ dlog("improved_vllm_client.py:generate.conn_err", "vLLM connection error", {
124
+ "api_url": self.api_url,
125
+ "exc_msg": str(e)[:300],
126
+ }, "E")
127
+ # #endregion
128
  logger.error(f"Unable to connect to vLLM at {self.api_url}")
129
  return "I'm unable to connect to the AI service. Please ensure the vLLM endpoint is available."
130
  except APIStatusError as e:
131
+ # #region agent log
132
+ from app.core._debug_log import dlog
133
+ dlog("improved_vllm_client.py:generate.status_err", "vLLM API status error", {
134
+ "status_code": getattr(e, "status_code", None),
135
+ "message": getattr(e, "message", None),
136
+ "api_url": self.api_url,
137
+ "model": self.model_name,
138
+ }, "E")
139
+ # #endregion
140
  logger.error(f"vLLM API error: {e.status_code} - {e.message}")
141
  if e.status_code == 404:
142
  logger.info("Model not found, will re-discover on next request")
143
  self.model_name = None
144
  return "The AI service encountered an error. Please try again."
145
  except Exception as e:
146
+ # #region agent log
147
+ from app.core._debug_log import dlog
148
+ dlog("improved_vllm_client.py:generate.unexpected", "vLLM unexpected error", {
149
+ "exc_type": type(e).__name__,
150
+ "exc_msg": str(e)[:500],
151
+ }, "E")
152
+ # #endregion
153
  logger.error(f"Unexpected error in vLLM client: {str(e)}")
154
  return "I encountered an unexpected error. Please try again."
155
 
multi_llm_chatbot_backend/app/llm/openai_fallback_client.py CHANGED
@@ -34,11 +34,36 @@ class OpenAIFallbackClient(LLMClient):
34
  self.client = AsyncOpenAI(api_key=api_key, timeout=120.0)
35
  self.context_manager = get_context_manager()
36
 
 
 
37
  def _reasoning_kwargs(self) -> Dict[str, Any]:
38
  if not self.reasoning_effort or self.reasoning_effort == "none":
39
  return {}
40
  return {"reasoning_effort": self.reasoning_effort}
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  async def generate(
43
  self,
44
  system_prompt: str,
@@ -52,13 +77,16 @@ class OpenAIFallbackClient(LLMClient):
52
  system_prompt=system_prompt,
53
  llm_provider="openai",
54
  )
 
 
55
  create_kwargs: Dict[str, Any] = dict(
56
  model=self.model,
57
- messages=context_window.messages,
58
- temperature=temperature,
59
- max_tokens=max_tokens,
60
  **self._reasoning_kwargs(),
61
  )
 
 
62
  if response_mime_type == "application/json":
63
  create_kwargs["response_format"] = {"type": "json_object"}
64
 
@@ -69,9 +97,26 @@ class OpenAIFallbackClient(LLMClient):
69
  raise ValueError("OpenAI returned empty content")
70
  return self._clean_response(text)
71
  except (APIConnectionError, APIStatusError) as exc:
 
 
 
 
 
 
 
 
 
72
  logger.error("OpenAI API error: %s", exc)
73
  raise
74
  except Exception as exc:
 
 
 
 
 
 
 
 
75
  logger.error("OpenAI generate failed: %s", exc)
76
  raise
77
 
@@ -95,14 +140,17 @@ class OpenAIFallbackClient(LLMClient):
95
 
96
  try:
97
  for _round in range(self._MAX_TOOL_ROUNDS):
98
- response = await self.client.chat.completions.create(
 
99
  model=self.model,
100
- messages=messages,
101
  tools=openai_tools or None,
102
- temperature=temperature,
103
- max_tokens=max_tokens,
104
  **self._reasoning_kwargs(),
105
  )
 
 
 
106
  choice = response.choices[0].message
107
  if not choice.tool_calls:
108
  text = choice.content or ""
 
34
  self.client = AsyncOpenAI(api_key=api_key, timeout=120.0)
35
  self.context_manager = get_context_manager()
36
 
37
+ _ALLOWED_ROLES = {"system", "assistant", "user", "function", "tool", "developer"}
38
+
39
  def _reasoning_kwargs(self) -> Dict[str, Any]:
40
  if not self.reasoning_effort or self.reasoning_effort == "none":
41
  return {}
42
  return {"reasoning_effort": self.reasoning_effort}
43
 
44
+ def _uses_completion_tokens_param(self) -> bool:
45
+ """gpt-5+ requires `max_completion_tokens`; older models use `max_tokens`."""
46
+ m = (self.model or "").lower()
47
+ return m.startswith("gpt-5") or m.startswith("o1") or m.startswith("o3")
48
+
49
+ def _normalize_messages(self, messages: List[dict]) -> List[dict]:
50
+ """Map persona-id roles (e.g. 'jerry_huaute') to 'assistant' so OpenAI
51
+ accepts them. Preserve the persona name via the optional 'name' field
52
+ when possible.
53
+ """
54
+ out: List[dict] = []
55
+ for msg in messages:
56
+ role = msg.get("role", "user")
57
+ if role in self._ALLOWED_ROLES:
58
+ out.append(msg)
59
+ continue
60
+ new_msg = dict(msg)
61
+ new_msg["role"] = "assistant"
62
+ if "name" not in new_msg and isinstance(role, str):
63
+ new_msg["name"] = role[:64]
64
+ out.append(new_msg)
65
+ return out
66
+
67
  async def generate(
68
  self,
69
  system_prompt: str,
 
77
  system_prompt=system_prompt,
78
  llm_provider="openai",
79
  )
80
+ normalized_messages = self._normalize_messages(context_window.messages)
81
+ token_kwarg = "max_completion_tokens" if self._uses_completion_tokens_param() else "max_tokens"
82
  create_kwargs: Dict[str, Any] = dict(
83
  model=self.model,
84
+ messages=normalized_messages,
85
+ **{token_kwarg: max_tokens},
 
86
  **self._reasoning_kwargs(),
87
  )
88
+ if not self._uses_completion_tokens_param():
89
+ create_kwargs["temperature"] = temperature
90
  if response_mime_type == "application/json":
91
  create_kwargs["response_format"] = {"type": "json_object"}
92
 
 
97
  raise ValueError("OpenAI returned empty content")
98
  return self._clean_response(text)
99
  except (APIConnectionError, APIStatusError) as exc:
100
+ # #region agent log
101
+ from app.core._debug_log import dlog
102
+ dlog("openai_fallback_client.py:generate.api_err", "OpenAI API error", {
103
+ "exc_type": type(exc).__name__,
104
+ "status_code": getattr(exc, "status_code", None),
105
+ "model": self.model,
106
+ "exc_msg": str(exc)[:500],
107
+ }, "B")
108
+ # #endregion
109
  logger.error("OpenAI API error: %s", exc)
110
  raise
111
  except Exception as exc:
112
+ # #region agent log
113
+ from app.core._debug_log import dlog
114
+ dlog("openai_fallback_client.py:generate.unexpected", "OpenAI generate failed", {
115
+ "exc_type": type(exc).__name__,
116
+ "model": self.model,
117
+ "exc_msg": str(exc)[:500],
118
+ }, "B")
119
+ # #endregion
120
  logger.error("OpenAI generate failed: %s", exc)
121
  raise
122
 
 
140
 
141
  try:
142
  for _round in range(self._MAX_TOOL_ROUNDS):
143
+ token_kwarg = "max_completion_tokens" if self._uses_completion_tokens_param() else "max_tokens"
144
+ tool_kwargs: Dict[str, Any] = dict(
145
  model=self.model,
146
+ messages=self._normalize_messages(messages),
147
  tools=openai_tools or None,
148
+ **{token_kwarg: max_tokens},
 
149
  **self._reasoning_kwargs(),
150
  )
151
+ if not self._uses_completion_tokens_param():
152
+ tool_kwargs["temperature"] = temperature
153
+ response = await self.client.chat.completions.create(**tool_kwargs)
154
  choice = response.choices[0].message
155
  if not choice.tool_calls:
156
  text = choice.content or ""
multi_llm_chatbot_backend/app/llm/resilient_client.py CHANGED
@@ -41,6 +41,15 @@ class ResilientLLMClient(LLMClient):
41
 
42
  async def _run_primary(self, coro_factory):
43
  result = await coro_factory(self.primary)
 
 
 
 
 
 
 
 
 
44
  if isinstance(result, str) and _looks_like_failed_response(result):
45
  raise RuntimeError(f"{self.primary_label} returned failure text")
46
  if isinstance(result, ToolCallResult) and _looks_like_failed_response(result.text):
@@ -49,49 +58,152 @@ class ResilientLLMClient(LLMClient):
49
 
50
  async def _run_fallback(self, coro_factory):
51
  logger.info("Using fallback LLM for %s", self.primary_label)
52
- return await coro_factory(self.fallback)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  async def _race_or_fallback(self, coro_factory):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  primary_task = asyncio.create_task(self._run_primary(coro_factory))
 
56
  try:
57
- return await asyncio.wait_for(
58
  asyncio.shield(primary_task),
59
  timeout=self.race_timeout_seconds,
60
  )
 
 
 
 
 
 
 
 
61
  except asyncio.TimeoutError:
62
  logger.info(
63
- "%s exceeded %.1fs — racing fallback",
64
  self.primary_label,
65
  self.race_timeout_seconds,
66
  )
 
 
 
 
 
 
 
67
  except Exception as exc:
68
- logger.warning("%s failed: %s — using fallback", self.primary_label, exc)
 
 
 
 
 
 
 
69
  if not primary_task.done():
70
  primary_task.cancel()
71
- return await self._run_fallback(coro_factory)
72
-
73
- if primary_task.done():
74
- try:
75
- return primary_task.result()
76
- except Exception as exc:
77
- logger.warning("%s failed after wait: %s", self.primary_label, exc)
78
- return await self._run_fallback(coro_factory)
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  fallback_task = asyncio.create_task(self._run_fallback(coro_factory))
81
- done, pending = await asyncio.wait(
82
- {primary_task, fallback_task},
83
- return_when=asyncio.FIRST_COMPLETED,
84
- )
85
- for task in pending:
86
- task.cancel()
87
- for task in done:
88
- if task.cancelled():
89
- continue
90
- try:
91
- return task.result()
92
- except Exception as exc:
93
- logger.warning("Race winner failed: %s", exc)
94
- return await self._run_fallback(coro_factory)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  async def generate(
97
  self,
 
41
 
42
  async def _run_primary(self, coro_factory):
43
  result = await coro_factory(self.primary)
44
+ # #region agent log
45
+ from app.core._debug_log import dlog
46
+ preview = (result if isinstance(result, str) else getattr(result, "text", ""))[:200]
47
+ dlog("resilient_client.py:_run_primary", "primary returned", {
48
+ "label": self.primary_label,
49
+ "result_type": type(result).__name__,
50
+ "preview": preview,
51
+ }, "E")
52
+ # #endregion
53
  if isinstance(result, str) and _looks_like_failed_response(result):
54
  raise RuntimeError(f"{self.primary_label} returned failure text")
55
  if isinstance(result, ToolCallResult) and _looks_like_failed_response(result.text):
 
58
 
59
  async def _run_fallback(self, coro_factory):
60
  logger.info("Using fallback LLM for %s", self.primary_label)
61
+ # #region agent log
62
+ from app.core._debug_log import dlog
63
+ dlog("resilient_client.py:_run_fallback.start", "invoking fallback", {
64
+ "label": self.primary_label,
65
+ }, "B")
66
+ # #endregion
67
+ try:
68
+ result = await coro_factory(self.fallback)
69
+ # #region agent log
70
+ preview = (result if isinstance(result, str) else getattr(result, "text", ""))[:200]
71
+ dlog("resilient_client.py:_run_fallback.ok", "fallback returned", {
72
+ "label": self.primary_label,
73
+ "preview": preview,
74
+ }, "B")
75
+ # #endregion
76
+ return result
77
+ except Exception as fe:
78
+ # #region agent log
79
+ dlog("resilient_client.py:_run_fallback.error", "fallback raised", {
80
+ "label": self.primary_label,
81
+ "exc_type": type(fe).__name__,
82
+ "exc_msg": str(fe)[:500],
83
+ }, "B")
84
+ # #endregion
85
+ raise
86
 
87
  async def _race_or_fallback(self, coro_factory):
88
+ """Spec:
89
+ - Start *primary* immediately.
90
+ - If primary returns successfully → use it.
91
+ - If primary fails (raises, returns recognised failure text) → run
92
+ *fallback* sequentially and return its result.
93
+ - If primary takes longer than ``race_timeout_seconds`` without
94
+ completing → start *fallback* in parallel and return whichever
95
+ finishes first (primary or fallback).
96
+ """
97
+ # #region agent log
98
+ from app.core._debug_log import dlog
99
+ dlog("resilient_client.py:_race_or_fallback.enter", "starting primary", {
100
+ "label": self.primary_label,
101
+ "timeout_s": self.race_timeout_seconds,
102
+ }, "B")
103
+ # #endregion
104
+
105
  primary_task = asyncio.create_task(self._run_primary(coro_factory))
106
+
107
  try:
108
+ result = await asyncio.wait_for(
109
  asyncio.shield(primary_task),
110
  timeout=self.race_timeout_seconds,
111
  )
112
+ # #region agent log
113
+ preview = (result if isinstance(result, str) else getattr(result, "text", ""))[:200]
114
+ dlog("resilient_client.py:_race_or_fallback.primary_ok", "primary succeeded", {
115
+ "label": self.primary_label,
116
+ "preview": preview,
117
+ }, "B")
118
+ # #endregion
119
+ return result
120
  except asyncio.TimeoutError:
121
  logger.info(
122
+ "%s slower than %.1fs — racing fallback in parallel",
123
  self.primary_label,
124
  self.race_timeout_seconds,
125
  )
126
+ # #region agent log
127
+ dlog("resilient_client.py:_race_or_fallback.timeout_race", "racing fallback in parallel", {
128
+ "label": self.primary_label,
129
+ "timeout_s": self.race_timeout_seconds,
130
+ }, "B")
131
+ # #endregion
132
+ return await self._race_first_successful(primary_task, coro_factory)
133
  except Exception as exc:
134
+ logger.warning("%s failed: %s — using fallback sequentially", self.primary_label, exc)
135
+ # #region agent log
136
+ dlog("resilient_client.py:_race_or_fallback.primary_fail", "primary raised", {
137
+ "label": self.primary_label,
138
+ "exc_type": type(exc).__name__,
139
+ "exc_msg": str(exc)[:500],
140
+ }, "B")
141
+ # #endregion
142
  if not primary_task.done():
143
  primary_task.cancel()
 
 
 
 
 
 
 
 
144
 
145
+ try:
146
+ return await self._run_fallback(coro_factory)
147
+ except Exception as fb_exc:
148
+ logger.error(
149
+ "Both primary (%s) and fallback failed for this request: %s",
150
+ self.primary_label, fb_exc,
151
+ )
152
+ raise
153
+
154
+ async def _race_first_successful(self, primary_task: asyncio.Task, coro_factory):
155
+ """Primary is still running past the soft timeout. Start fallback in
156
+ parallel and return whichever completes successfully first. If the
157
+ first one to complete failed, await the other; if both fail, raise.
158
+ """
159
+ # #region agent log
160
+ from app.core._debug_log import dlog
161
+ # #endregion
162
  fallback_task = asyncio.create_task(self._run_fallback(coro_factory))
163
+ pending = {primary_task, fallback_task}
164
+
165
+ winner_text: str | None = None
166
+ winner_obj: Any = None
167
+ last_exc: Exception | None = None
168
+
169
+ while pending:
170
+ done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
171
+ for task in done:
172
+ which = "primary" if task is primary_task else "fallback"
173
+ try:
174
+ result = task.result()
175
+ except Exception as exc:
176
+ last_exc = exc
177
+ # #region agent log
178
+ dlog("resilient_client.py:_race_first_successful.loser", "task failed", {
179
+ "which": which,
180
+ "exc_type": type(exc).__name__,
181
+ "exc_msg": str(exc)[:300],
182
+ }, "B")
183
+ # #endregion
184
+ continue
185
+ # #region agent log
186
+ preview = (result if isinstance(result, str) else getattr(result, "text", ""))[:200]
187
+ dlog("resilient_client.py:_race_first_successful.winner", "task succeeded", {
188
+ "which": which,
189
+ "preview": preview,
190
+ }, "B")
191
+ # #endregion
192
+ winner_obj = result
193
+ winner_text = preview
194
+ break
195
+ if winner_obj is not None:
196
+ break
197
+
198
+ for t in pending:
199
+ if not t.done():
200
+ t.cancel()
201
+
202
+ if winner_obj is not None:
203
+ return winner_obj
204
+ if last_exc is not None:
205
+ raise last_exc
206
+ raise RuntimeError("race produced no result and no exception")
207
 
208
  async def generate(
209
  self,
phd-advisor-frontend/public/index.html CHANGED
@@ -7,7 +7,7 @@
7
  <meta name="theme-color" content="#000000" />
8
  <meta
9
  name="description"
10
- content="AI Advisor Panel — AI-Powered Guidance from Multiple Experts"
11
  />
12
  <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
13
  <!--
@@ -24,7 +24,7 @@
24
  work correctly both with client-side routing and a non-root public URL.
25
  Learn how to configure a non-root public URL by running `npm run build`.
26
  -->
27
- <title>React App</title>
28
  </head>
29
  <body>
30
  <noscript>You need to enable JavaScript to run this app.</noscript>
 
7
  <meta name="theme-color" content="#000000" />
8
  <meta
9
  name="description"
10
+ content="Cybersecurity Advisor — AI-powered security guidance from expert advisors"
11
  />
12
  <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
13
  <!--
 
24
  work correctly both with client-side routing and a non-root public URL.
25
  Learn how to configure a non-root public URL by running `npm run build`.
26
  -->
27
+ <title>Cybersecurity Advisor</title>
28
  </head>
29
  <body>
30
  <noscript>You need to enable JavaScript to run this app.</noscript>
phd-advisor-frontend/public/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
- "short_name": "AI Advisor Panel",
3
- "name": "Neon AI Panel of Experts",
4
  "icons": [
5
  {
6
  "src": "favicon.ico",
 
1
  {
2
+ "short_name": "Cybersecurity Advisor",
3
+ "name": "Cybersecurity Advisor",
4
  "icons": [
5
  {
6
  "src": "favicon.ico",
phd-advisor-frontend/src/data/userGuide.js CHANGED
@@ -1,4 +1,4 @@
1
- // User Guide content for Cybersecurity Advisor Canvas.
2
  // Use {{appName}} as a placeholder — replaced at render time.
3
 
4
  export const userGuideTopics = [
 
1
+ // User Guide content for Cybersecurity Advisor.
2
  // Use {{appName}} as a placeholder — replaced at render time.
3
 
4
  export const userGuideTopics = [
phd-advisor-frontend/src/styles/CanvasPage.css CHANGED
@@ -157,23 +157,19 @@
157
  position: sticky;
158
  top: 0;
159
  z-index: 100;
160
- background: rgba(31, 41, 55, 0.95);
161
  backdrop-filter: blur(20px);
162
  -webkit-backdrop-filter: blur(20px);
163
- border-bottom: 1px solid var(--canvas-border);
164
  padding: 12px 24px;
165
  display: grid;
166
  grid-template-columns: 1fr auto 1fr;
167
  align-items: center;
168
  gap: 16px;
169
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
170
  flex-shrink: 0;
171
  }
172
  .canvas-page-with-sidebar .floating-header .header-right { justify-self: end; }
173
- .canvas-page-with-sidebar[data-canvas-theme="light"] .floating-header {
174
- background: rgba(255, 255, 255, 0.95);
175
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
176
- }
177
 
178
  .canvas-page-with-sidebar .header-left {
179
  display: flex;
 
157
  position: sticky;
158
  top: 0;
159
  z-index: 100;
160
+ background: var(--header-bg);
161
  backdrop-filter: blur(20px);
162
  -webkit-backdrop-filter: blur(20px);
163
+ border-bottom: 1px solid var(--header-border);
164
  padding: 12px 24px;
165
  display: grid;
166
  grid-template-columns: 1fr auto 1fr;
167
  align-items: center;
168
  gap: 16px;
169
+ box-shadow: var(--header-shadow);
170
  flex-shrink: 0;
171
  }
172
  .canvas-page-with-sidebar .floating-header .header-right { justify-self: end; }
 
 
 
 
173
 
174
  .canvas-page-with-sidebar .header-left {
175
  display: flex;
phd-advisor-frontend/src/styles/ChatPage.css CHANGED
@@ -18,23 +18,18 @@
18
  position: sticky;
19
  top: 0;
20
  z-index: 100;
21
- background: rgba(255, 255, 255, 0.95);
22
  backdrop-filter: blur(20px);
23
  -webkit-backdrop-filter: blur(20px);
24
- border-bottom: 1px solid var(--border-primary);
25
  padding: 12px 24px;
26
  display: flex;
27
  align-items: center;
28
  justify-content: space-between;
29
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
30
  flex-shrink: 0;
31
  }
32
 
33
- [data-theme="dark"] .floating-header {
34
- background: rgba(31, 41, 55, 0.95);
35
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
36
- }
37
-
38
  .header-left {
39
  display: flex;
40
  align-items: center;
 
18
  position: sticky;
19
  top: 0;
20
  z-index: 100;
21
+ background: var(--header-bg);
22
  backdrop-filter: blur(20px);
23
  -webkit-backdrop-filter: blur(20px);
24
+ border-bottom: 1px solid var(--header-border);
25
  padding: 12px 24px;
26
  display: flex;
27
  align-items: center;
28
  justify-content: space-between;
29
+ box-shadow: var(--header-shadow);
30
  flex-shrink: 0;
31
  }
32
 
 
 
 
 
 
33
  .header-left {
34
  display: flex;
35
  align-items: center;
phd-advisor-frontend/src/styles/components.css CHANGED
@@ -42,6 +42,11 @@
42
  --input-border: #D1D5DB;
43
  --input-focus: #6366F1;
44
  --input-focus-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
 
 
 
 
 
45
  }
46
 
47
  :root[data-theme="dark"] {
@@ -87,6 +92,11 @@
87
  --input-border: #4B5563;
88
  --input-focus: #818CF8;
89
  --input-focus-shadow: 0 0 0 3px rgba(129, 140, 248, 0.2);
 
 
 
 
 
90
  }
91
 
92
  /* Theme Toggle Button */
 
42
  --input-border: #D1D5DB;
43
  --input-focus: #6366F1;
44
  --input-focus-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
45
+
46
+ /* App header (floating bar) */
47
+ --header-bg: #EBF5FF;
48
+ --header-border: rgba(59, 130, 246, 0.2);
49
+ --header-shadow: 0 4px 20px rgba(37, 99, 235, 0.1);
50
  }
51
 
52
  :root[data-theme="dark"] {
 
92
  --input-border: #4B5563;
93
  --input-focus: #818CF8;
94
  --input-focus-shadow: 0 0 0 3px rgba(129, 140, 248, 0.2);
95
+
96
+ /* App header (floating bar) */
97
+ --header-bg: rgba(23, 37, 64, 0.97);
98
+ --header-border: rgba(96, 165, 250, 0.18);
99
+ --header-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
100
  }
101
 
102
  /* Theme Toggle Button */
scripts/patch_canvas_insights.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ p = Path(r"C:\Users\dream\CCAI-Demo-Canvas-Upgrades\phd-advisor-frontend\src\components\canvas\canvasData.js")
4
+ c = p.read_text(encoding="utf-8")
5
+
6
+ start = c.index("export const INSIGHTS = [")
7
+ end = c.index("export const WIDGET_CATALOG")
8
+
9
+ insights = r'''export const INSIGHTS = [
10
+ {
11
+ id: 'i-progress',
12
+ title: 'Program progress',
13
+ icon: 'graph',
14
+ category: 'progress',
15
+ confidence: 82,
16
+ summary: 'Zero Trust Phase 2 is 78% complete. MFA enforced for workforce; service accounts and legacy VPN exceptions remain the main gaps before audit sampling.',
17
+ bullets: [
18
+ 'Identity: <strong>MFA 94%</strong> workforce · service accounts in remediation',
19
+ 'Network: micro-segmentation pilot on <strong>3 app tiers</strong>',
20
+ '<strong>Risk:</strong> 12 VPN exceptions still lack compensating controls',
21
+ ],
22
+ pinned: true,
23
+ sources: 18,
24
+ updatedMinutesAgo: 5,
25
+ quotes: [
26
+ '"MFA rollout blocked on two legacy HR integrations." — IAM workstream notes',
27
+ '"Auditors will sample VPN exception register first." — GRC advisor chat',
28
+ ],
29
+ },
30
+ {
31
+ id: 'i-method',
32
+ title: 'Controls posture',
33
+ icon: 'flask',
34
+ category: 'theory',
35
+ confidence: 71,
36
+ summary: 'SOC 2 CC6/CC7 mappings are drafted. Detection use cases cover ransomware and cred theft; log retention and IR tabletop evidence are still thin.',
37
+ bullets: [
38
+ 'Mapped: <strong>CC6.1–CC6.7</strong> access controls with Okta + AWS',
39
+ 'Open: centralized logging retention proof for <strong>365 days</strong>',
40
+ 'Open: tabletop scenario for <strong>ransomware + exfil</strong> not yet run',
41
+ ],
42
+ sources: 14,
43
+ updatedMinutesAgo: 14,
44
+ quotes: [
45
+ '"Need SIEM retention screenshots before fieldwork." — compliance advisor',
46
+ '"Tabletop scheduled but not executed." — IR lead notes',
47
+ ],
48
+ },
49
+ {
50
+ id: 'i-lit',
51
+ title: 'Threat landscape',
52
+ icon: 'book',
53
+ category: 'literature',
54
+ confidence: 76,
55
+ summary: 'Strong coverage of identity attacks, SaaS misconfigurations, and supply-chain risks for your stack. Weaker on OT exposure and insider threat playbooks.',
56
+ bullets: [
57
+ '<strong>Coverage:</strong> MITRE techniques for cloud identity & SaaS',
58
+ '<strong>Gap:</strong> limited intel on <strong>OAuth consent phishing</strong> variants',
59
+ '<strong>Gap:</strong> no formal insider-threat escalation path documented',
60
+ ],
61
+ sources: 32,
62
+ updatedMinutesAgo: 28,
63
+ quotes: [
64
+ '"OAuth abuse is the fastest-moving thread in your sector." — threat intel advisor',
65
+ '"Insider playbook is a one-pager — not enough for audit." — GRC advisor',
66
+ ],
67
+ },
68
+ {
69
+ id: 'i-questions',
70
+ title: 'Open security questions',
71
+ icon: 'sparkles',
72
+ category: 'theory',
73
+ confidence: 63,
74
+ summary: 'Three live threads. Q1 (scope of zero trust for contractors) gates architecture sign-off. Q2–Q3 affect detection engineering priorities.',
75
+ bullets: [
76
+ '<strong>Q1:</strong> Do contractors get full ZTNA or bastion-only access?',
77
+ '<strong>Q2:</strong> Which SIEM detections are in-scope for SOC 2 evidence?',
78
+ '<strong>Q3:</strong> Is customer data in EU regions in scope for DPA addendum?',
79
+ ],
80
+ sources: 9,
81
+ updatedMinutesAgo: 41,
82
+ quotes: [
83
+ '"Contractor access model blocks network design." — architect advisor',
84
+ '"EU data residency may expand audit scope." — privacy advisor',
85
+ ],
86
+ },
87
+ {
88
+ id: 'i-next',
89
+ title: 'Next steps',
90
+ icon: 'arrow',
91
+ category: 'action',
92
+ confidence: 85,
93
+ summary: 'Near-term actions tied to audit date and production cutover. Two items have slipped one sprint.',
94
+ bullets: [
95
+ 'Close <strong>12 VPN exceptions</strong> or document compensating controls',
96
+ 'Run ransomware tabletop & upload minutes to evidence locker',
97
+ 'Ship <strong>5 high-fidelity detections</strong> to production SIEM',
98
+ 'Finalize vendor SOC 2 bridge letter for subprocessors',
99
+ ],
100
+ sources: 7,
101
+ updatedMinutesAgo: 9,
102
+ quotes: [
103
+ '"VPN exceptions are the #1 audit finding risk." — GRC advisor',
104
+ '"Detections without tuning will false-positive in week one." — SOC advisor',
105
+ ],
106
+ },
107
+ {
108
+ id: 'i-blockers',
109
+ title: 'Blockers & risks',
110
+ icon: 'alert',
111
+ category: 'risk',
112
+ confidence: 74,
113
+ summary: 'One technical blocker (legacy logging), one governance blocker (exception approvals). Governance is the higher audit risk.',
114
+ bullets: [
115
+ '<strong>Technical:</strong> legacy app logs not reaching SIEM — 18% of prod traffic',
116
+ '<strong>Governance:</strong> exception approval SLA &gt; 10 days — auditors will flag',
117
+ ],
118
+ sources: 6,
119
+ updatedMinutesAgo: 20,
120
+ quotes: [
121
+ '"Without those logs you cannot prove detective controls." — detection engineer',
122
+ '"Exception backlog reads as control failure." — devil\'s advocate advisor',
123
+ ],
124
+ },
125
+ ];
126
+
127
+ '''
128
+
129
+ p.write_text(c[:start] + insights + c[end:], encoding="utf-8")
130
+ print("insights updated")
scripts/patch_chatpage_end.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ p = Path(r"C:\Users\dream\CCAI-Demo-Canvas-Upgrades\phd-advisor-frontend\src\pages\ChatPage.js")
4
+ c = p.read_text(encoding="utf-8")
5
+
6
+ close_div = "</" + "div>"
7
+
8
+ needle = f""" placeholder={{
9
+ replyingTo
10
+ ? `Reply to ${{replyingTo.advisorName}}...`
11
+ : chatPlaceholder
12
+ }}
13
+ />
14
+ {close_div}
15
+ {close_div}
16
+ {close_div}
17
+ {close_div}
18
+ );
19
+ }};
20
+
21
+ export default ChatPage;
22
+ """
23
+
24
+ replacement = f""" placeholder={{
25
+ replyingTo
26
+ ? `Reply to ${{replyingTo.advisorName}}...`
27
+ : chatPlaceholder
28
+ }}
29
+ showProfileButtons={{!userProfile || userProfile.completion_pct < 100}}
30
+ onOpenOnboarding={{() => setShowOnboarding(true)}}
31
+ onOpenProfileForm={{() => setShowProfileForm(true)}}
32
+ />
33
+ {close_div}
34
+ {close_div}
35
+ {close_div}
36
+
37
+ {{showOnboarding && (
38
+ <OnboardingChat
39
+ authToken={{authToken}}
40
+ userName={{user?.firstName}}
41
+ onClose={{() => {{ setShowOnboarding(false); loadProfile(); }}}}
42
+ />
43
+ )}}
44
+
45
+ {{showProfileForm && (
46
+ <ProfileWalkthrough
47
+ authToken={{authToken}}
48
+ existingProfile={{userProfile}}
49
+ onClose={{() => {{ setShowProfileForm(false); loadProfile(); }}}}
50
+ />
51
+ )}}
52
+
53
+ {{showClearData && (
54
+ <ClearDataModal
55
+ authToken={{authToken}}
56
+ onClose={{() => setShowClearData(false)}}
57
+ onDataCleared={{({{ profile: clearedProfile, chats: clearedChats }}) => {{
58
+ if (clearedProfile) {{
59
+ setUserProfile(null);
60
+ loadProfile();
61
+ }}
62
+ if (clearedChats) {{
63
+ setMessages([]);
64
+ setCurrentSessionId(null);
65
+ setCurrentSessionTitle('');
66
+ handleNewChat();
67
+ }}
68
+ }}}}
69
+ />
70
+ )}}
71
+
72
+ {{showAccount && (
73
+ <AccountModal
74
+ user={{user}}
75
+ authToken={{authToken}}
76
+ onClose={{() => setShowAccount(false)}}
77
+ onAccountUpdated={{(updated) => {{
78
+ if (user) {{
79
+ user.firstName = updated.firstName;
80
+ user.lastName = updated.lastName;
81
+ user.email = updated.email;
82
+ }}
83
+ }}}}
84
+ onAccountDeleted={{() => {{
85
+ setShowAccount(false);
86
+ onSignOut();
87
+ }}}}
88
+ />
89
+ )}}
90
+ {close_div}
91
+ );
92
+ }};
93
+
94
+ export default ChatPage;
95
+ """
96
+
97
+ if needle not in c:
98
+ raise SystemExit("needle not found in ChatPage.js")
99
+
100
+ p.write_text(c.replace(needle, replacement, 1), encoding="utf-8")
101
+ print("ok")
scripts/patch_message_bubble_avatar.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ p = Path(r"C:\Users\dream\CCAI-Demo-Canvas-Upgrades\phd-advisor-frontend\src\components\MessageBubble.js")
4
+ c = p.read_text(encoding="utf-8")
5
+
6
+ start = c.index(" const avatarElement = (size = 40)")
7
+ end = c.index(" return (", start)
8
+ new = """ const avatarElement = (size = 44) => {
9
+ const iconSize = Math.round(size * 0.52);
10
+ return (
11
+ <div
12
+ className="advisor-message-avatar-ring"
13
+ style={{ width: size, height: size }}
14
+ >
15
+ {advisor.avatarUrl ? (
16
+ <img
17
+ src={advisor.avatarUrl}
18
+ alt={advisor.name || 'Advisor'}
19
+ />
20
+ ) : Icon ? (
21
+ <Icon
22
+ className="advisor-message-avatar-icon"
23
+ style={{
24
+ color: colors.color || 'var(--text-secondary)',
25
+ width: iconSize,
26
+ height: iconSize,
27
+ }}
28
+ />
29
+ ) : (
30
+ <span
31
+ className="advisor-message-avatar-initial"
32
+ style={{ color: colors.color || 'var(--text-secondary)', fontSize: iconSize }}
33
+ >
34
+ {advisor.name ? advisor.name.charAt(0) : 'A'}
35
+ </span>
36
+ )}
37
+ </motion.div>
38
+ );
39
+ };
40
+
41
+ """
42
+ new = new.replace("</motion.div>", "</" + "div>")
43
+
44
+ c = c[:start] + new + c[end:]
45
+ c = c.replace("{inlineAvatar && avatarElement(32)}", "{inlineAvatar && avatarElement(44)}", 1)
46
+ p.write_text(c, encoding="utf-8")
47
+ print("ok")
scripts/patch_sidebar.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ p = Path(r"C:\Users\dream\CCAI-Demo-Canvas-Upgrades\phd-advisor-frontend\src\components\Sidebar.js")
4
+ c = p.read_text(encoding="utf-8")
5
+
6
+ avatar_old = (
7
+ ' <div className="user-avatar">\n'
8
+ ' <User size={20} />\n'
9
+ ' </div>'
10
+ )
11
+ avatar_new = (
12
+ ' <motion.div\n'
13
+ ' className="user-avatar"\n'
14
+ ' onClick={() => onAvatarChange && setShowAvatarPicker(true)}\n'
15
+ ' style={{\n'
16
+ " cursor: onAvatarChange ? 'pointer' : undefined,\n"
17
+ ' backgroundColor: currentAvatar?.bg || undefined,\n'
18
+ ' color: currentAvatar?.color || undefined,\n'
19
+ ' }}\n'
20
+ " title={onAvatarChange ? 'Change avatar' : undefined}\n"
21
+ ' >\n'
22
+ ' <AvatarIcon size={20} />\n'
23
+ ' </div>'
24
+ )
25
+ avatar_new = avatar_new.replace("<motion.div", "<div").replace("motion.div\n", "div\n")
26
+
27
+ menu_old = (
28
+ ' {showUserMenu && (\n'
29
+ ' <div className="user-menu">\n'
30
+ ' <button className="user-menu-item">\n'
31
+ ' <Settings size={16} />\n'
32
+ ' <span>Settings</span>\n'
33
+ ' </button>\n'
34
+ ' <button className="user-menu-item sign-out" onClick={onSignOut}>\n'
35
+ ' <LogOut size={16} />\n'
36
+ ' <span>Sign Out</span>\n'
37
+ ' </button>\n'
38
+ ' </div>\n'
39
+ ' )}'
40
+ )
41
+ menu_new = (
42
+ ' {showUserMenu && (\n'
43
+ ' <div className="user-menu">\n'
44
+ ' <button className="user-menu-item" onClick={() => { setShowUserMenu(false); setShowAvatarPicker(true); }}>\n'
45
+ ' <User size={16} />\n'
46
+ ' <span>Change Avatar</span>\n'
47
+ ' </button>\n'
48
+ ' <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenProfile) onOpenProfile(); }}>\n'
49
+ ' <UserCircle size={16} />\n'
50
+ ' <span>Profile</span>\n'
51
+ ' </button>\n'
52
+ ' <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenAccount) onOpenAccount(); }}>\n'
53
+ ' <KeyRound size={16} />\n'
54
+ ' <span>Account</span>\n'
55
+ ' </button>\n'
56
+ ' <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenClearData) onOpenClearData(); }}>\n'
57
+ ' <DatabaseZap size={16} />\n'
58
+ ' <span>Clear User Data</span>\n'
59
+ ' </button>\n'
60
+ ' <button className="user-menu-item sign-out" onClick={onSignOut}>\n'
61
+ ' <LogOut size={16} />\n'
62
+ ' <span>Sign Out</span>\n'
63
+ ' </button>\n'
64
+ ' </div>\n'
65
+ ' )}'
66
+ )
67
+
68
+ if avatar_old not in c:
69
+ raise SystemExit("avatar block not found")
70
+ c = c.replace(avatar_old, avatar_new, 1)
71
+ if menu_old not in c:
72
+ raise SystemExit("menu block not found")
73
+ c = c.replace(menu_old, menu_new, 1)
74
+
75
+ picker = """
76
+ {showAvatarPicker && (
77
+ <UserAvatarPicker
78
+ options={avatarOptions}
79
+ currentId={userAvatarId}
80
+ onSelect={(id) => { onAvatarChange?.(id); setShowAvatarPicker(false); }}
81
+ onClose={() => setShowAvatarPicker(false)}
82
+ />
83
+ )}
84
+ """
85
+
86
+ if "UserAvatarPicker" not in c.split("export default")[0].split("return (")[-1]:
87
+ c = c.replace(
88
+ " {isMobileOpen && (",
89
+ picker + "\n {isMobileOpen && (",
90
+ 1,
91
+ )
92
+
93
+ p.write_text(c, encoding="utf-8")
94
+ print("Sidebar patched")