AbenzaFran commited on
Commit
96157a7
·
1 Parent(s): b02dba2
Files changed (3) hide show
  1. .gitignore +1 -0
  2. app.py +267 -197
  3. requirements.txt +59 -4
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ .env
app.py CHANGED
@@ -1,244 +1,314 @@
1
  import os
2
  import re
3
- import streamlit as st
4
- from dotenv import load_dotenv
5
-
6
  import io
7
  import time
8
  import json
9
  import queue
10
  import logging
11
- from PIL import Image
 
12
 
13
- # ------------------------
14
- # LangSmith imports
15
- # ------------------------
16
  import openai
17
  from langsmith.wrappers import wrap_openai
18
  from langsmith import traceable
19
 
20
  # ------------------------
21
- # Configure logging (optional but recommended)
22
  # ------------------------
23
- def init_logging():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  logging.basicConfig(
25
  format="[%(asctime)s] %(levelname)+8s: %(message)s",
26
  level=logging.INFO,
27
  )
28
- return logging.getLogger()
29
 
30
- logger = init_logging()
31
 
32
  # ------------------------
33
- # Load environment variables
34
  # ------------------------
35
- load_dotenv()
36
- api_key = os.getenv("OPENAI_API_KEY")
37
- assistant_id = os.getenv("ASSISTANT_ID_SOLUTION_SPECIFIER_A") # The assistant we want to call
38
-
39
- if not api_key or not assistant_id:
40
- raise RuntimeError("Please set OPENAI_API_KEY and ASSISTANT_ID_SOLUTION_SPECIFIER_A in your environment")
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  # ------------------------
43
- # Wrap the OpenAI client for LangSmith traceability
44
  # ------------------------
45
- openai_client = openai.Client(api_key=api_key)
46
- client = wrap_openai(openai_client)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
  # ------------------------
49
- # Streamlit session state
50
  # ------------------------
51
- if "messages" not in st.session_state:
52
- st.session_state["messages"] = []
53
-
54
- if "thread" not in st.session_state:
55
- st.session_state["thread"] = None
56
-
57
- if "tool_requests" not in st.session_state:
58
- st.session_state["tool_requests"] = queue.Queue()
59
-
60
- tool_requests = st.session_state["tool_requests"]
61
 
62
  # ------------------------
63
- # Utility to remove citations like: 【12†somefile】
64
- # You can adapt to your own "annotations" handling if needed
65
  # ------------------------
