huang_de_jun commited on
Commit
a402300
·
1 Parent(s): e8ee7ad

feat: add configurable LLM provider support (OpenAI/Anthropic)

Browse files

- Add config.yaml for switching between OpenAI and Anthropic
providers
- Support gpt-5-mini with max_completion_tokens parameter
- Add strict mode to OpenAI tool definitions for newer models
- Add loguru for better error logging in terminal
- Update README with solved challenges

Files changed (5) hide show
  1. README.md +8 -12
  2. config.yaml +9 -0
  3. pyproject.toml +2 -0
  4. src/streaming_agent.py +210 -67
  5. uv.lock +27 -1
README.md CHANGED
@@ -154,24 +154,20 @@ Powered by:
154
  - [Gradio](https://gradio.app) - UI framework
155
  - [HuggingFace](https://huggingface.co) - Hosting
156
 
157
- ## Challenges
158
 
159
- ### Audio Transcription Timing Issues
160
 
161
- The current implementation uses short audio chunk intervals for streaming transcription. However, this creates a significant problem:
162
 
163
- - **Issue**: The transcription API cannot process chunks fast enough before the next chunk arrives
164
- - **Result**: Only fragments of audio are being transcribed, causing substantial information loss
165
- - **Impact**: The accumulated context is incomplete, leading to lower quality question generation
 
 
166
 
167
  ## To-Do
168
 
169
- ### High Priority
170
-
171
- - [ ] **Experiment with audio buffering** - Implement a buffer that accumulates audio before sending to transcription API
172
- - [ ] **Increase audio chunk interval** - Test longer intervals (e.g., 5-10 seconds instead of 1-2 seconds) to allow complete transcription
173
- - [ ] **Add overlap between chunks** - Implement overlapping audio segments to prevent word cutoffs at boundaries
174
-
175
  ### Future Improvements
176
 
177
  - [ ] **Add useful MCPs** - Integrate additional MCP servers for enhanced capabilities:
 
154
  - [Gradio](https://gradio.app) - UI framework
155
  - [HuggingFace](https://huggingface.co) - Hosting
156
 
157
+ ## Challenges & Solutions
158
 
159
+ ### Audio Transcription Timing Issues (Solved ✅)
160
 
161
+ **Original problem:** Short audio chunk intervals caused the transcription API to fall behind, resulting in fragmented audio and information loss.
162
 
163
+ **Solutions implemented:**
164
+ - **20-second audio chunks** - Longer intervals give Whisper more context and reduce API calls
165
+ - **2-second overlap** - Prevents word cutoffs at chunk boundaries
166
+ - ✅ **Background threading** - Transcription and question generation run in separate threads
167
+ - ✅ **Sequential queue** - Ensures transcriptions complete in order while audio keeps buffering
168
 
169
  ## To-Do
170
 
 
 
 
 
 
 
171
  ### Future Improvements
172
 
173
  - [ ] **Add useful MCPs** - Integrate additional MCP servers for enhanced capabilities:
config.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # LLM Configuration
2
+ llm:
3
+ provider: openai # "openai" or "anthropic"
4
+
5
+ openai:
6
+ model: gpt-5-mini
7
+
8
+ anthropic:
9
+ model: claude-sonnet-4-20250514
pyproject.toml CHANGED
@@ -7,8 +7,10 @@ requires-python = ">=3.10"
7
  dependencies = [
8
  "anthropic>=0.75.0",
9
  "gradio>=5.50.0",
 
10
  "openai>=2.8.1",
11
  "python-dotenv>=1.2.1",
 
12
  "tavily-python>=0.7.13",
13
  ]
14
 
 
7
  dependencies = [
8
  "anthropic>=0.75.0",
9
  "gradio>=5.50.0",
10
+ "loguru>=0.7.3",
11
  "openai>=2.8.1",
12
  "python-dotenv>=1.2.1",
13
+ "pyyaml>=6.0",
14
  "tavily-python>=0.7.13",
15
  ]
16
 
src/streaming_agent.py CHANGED
@@ -2,18 +2,40 @@
2
 
3
  import os
4
  import json
5
- from anthropic import Anthropic
 
 
6
 
7
  from .context import ConversationContext
8
  from .research import search_web, search_news, research_speaker
9
 
10
 
11
- def get_anthropic_client() -> Anthropic:
12
- """Get Anthropic client with API key from environment."""
13
- api_key = os.getenv("ANTHROPIC_API_KEY")
14
- if not api_key:
15
- raise ValueError("ANTHROPIC_API_KEY environment variable not set")
16
- return Anthropic(api_key=api_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  # Concise system prompt optimized for streaming
@@ -27,7 +49,8 @@ Output JSON only:
27
  {"questions": [{"q": "question text", "type": "TYPE", "why": "brief reason"}]}"""
28
 
29
 
30
- TOOLS = [
 
31
  {
32
  "name": "search_web",
33
  "description": "Search web for context. Use sparingly.",
@@ -57,6 +80,52 @@ TOOLS = [
57
  }
58
  ]
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  TOOL_DISPLAY = {
61
  "search_web": {"icon": "🔍", "name": "Web Search"},
62
  "search_news": {"icon": "📰", "name": "News Search"},
@@ -166,6 +235,7 @@ def generate_questions_sync(
166
  ) -> tuple[list[dict], list[dict], str, str]:
167
  """
168
  Generate new questions synchronously (non-generator version).
 
169
 
170
  Args:
171
  context: Current conversation context
@@ -176,7 +246,7 @@ def generate_questions_sync(
176
  Returns:
177
  Tuple of (all_questions, updated_activity_log, questions_html, log_html)
178
  """
179
- client = get_anthropic_client()
180
 
181
  # Add thinking activity
182
  activity_log.append({"type": "thinking"})
@@ -199,73 +269,23 @@ Already asked (avoid similar):
199
 
200
  Generate 1-3 NEW insightful questions based on the recent content. Be concise."""
201
 
202
- messages = [{"role": "user", "content": user_message}]
203
-
204
  # Limit iterations for speed
205
  max_iterations = 3
206
  iteration = 0
207
  new_questions = []
208
 
209
  try:
210
- while iteration < max_iterations:
211
- iteration += 1
212
-
213
- response = client.messages.create(
214
- model="claude-sonnet-4-20250514",
215
- max_tokens=1024,
216
- system=STREAMING_SYSTEM_PROMPT,
217
- tools=TOOLS,
218
- messages=messages
219
  )
220
-
221
- if response.stop_reason == "tool_use":
222
- tool_results = []
223
- for block in response.content:
224
- if block.type == "tool_use":
225
- query = block.input.get("query", block.input.get("speaker_name", ""))
226
- activity_log.append({"type": "tool_call", "tool": block.name, "query": query})
227
-
228
- result = execute_tool(block.name, block.input)
229
-
230
- activity_log.append({"type": "tool_result"})
231
-
232
- tool_results.append({
233
- "type": "tool_result",
234
- "tool_use_id": block.id,
235
- "content": result
236
- })
237
-
238
- messages.append({"role": "assistant", "content": response.content})
239
- messages.append({"role": "user", "content": tool_results})
240
- else:
241
- # Extract questions from response
242
- activity_log.append({"type": "generating"})
243
-
244
- final_text = ""
245
- for block in response.content:
246
- if hasattr(block, "text"):
247
- final_text += block.text
248
-
249
- try:
250
- json_start = final_text.find("{")
251
- json_end = final_text.rfind("}") + 1
252
- if json_start >= 0 and json_end > json_start:
253
- result = json.loads(final_text[json_start:json_end])
254
- new_questions = result.get("questions", [])
255
-
256
- # Store in context
257
- for q in new_questions:
258
- context.add_question(
259
- question=q.get("q", ""),
260
- category=q.get("type", ""),
261
- reasoning=q.get("why", "")
262
- )
263
- except json.JSONDecodeError:
264
- pass
265
-
266
- break
267
 
268
  except Exception as e:
 
269
  activity_log.append({"type": "error", "msg": str(e)})
270
 
271
  # Combine questions
@@ -281,3 +301,126 @@ Generate 1-3 NEW insightful questions based on the recent content. Be concise.""
281
  format_questions_html(all_questions),
282
  format_activity_log(activity_log)
283
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import os
4
  import json
5
+ import yaml
6
+ from pathlib import Path
7
+ from loguru import logger
8
 
9
  from .context import ConversationContext
10
  from .research import search_web, search_news, research_speaker
11
 
12
 
13
+ def load_config() -> dict:
14
+ """Load configuration from config.yaml."""
15
+ config_path = Path(__file__).parent.parent / "config.yaml"
16
+ if config_path.exists():
17
+ with open(config_path) as f:
18
+ return yaml.safe_load(f)
19
+ return {"llm": {"provider": "openai", "openai": {"model": "gpt-4o-mini"}}}
20
+
21
+
22
+ def get_llm_client():
23
+ """Get LLM client based on config."""
24
+ config = load_config()
25
+ provider = config.get("llm", {}).get("provider", "openai")
26
+
27
+ if provider == "anthropic":
28
+ from anthropic import Anthropic
29
+ api_key = os.getenv("ANTHROPIC_API_KEY")
30
+ if not api_key:
31
+ raise ValueError("ANTHROPIC_API_KEY environment variable not set")
32
+ return Anthropic(api_key=api_key), provider, config["llm"]["anthropic"]["model"]
33
+ else:
34
+ from openai import OpenAI
35
+ api_key = os.getenv("OPENAI_API_KEY")
36
+ if not api_key:
37
+ raise ValueError("OPENAI_API_KEY environment variable not set")
38
+ return OpenAI(api_key=api_key), provider, config["llm"]["openai"]["model"]
39
 
40
 
41
  # Concise system prompt optimized for streaming
 
49
  {"questions": [{"q": "question text", "type": "TYPE", "why": "brief reason"}]}"""
50
 
51
 
52
+ # Anthropic tools format
53
+ ANTHROPIC_TOOLS = [
54
  {
55
  "name": "search_web",
56
  "description": "Search web for context. Use sparingly.",
 
80
  }
81
  ]
82
 
83
+ # OpenAI tools format
84
+ OPENAI_TOOLS = [
85
+ {
86
+ "type": "function",
87
+ "function": {
88
+ "name": "search_web",
89
+ "description": "Search web for context. Use sparingly.",
90
+ "strict": True,
91
+ "parameters": {
92
+ "type": "object",
93
+ "properties": {"query": {"type": "string"}},
94
+ "required": ["query"],
95
+ "additionalProperties": False
96
+ }
97
+ }
98
+ },
99
+ {
100
+ "type": "function",
101
+ "function": {
102
+ "name": "search_news",
103
+ "description": "Search recent news. Use sparingly.",
104
+ "strict": True,
105
+ "parameters": {
106
+ "type": "object",
107
+ "properties": {"query": {"type": "string"}},
108
+ "required": ["query"],
109
+ "additionalProperties": False
110
+ }
111
+ }
112
+ },
113
+ {
114
+ "type": "function",
115
+ "function": {
116
+ "name": "research_speaker",
117
+ "description": "Research speaker background.",
118
+ "strict": True,
119
+ "parameters": {
120
+ "type": "object",
121
+ "properties": {"speaker_name": {"type": "string"}},
122
+ "required": ["speaker_name"],
123
+ "additionalProperties": False
124
+ }
125
+ }
126
+ }
127
+ ]
128
+
129
  TOOL_DISPLAY = {
130
  "search_web": {"icon": "🔍", "name": "Web Search"},
131
  "search_news": {"icon": "📰", "name": "News Search"},
 
235
  ) -> tuple[list[dict], list[dict], str, str]:
236
  """
237
  Generate new questions synchronously (non-generator version).
238
+ Supports both OpenAI and Anthropic providers based on config.
239
 
240
  Args:
241
  context: Current conversation context
 
246
  Returns:
247
  Tuple of (all_questions, updated_activity_log, questions_html, log_html)
248
  """
249
+ client, provider, model = get_llm_client()
250
 
251
  # Add thinking activity
252
  activity_log.append({"type": "thinking"})
 
269
 
270
  Generate 1-3 NEW insightful questions based on the recent content. Be concise."""
271
 
 
 
272
  # Limit iterations for speed
273
  max_iterations = 3
274
  iteration = 0
275
  new_questions = []
276
 
277
  try:
278
+ if provider == "anthropic":
279
+ new_questions = _generate_with_anthropic(
280
+ client, model, user_message, activity_log, context, max_iterations
281
+ )
282
+ else:
283
+ new_questions = _generate_with_openai(
284
+ client, model, user_message, activity_log, context, max_iterations
 
 
285
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
  except Exception as e:
288
+ logger.error(f"Question generation failed: {e}")
289
  activity_log.append({"type": "error", "msg": str(e)})
290
 
291
  # Combine questions
 
301
  format_questions_html(all_questions),
302
  format_activity_log(activity_log)
303
  )
304
+
305
+
306
+ def _generate_with_anthropic(client, model, user_message, activity_log, context, max_iterations):
307
+ """Generate questions using Anthropic API."""
308
+ messages = [{"role": "user", "content": user_message}]
309
+ new_questions = []
310
+ iteration = 0
311
+
312
+ while iteration < max_iterations:
313
+ iteration += 1
314
+
315
+ response = client.messages.create(
316
+ model=model,
317
+ max_tokens=1024,
318
+ system=STREAMING_SYSTEM_PROMPT,
319
+ tools=ANTHROPIC_TOOLS,
320
+ messages=messages
321
+ )
322
+
323
+ if response.stop_reason == "tool_use":
324
+ tool_results = []
325
+ for block in response.content:
326
+ if block.type == "tool_use":
327
+ query = block.input.get("query", block.input.get("speaker_name", ""))
328
+ activity_log.append({"type": "tool_call", "tool": block.name, "query": query})
329
+
330
+ result = execute_tool(block.name, block.input)
331
+ activity_log.append({"type": "tool_result"})
332
+
333
+ tool_results.append({
334
+ "type": "tool_result",
335
+ "tool_use_id": block.id,
336
+ "content": result
337
+ })
338
+
339
+ messages.append({"role": "assistant", "content": response.content})
340
+ messages.append({"role": "user", "content": tool_results})
341
+ else:
342
+ activity_log.append({"type": "generating"})
343
+
344
+ final_text = ""
345
+ for block in response.content:
346
+ if hasattr(block, "text"):
347
+ final_text += block.text
348
+
349
+ new_questions = _parse_questions(final_text, context)
350
+ break
351
+
352
+ return new_questions
353
+
354
+
355
+ def _generate_with_openai(client, model, user_message, activity_log, context, max_iterations):
356
+ """Generate questions using OpenAI API."""
357
+ messages = [
358
+ {"role": "system", "content": STREAMING_SYSTEM_PROMPT},
359
+ {"role": "user", "content": user_message}
360
+ ]
361
+ new_questions = []
362
+ iteration = 0
363
+
364
+ while iteration < max_iterations:
365
+ iteration += 1
366
+
367
+ response = client.chat.completions.create(
368
+ model=model,
369
+ max_completion_tokens=1024,
370
+ tools=OPENAI_TOOLS,
371
+ messages=messages
372
+ )
373
+
374
+ choice = response.choices[0]
375
+
376
+ if choice.finish_reason == "tool_calls" and choice.message.tool_calls:
377
+ tool_messages = []
378
+ for tool_call in choice.message.tool_calls:
379
+ func_name = tool_call.function.name
380
+ func_args = json.loads(tool_call.function.arguments)
381
+
382
+ query = func_args.get("query", func_args.get("speaker_name", ""))
383
+ activity_log.append({"type": "tool_call", "tool": func_name, "query": query})
384
+
385
+ result = execute_tool(func_name, func_args)
386
+ activity_log.append({"type": "tool_result"})
387
+
388
+ tool_messages.append({
389
+ "role": "tool",
390
+ "tool_call_id": tool_call.id,
391
+ "content": result
392
+ })
393
+
394
+ messages.append(choice.message)
395
+ messages.extend(tool_messages)
396
+ else:
397
+ activity_log.append({"type": "generating"})
398
+
399
+ final_text = choice.message.content or ""
400
+ new_questions = _parse_questions(final_text, context)
401
+ break
402
+
403
+ return new_questions
404
+
405
+
406
+ def _parse_questions(text: str, context: ConversationContext) -> list[dict]:
407
+ """Parse questions from LLM response text."""
408
+ new_questions = []
409
+ try:
410
+ json_start = text.find("{")
411
+ json_end = text.rfind("}") + 1
412
+ if json_start >= 0 and json_end > json_start:
413
+ result = json.loads(text[json_start:json_end])
414
+ new_questions = result.get("questions", [])
415
+
416
+ # Store in context
417
+ for q in new_questions:
418
+ context.add_question(
419
+ question=q.get("q", ""),
420
+ category=q.get("type", ""),
421
+ reasoning=q.get("why", "")
422
+ )
423
+ except json.JSONDecodeError:
424
+ pass
425
+
426
+ return new_questions
uv.lock CHANGED
@@ -76,8 +76,10 @@ source = { virtual = "." }
76
  dependencies = [
77
  { name = "anthropic" },
78
  { name = "gradio" },
 
79
  { name = "openai" },
80
  { name = "python-dotenv" },
 
81
  { name = "tavily-python" },
82
  ]
83
 
@@ -85,8 +87,10 @@ dependencies = [
85
  requires-dist = [
86
  { name = "anthropic", specifier = ">=0.75.0" },
87
  { name = "gradio", specifier = ">=5.50.0" },
 
88
  { name = "openai", specifier = ">=2.8.1" },
89
  { name = "python-dotenv", specifier = ">=1.2.1" },
 
90
  { name = "tavily-python", specifier = ">=0.7.13" },
91
  ]
92
 
@@ -346,7 +350,7 @@ name = "exceptiongroup"
346
  version = "1.3.1"
347
  source = { registry = "https://pypi.org/simple" }
348
  dependencies = [
349
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
350
  ]
351
  sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
352
  wheels = [
@@ -665,6 +669,19 @@ wheels = [
665
  { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" },
666
  ]
667
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  [[package]]
669
  name = "markdown-it-py"
670
  version = "4.0.0"
@@ -1902,3 +1919,12 @@ wheels = [
1902
  { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
1903
  { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
1904
  ]
 
 
 
 
 
 
 
 
 
 
76
  dependencies = [
77
  { name = "anthropic" },
78
  { name = "gradio" },
79
+ { name = "loguru" },
80
  { name = "openai" },
81
  { name = "python-dotenv" },
82
+ { name = "pyyaml" },
83
  { name = "tavily-python" },
84
  ]
85
 
 
87
  requires-dist = [
88
  { name = "anthropic", specifier = ">=0.75.0" },
89
  { name = "gradio", specifier = ">=5.50.0" },
90
+ { name = "loguru", specifier = ">=0.7.3" },
91
  { name = "openai", specifier = ">=2.8.1" },
92
  { name = "python-dotenv", specifier = ">=1.2.1" },
93
+ { name = "pyyaml", specifier = ">=6.0" },
94
  { name = "tavily-python", specifier = ">=0.7.13" },
95
  ]
96
 
 
350
  version = "1.3.1"
351
  source = { registry = "https://pypi.org/simple" }
352
  dependencies = [
353
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
354
  ]
355
  sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
356
  wheels = [
 
669
  { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" },
670
  ]
671
 
672
+ [[package]]
673
+ name = "loguru"
674
+ version = "0.7.3"
675
+ source = { registry = "https://pypi.org/simple" }
676
+ dependencies = [
677
+ { name = "colorama", marker = "sys_platform == 'win32'" },
678
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
679
+ ]
680
+ sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
681
+ wheels = [
682
+ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
683
+ ]
684
+
685
  [[package]]
686
  name = "markdown-it-py"
687
  version = "4.0.0"
 
1919
  { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
1920
  { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
1921
  ]
1922
+
1923
+ [[package]]
1924
+ name = "win32-setctime"
1925
+ version = "1.2.0"
1926
+ source = { registry = "https://pypi.org/simple" }
1927
+ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
1928
+ wheels = [
1929
+ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
1930
+ ]