Skip to content

Commit d660d66

Browse files
authored
feat(bot-thinking): improve event logging and add bot thinking display (#1434)
add BotThinking event support
1 parent 59d1a6d commit d660d66

File tree

4 files changed

+192
-12
lines changed

4 files changed

+192
-12
lines changed

nemoguardrails/colang/v1_0/runtime/runtime.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,6 @@ async def generate_events(
159159

160160
log.info("Processing event: %s", last_event)
161161

162-
event_type = last_event["type"]
163-
log.info(
164-
"Event :: %s %s",
165-
event_type,
166-
str({k: v for k, v in last_event.items() if k != "type"}),
167-
)
168-
169162
# If we need to execute an action, we start doing that.
170163
if last_event["type"] == "StartInternalSystemAction":
171164
next_events = await self._process_start_action(events)
@@ -186,16 +179,23 @@ async def generate_events(
186179
if len(next_events) == 0:
187180
next_events = [new_event_dict("Listen")]
188181

189-
# Otherwise, we append the event and continue the processing.
190-
events.extend(next_events)
191-
new_events.extend(next_events)
192-
182+
# Log all generated events and add them to processing log
193183
for event in next_events:
194184
if event["type"] != "EventHistoryUpdate":
185+
event_type = event["type"]
186+
log.info(
187+
"Event :: %s %s",
188+
event_type,
189+
str({k: v for k, v in event.items() if k != "type"}),
190+
)
195191
processing_log.append(
196192
{"type": "event", "timestamp": time(), "data": event}
197193
)
198194

195+
# Append events to the event stream and new_events list
196+
events.extend(next_events)
197+
new_events.extend(next_events)
198+
199199
# If the next event is a listen, we stop the processing.
200200
if next_events[-1]["type"] == "Listen":
201201
break

nemoguardrails/logging/callbacks.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,27 @@ async def on_llm_end(
163163
llm_call_info = LLMCallInfo()
164164
llm_call_info.completion = response.generations[0][0].text
165165
llm_call_info.finished_at = time()
166+
167+
completion_text = response.generations[0][0].text
168+
reasoning_content = None
169+
170+
if isinstance(response.generations[0][0], ChatGeneration):
171+
chat_gen = response.generations[0][0]
172+
if hasattr(chat_gen, "message") and hasattr(
173+
chat_gen.message, "additional_kwargs"
174+
):
175+
reasoning_content = chat_gen.message.additional_kwargs.get(
176+
"reasoning_content"
177+
)
178+
179+
if reasoning_content:
180+
full_completion = f"{reasoning_content}\n---\n{completion_text}"
181+
else:
182+
full_completion = completion_text
183+
166184
log.info(
167185
"Completion :: %s",
168-
response.generations[0][0].text,
186+
full_completion,
169187
extra={"id": llm_call_info.id, "task": llm_call_info.task},
170188
)
171189

nemoguardrails/logging/verbose.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ def emit(self, record) -> None:
129129
title = f"[magenta]{event_type[:-8]}[bold]Finished[/][/]"
130130
elif event_type.endswith("ActionFailed"):
131131
title = f"[magenta]{event_type[:-6]}[bold]Failed[/][/]"
132+
elif event_type == "BotThinking":
133+
title = f"[yellow bold]{event_type}[/]"
132134
else:
133135
title = event_type
134136
else:
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2023 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import logging
17+
from unittest.mock import patch
18+
19+
import pytest
20+
21+
from nemoguardrails import RailsConfig
22+
from tests.utils import TestChat
23+
24+
25+
@pytest.mark.asyncio
26+
async def test_bot_thinking_event_logged_in_runtime(caplog):
27+
test_reasoning_trace = "Let me think about this step by step..."
28+
29+
caplog.set_level(logging.INFO)
30+
31+
with patch(
32+
"nemoguardrails.actions.llm.generation.get_and_clear_reasoning_trace_contextvar"
33+
) as mock_get_reasoning:
34+
mock_get_reasoning.return_value = test_reasoning_trace
35+
36+
config = RailsConfig.from_content(config={"models": [], "passthrough": True})
37+
chat = TestChat(config, llm_completions=["The answer is 42"])
38+
39+
await chat.app.generate_events_async(
40+
[{"type": "UserMessage", "text": "What is the answer?"}]
41+
)
42+
43+
bot_thinking_logs = [
44+
record
45+
for record in caplog.records
46+
if "Event :: BotThinking" in record.message
47+
]
48+
assert len(bot_thinking_logs) >= 1
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_bot_message_event_logged_in_runtime(caplog):
53+
caplog.set_level(logging.INFO)
54+
55+
config = RailsConfig.from_content(config={"models": [], "passthrough": True})
56+
chat = TestChat(config, llm_completions=["The answer is 42"])
57+
58+
await chat.app.generate_events_async(
59+
[{"type": "UserMessage", "text": "What is the answer?"}]
60+
)
61+
62+
bot_message_logs = [
63+
record for record in caplog.records if "Event :: BotMessage" in record.message
64+
]
65+
assert len(bot_message_logs) >= 1
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_context_update_event_logged_in_runtime(caplog):
70+
caplog.set_level(logging.INFO)
71+
72+
config = RailsConfig.from_content(config={"models": [], "passthrough": True})
73+
chat = TestChat(config, llm_completions=["Response"])
74+
75+
await chat.app.generate_events_async([{"type": "UserMessage", "text": "Hello"}])
76+
77+
context_update_logs = [
78+
record
79+
for record in caplog.records
80+
if "Event :: ContextUpdate" in record.message
81+
or "Event ContextUpdate" in record.message
82+
]
83+
assert len(context_update_logs) >= 1
84+
85+
86+
@pytest.mark.asyncio
87+
async def test_all_events_logged_when_multiple_events_generated(caplog):
88+
test_reasoning_trace = "Analyzing..."
89+
90+
caplog.set_level(logging.INFO)
91+
92+
with patch(
93+
"nemoguardrails.actions.llm.generation.get_and_clear_reasoning_trace_contextvar"
94+
) as mock_get_reasoning:
95+
mock_get_reasoning.return_value = test_reasoning_trace
96+
97+
config = RailsConfig.from_content(config={"models": [], "passthrough": True})
98+
chat = TestChat(config, llm_completions=["Response"])
99+
100+
await chat.app.generate_events_async([{"type": "UserMessage", "text": "Test"}])
101+
102+
bot_thinking_found = any(
103+
"Event :: BotThinking" in record.message for record in caplog.records
104+
)
105+
bot_message_found = any(
106+
"Event :: BotMessage" in record.message for record in caplog.records
107+
)
108+
109+
assert bot_thinking_found
110+
assert bot_message_found
111+
112+
113+
@pytest.mark.asyncio
114+
async def test_bot_thinking_event_logged_before_bot_message(caplog):
115+
test_reasoning_trace = "Step 1: Understand\nStep 2: Respond"
116+
117+
caplog.set_level(logging.INFO)
118+
119+
with patch(
120+
"nemoguardrails.actions.llm.generation.get_and_clear_reasoning_trace_contextvar"
121+
) as mock_get_reasoning:
122+
mock_get_reasoning.return_value = test_reasoning_trace
123+
124+
config = RailsConfig.from_content(config={"models": [], "passthrough": True})
125+
chat = TestChat(config, llm_completions=["Answer"])
126+
127+
await chat.app.generate_events_async(
128+
[{"type": "UserMessage", "text": "Question"}]
129+
)
130+
131+
bot_thinking_idx = None
132+
bot_message_idx = None
133+
134+
for idx, record in enumerate(caplog.records):
135+
if "Event :: BotThinking" in record.message and bot_thinking_idx is None:
136+
bot_thinking_idx = idx
137+
if "Event :: BotMessage" in record.message and bot_message_idx is None:
138+
bot_message_idx = idx
139+
140+
assert bot_thinking_idx is not None
141+
assert bot_message_idx is not None
142+
assert bot_thinking_idx < bot_message_idx
143+
144+
145+
@pytest.mark.asyncio
146+
async def test_event_history_update_not_logged(caplog):
147+
caplog.set_level(logging.INFO)
148+
149+
config = RailsConfig.from_content(config={"models": [], "passthrough": True})
150+
chat = TestChat(config, llm_completions=["Response"])
151+
152+
await chat.app.generate_events_async([{"type": "UserMessage", "text": "Test"}])
153+
154+
event_history_update_logs = [
155+
record
156+
for record in caplog.records
157+
if "Event :: EventHistoryUpdate" in record.message
158+
or "Event EventHistoryUpdate" in record.message
159+
]
160+
assert len(event_history_update_logs) == 0

0 commit comments

Comments
 (0)