66
- def remove_citation(text: str) -> str:
67
- pattern = r"【\d+†\w+】"
68
- return re.sub(pattern, "📚", text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  # ------------------------
71
- # Helper: data streamer for text & images
72
- # Adapted from the Medium article approach
73
- # to handle text deltas, images, or function calls
74
  # ------------------------
75
- def data_streamer():
76
- """
77
- Streams data from the assistant run. Yields text or images
78
- and enqueues tool requests (function calls) to tool_requests.
79
- """
80
- st.toast("Thinking...", icon=":material/emoji_objects:")
81
- content_produced = False
82
-
83
- for event in st.session_state["run_stream"]:
84
- match event.event:
85
- case "thread.message.delta":
86
- # A chunk of text or an image
87
- content = event.data.delta.content[0]
88
- match content.type:
89
- case "text":
90
- text_value = content.text.value
91
- content_produced = True
92
- # Optionally remove citations, etc.
93
- yield remove_citation(text_value)
94
-
95
- case "image_file":
96
- # If the assistant returns an image
97
- file_id = content.image_file.file_id
98
- content_produced = True
99
- image_content = io.BytesIO(client.files.content(file_id).read())
100
- yield Image.open(image_content)
101
-
102
- case "thread.run.requires_action":
103
- # The assistant is requesting a function call
104
- logger.info(f"[Tool Request] {event}")
105
- tool_requests.put(event)
106
- if not content_produced:
107
- # We can yield a placeholder if the model hasn't said anything yet
108
- yield "[LLM is requesting a function call]"
109
- return
110
-
111
- case "thread.run.failed":
112
- # The run failed for some reason
113
- logger.error(f"Run failed: {event}")
114
- return
115
-
116
- # If we successfully streamed everything
117
- st.toast("Completed", icon=":material/emoji_objects:")
118
-
119
- # ------------------------
120
- # Helper: display the streaming content
121
- # This wraps data_streamer in st.write_stream
122
- # so you can see partial tokens in real-time
123
- # ------------------------
124
- def display_stream(run_stream, create_context=True):
125
- """
126
- Grabs tokens from data_streamer() and displays them in real-time.
127
- If `create_context=True`, messages are displayed as an assistant block.
128
- """
129
- st.session_state["run_stream"] = run_stream
130
- if create_context:
131
- with st.chat_message("assistant"):
132
- streamed_result = st.write_stream(data_streamer)
133
- else:
134
- streamed_result = st.write_stream(data_streamer)
135
-
136
- # Return whatever the final token stream is
137
- return streamed_result
138
-
139
- # ------------------------
140
- # Example of handling a function call (requires_action)
141
- # If your Assistant uses function calling (e.g. code interpreter),
142
- # you'd parse arguments, run the function, and return output here.
143
- # ------------------------
144
- def handle_tool_request(event):
145
- """
146
- Demonstrates how you might handle a function call.
147
- In practice, you'd parse the arguments from the event
148
- and run your custom logic. Then return outputs as JSON.
149
- """
150
- st.toast("Running a function (this is user-defined code)", icon=":material/function:")
151
- tool_outputs = []
152
- data = event.data
153
- for tool_call in data.required_action.submit_tool_outputs.tool_calls:
154
- if tool_call.function.arguments:
155
- function_args = json.loads(tool_call.function.arguments)
156
- else:
157
- function_args = {}
158
-
159
  match tool_call.function.name:
160
  case "hello_world":
161
- # Example: implement a user-defined function
162
  name = function_args.get("name", "anonymous")
163
- time.sleep(2) # Simulate a long-running function
164
  output_val = f"Hello, {name}! This was from a local function."
165
- tool_outputs.append({"tool_call_id": tool_call.id, "output": output_val})
166
  case _:
167
- # If unknown function name
168
- msg = {"status": "error", "message": "Unknown function request."}
169
- tool_outputs.append({"tool_call_id": tool_call.id, "output": json.dumps(msg)})
170
- return tool_outputs, data.thread_id, data.id
171
-
172
- # ------------------------
173
- # Main chat logic
174
- # ------------------------
175
- @traceable # Make this function traceable via LangSmith
176
- def generate_assistant_reply(user_input: str):
177
- """
178
- 1. If no thread exists, create a new one.
179
- 2. Insert user message into the thread.
180
- 3. Use the Assistants API to create a run + stream the response.
181
- 4. If the assistant requests a function call, handle it and stream again.
182
- """
183
- # Create or retrieve thread
184
- if not st.session_state["thread"]:
185
- st.session_state["thread"] = client.beta.threads.create()
186
- thread = st.session_state["thread"]
187
-
188
- # Add user message to the thread
189
- client.beta.threads.messages.create(
190
- thread_id=thread.id,
191
- role="user",
192
- content=user_input
193
- )
194
 
195
- # Start streaming assistant response
196
- with client.beta.threads.runs.stream(
197
- thread_id=thread.id,
198
- assistant_id=assistant_id,
199
- ) as run_stream:
200
- display_stream(run_stream)
201
-
202
- # If the assistant requested any tool calls, handle them now
203
- while not tool_requests.empty():
204
- event = tool_requests.get()
205
- tool_outputs, t_id, run_id = handle_tool_request(event)
206
- # Submit tool outputs
207
- with client.beta.threads.runs.submit_tool_outputs_stream(
208
- thread_id=t_id, run_id=run_id, tool_outputs=tool_outputs
209
- ) as next_stream:
210
- display_stream(next_stream, create_context=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
  # ------------------------
213
- # Streamlit UI
214
  # ------------------------
215
- def main():
216
- st.set_page_config(page_title="Solution Specifier A", layout="centered")
217
- st.title("Solution Specifier A")
218
-
219
- # Display existing conversation
220
- for msg in st.session_state["messages"]:
221
- with st.chat_message(msg["role"]):
222
- st.write(msg["content"])
223
-
224
- user_input = st.chat_input("Type your message here...")
225
- if user_input:
226
- # Show user's message
227
- with st.chat_message("user"):
228
- st.write(user_input)
229
-
230
- # Keep in session state
231
- st.session_state["messages"].append({"role": "user", "content": user_input})
232
-
233
- # Generate assistant reply
234
- generate_assistant_reply(user_input)
235
-
236
- # In a real app, you might keep track of the final text
237
- # from the streamed tokens. For simplicity, we store
238
- # the entire streamed result as one block in session state:
239
- st.session_state["messages"].append(
240
- {"role": "assistant", "content": "[assistant reply streamed above]"}
241
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
 
243
  if __name__ == "__main__":
244
  main()
 
1
  import os
2
  import re
 
 
 
3
  import io
4
  import time
5
  import json
6
  import queue
7
  import logging
8
+ from typing import Any, Generator, Optional, List, Dict, Tuple
9
+ from dataclasses import dataclass
10
 
11
+ import streamlit as st
12
+ from dotenv import load_dotenv
13
+ from PIL import Image
14
  import openai
15
  from langsmith.wrappers import wrap_openai
16
  from langsmith import traceable
17
 
18
  # ------------------------
19
+ # Configuration and Types
20
  # ------------------------
21
+ @dataclass
22
+ class AppConfig:
23
+ """Application configuration settings."""
24
+ page_title: str = "Solution Specifier A"
25
+ page_icon: str = "🤖"
26
+ layout: str = "centered"
27
+
28
+ @dataclass
29
+ class Message:
30
+ """Chat message structure."""
31
+ role: str
32
+ content: str
33
+
34
+ class StreamingError(Exception):
35
+ """Custom exception for streaming-related errors."""
36
+ pass
37
+
38
+ # ------------------------
39
+ # Logging Configuration
40
+ # ------------------------
41
+ def setup_logging() -> logging.Logger:
42
+ """Configure and return the application logger."""
43
  logging.basicConfig(
44
  format="[%(asctime)s] %(levelname)+8s: %(message)s",
45
  level=logging.INFO,
46
  )
47
+ return logging.getLogger(__name__)
48
 
49
+ logger = setup_logging()
50
 
51
  # ------------------------
52
+ # Environment Setup
53
  # ------------------------
54
+ class EnvironmentManager:
55
+ """Manages environment variables and configuration."""
56
+
57
+ @staticmethod
58
+ def load_environment() -> Tuple[str, str]:
59
+ """Load and validate environment variables."""
60
+ load_dotenv(override=True)
61
+ api_key = os.getenv("OPENAI_API_KEY")
62
+ assistant_id = os.getenv("ASSISTANT_ID_SOLUTION_SPECIFIER_A")
63
+
64
+ if not api_key or not assistant_id:
65
+ raise RuntimeError(
66
+ "Missing required environment variables. Please set "
67
+ "OPENAI_API_KEY and ASSISTANT_ID_SOLUTION_SPECIFIER_A"
68
+ )
69
+
70
+ return api_key, assistant_id
71
 
72
  # ------------------------
73
+ # State Management
74
  # ------------------------
75
+ class StateManager:
76
+ """Manages Streamlit session state."""
77
+
78
+ @staticmethod
79
+ def initialize_state() -> None:
80
+ """Initialize session state variables."""
81
+ if "messages" not in st.session_state:
82
+ st.session_state.messages = []
83
+ if "thread" not in st.session_state:
84
+ st.session_state.thread = None
85
+ if "tool_requests" not in st.session_state:
86
+ st.session_state.tool_requests = queue.Queue()
87
+ if "run_stream" not in st.session_state:
88
+ st.session_state.run_stream = None
89
+
90
+ @staticmethod
91
+ def add_message(role: str, content: str) -> None:
92
+ """Add a message to the conversation history."""
93
+ st.session_state.messages.append(Message(role=role, content=content))
94
 
95
  # ------------------------
96
+ # Text Processing
97
  # ------------------------
98
+ class TextProcessor:
99
+ """Handles text processing and formatting."""
100
+
101
+ @staticmethod
102
+ def remove_citations(text: str) -> str:
103
+ """Remove citation markers from text."""
104
+ pattern = r"【\d+†\w+】"
105
+ return re.sub(pattern, "📚", text)
 
 
106
 
107
  # ------------------------
108
+ # Streaming Handler
 
109
  # ------------------------
110
+ class StreamHandler:
111
+ """Handles streaming of assistant responses."""
112
+
113
+ def __init__(self, client: Any):
114
+ self.client = client
115
+ self.text_processor = TextProcessor()
116
+
117
+ def stream_data(self) -> Generator[Any, None, None]:
118
+ """Stream data from the assistant run."""
119
+ st.toast("Thinking...", icon="🤔")
120
+ content_produced = False
121
+
122
+ try:
123
+ for event in st.session_state.run_stream:
124
+ match event.event:
125
+ case "thread.message.delta":
126
+ yield from self._handle_message_delta(event, content_produced)
127
+ case "thread.run.requires_action":
128
+ yield from self._handle_action_request(event, content_produced)
129
+ case "thread.run.failed":
130
+ logger.error(f"Run failed: {event}")
131
+ raise StreamingError(f"Assistant run failed: {event}")
132
+
133
+ st.toast("Completed", icon="✅")
134
+ except Exception as e:
135
+ logger.error(f"Streaming error: {e}")
136
+ st.error(f"An error occurred while streaming: {str(e)}")
137
+ raise
138
+
139
+ def _handle_message_delta(self, event: Any, content_produced: bool) -> Generator[Any, None, None]:
140
+ """Handle message delta events."""
141
+ content = event.data.delta.content[0]
142
+ match content.type:
143
+ case "text":
144
+ yield self.text_processor.remove_citations(content.text.value)
145
+ case "image_file":
146
+ image_content = io.BytesIO(self.client.files.content(content.image_file.file_id).read())
147
+ yield Image.open(image_content)
148
+
149
+ def _handle_action_request(self, event: Any, content_produced: bool) -> Generator[str, None, None]:
150
+ """Handle action request events."""
151
+ logger.info(f"[Tool Request] {event}")
152
+ st.session_state.tool_requests.put(event)
153
+ if not content_produced:
154
+ yield "[Processing function call...]"
155
 
156
  # ------------------------
157
+ # Tool Request Handler
 
 
158
  # ------------------------
159
+ class ToolRequestHandler:
160
+ """Handles tool requests from the assistant."""
161
+
162
+ @staticmethod
163
+ def handle_request(event: Any) -> Tuple[List[Dict[str, str]], str, str]:
164
+ """Process tool requests and return outputs."""
165
+ st.toast("Processing function call...", icon="⚙️")
166
+ tool_outputs = []
167
+ data = event.data
168
+
169
+ for tool_call in data.required_action.submit_tool_outputs.tool_calls:
170
+ output = ToolRequestHandler._process_tool_call(tool_call)
171
+ tool_outputs.append(output)
172
+
173
+ return tool_outputs, data.thread_id, data.id
174
+
175
+ @staticmethod
176
+ def _process_tool_call(tool_call: Any) -> Dict[str, str]:
177
+ """Process individual tool calls."""
178
+ function_args = json.loads(tool_call.function.arguments) if tool_call.function.arguments else {}
179
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  match tool_call.function.name:
181
  case "hello_world":
 
182
  name = function_args.get("name", "anonymous")
 
183
  output_val = f"Hello, {name}! This was from a local function."
 
184
  case _:
185
+ output_val = json.dumps({"status": "error", "message": "Unknown function request."})
186
+
187
+ return {"tool_call_id": tool_call.id, "output": output_val}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
 
189
+ # ------------------------
190
+ # Assistant Manager
191
+ # ------------------------
192
+ class AssistantManager:
193
+ """Manages interactions with the OpenAI Assistant."""
194
+
195
+ def __init__(self, client: Any, assistant_id: str):
196
+ self.client = client
197
+ self.assistant_id = assistant_id
198
+ self.stream_handler = StreamHandler(client)
199
+ self.tool_handler = ToolRequestHandler()
200
+
201
+ @traceable
202
+ def generate_reply(self, user_input: str) -> None:
203
+ """Generate and stream assistant's reply."""
204
+ # Ensure thread exists
205
+ if not st.session_state.thread:
206
+ st.session_state.thread = self.client.beta.threads.create()
207
+
208
+ # Add user message
209
+ self.client.beta.threads.messages.create(
210
+ thread_id=st.session_state.thread.id,
211
+ role="user",
212
+ content=user_input
213
+ )
214
+
215
+ # Stream initial response
216
+ with self.client.beta.threads.runs.stream(
217
+ thread_id=st.session_state.thread.id,
218
+ assistant_id=self.assistant_id,
219
+ ) as run_stream:
220
+ self._display_stream(run_stream)
221
+
222
+ # Handle any tool requests
223
+ self._process_tool_requests()
224
+
225
+ def _display_stream(self, run_stream: Any, create_context: bool = True) -> None:
226
+ """Display streaming content."""
227
+ st.session_state.run_stream = run_stream
228
+ if create_context:
229
+ with st.chat_message("assistant"):
230
+ st.write_stream(self.stream_handler.stream_data)
231
+ else:
232
+ st.write_stream(self.stream_handler.stream_data)
233
+
234
+ def _process_tool_requests(self) -> None:
235
+ """Process any pending tool requests."""
236
+ while not st.session_state.tool_requests.empty():
237
+ event = st.session_state.tool_requests.get()
238
+ tool_outputs, thread_id, run_id = self.tool_handler.handle_request(event)
239
+
240
+ with self.client.beta.threads.runs.submit_tool_outputs_stream(
241
+ thread_id=thread_id,
242
+ run_id=run_id,
243
+ tool_outputs=tool_outputs
244
+ ) as next_stream:
245
+ self._display_stream(next_stream, create_context=False)
246
 
247
  # ------------------------
248
+ # Main Application
249
  # ------------------------
250
+ class ChatApplication:
251
+ """Main chat application class."""
252
+
253
+ def __init__(self):
254
+ self.config = AppConfig()
255
+ api_key, assistant_id = EnvironmentManager.load_environment()
256
+
257
+ # Initialize OpenAI client
258
+ openai_client = openai.Client(api_key=api_key)
259
+ self.client = wrap_openai(openai_client)
260
+
261
+ # Initialize components
262
+ self.state_manager = StateManager()
263
+ self.assistant_manager = AssistantManager(self.client, assistant_id)
264
+
265
+ def setup_page(self) -> None:
266
+ """Configure the Streamlit page."""
267
+ st.set_page_config(
268
+ page_title=self.config.page_title,
269
+ page_icon=self.config.page_icon,
270
+ layout=self.config.layout
 
 
 
 
 
271
  )
272
+ st.title(self.config.page_title)
273
+
274
+ def display_chat_history(self) -> None:
275
+ """Display the chat history."""
276
+ for msg in st.session_state.messages:
277
+ with st.chat_message(msg.role):
278
+ st.write(msg.content)
279
+
280
+ def run(self) -> None:
281
+ """Run the chat application."""
282
+ self.setup_page()
283
+ self.state_manager.initialize_state()
284
+ self.display_chat_history()
285
+
286
+ user_input = st.chat_input("Type your message here...")
287
+ if user_input:
288
+ # Display and store user message
289
+ with st.chat_message("user"):
290
+ st.write(user_input)
291
+ self.state_manager.add_message("user", user_input)
292
+
293
+ # Generate and display assistant reply
294
+ try:
295
+ self.assistant_manager.generate_reply(user_input)
296
+ self.state_manager.add_message(
297
+ "assistant",
298
+ "[Assistant reply streamed above]"
299
+ )
300
+ except Exception as e:
301
+ st.error(f"Error generating response: {str(e)}")
302
+ logger.exception("Error in assistant reply generation")
303
+
304
+ def main():
305
+ """Application entry point."""
306
+ try:
307
+ app = ChatApplication()
308
+ app.run()
309
+ except Exception as e:
310
+ st.error(f"Application error: {str(e)}")
311
+ logger.exception("Fatal application error")
312
 
313
  if __name__ == "__main__":
314
  main()
requirements.txt CHANGED
@@ -1,4 +1,59 @@
1
- python-dotenv
2
- langsmith
3
- openai
4
- Pillow
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ altair==5.5.0
2
+ annotated-types==0.7.0
3
+ anyio==4.8.0
4
+ attrs==25.1.0
5
+ blinker==1.9.0
6
+ cachetools==5.5.2
7
+ certifi==2025.1.31
8
+ charset-normalizer==3.4.1
9
+ click==8.1.8
10
+ distro==1.9.0
11
+ gitdb==4.0.12
12
+ GitPython==3.1.44
13
+ h11==0.14.0
14
+ httpcore==1.0.7
15
+ httpx==0.28.1
16
+ idna==3.10
17
+ Jinja2==3.1.5
18
+ jiter==0.8.2
19
+ jsonschema==4.23.0
20
+ jsonschema-specifications==2024.10.1
21
+ langsmith==0.3.10
22
+ markdown-it-py==3.0.0
23
+ MarkupSafe==3.0.2
24
+ mdurl==0.1.2
25
+ narwhals==1.28.0
26
+ numpy==2.2.3
27
+ openai==1.64.0
28
+ orjson==3.10.15
29
+ packaging==24.2
30
+ pandas==2.2.3
31
+ pillow==11.1.0
32
+ protobuf==5.29.3
33
+ pyarrow==19.0.1
34
+ pydantic==2.10.6
35
+ pydantic_core==2.27.2
36
+ pydeck==0.9.1
37
+ Pygments==2.19.1
38
+ python-dateutil==2.9.0.post0
39
+ python-dotenv==1.0.1
40
+ pytz==2025.1
41
+ referencing==0.36.2
42
+ requests==2.32.3
43
+ requests-toolbelt==1.0.0
44
+ rich==13.9.4
45
+ rpds-py==0.23.1
46
+ setuptools==75.8.0
47
+ six==1.17.0
48
+ smmap==5.0.2
49
+ sniffio==1.3.1
50
+ streamlit==1.42.2
51
+ tenacity==9.0.0
52
+ toml==0.10.2
53
+ tornado==6.4.2
54
+ tqdm==4.67.1
55
+ typing_extensions==4.12.2
56
+ tzdata==2025.1
57
+ urllib3==2.3.0
58
+ wheel==0.45.1
59
+ zstandard==0.23.0