hedit 0.7.5.dev1__tar.gz → 0.7.6.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {hedit-0.7.5.dev1/hedit.egg-info → hedit-0.7.6.dev1}/PKG-INFO +1 -1
  2. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1/hedit.egg-info}/PKG-INFO +1 -1
  3. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/pyproject.toml +1 -1
  4. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/agents/workflow.py +31 -28
  5. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/api/main.py +187 -11
  6. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/version.py +2 -2
  7. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_api_endpoints.py +431 -0
  8. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/LICENSE +0 -0
  9. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/PKG_README.md +0 -0
  10. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/README.md +0 -0
  11. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/hedit.egg-info/SOURCES.txt +0 -0
  12. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/hedit.egg-info/dependency_links.txt +0 -0
  13. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/hedit.egg-info/entry_points.txt +0 -0
  14. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/hedit.egg-info/requires.txt +0 -0
  15. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/hedit.egg-info/top_level.txt +0 -0
  16. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/setup.cfg +0 -0
  17. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/__init__.py +0 -0
  18. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/agents/__init__.py +0 -0
  19. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/agents/annotation_agent.py +0 -0
  20. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/agents/assessment_agent.py +0 -0
  21. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/agents/evaluation_agent.py +0 -0
  22. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/agents/feedback_summarizer.py +0 -0
  23. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/agents/feedback_triage_agent.py +0 -0
  24. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/agents/state.py +0 -0
  25. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/agents/validation_agent.py +0 -0
  26. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/agents/vision_agent.py +0 -0
  27. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/api/__init__.py +0 -0
  28. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/api/models.py +0 -0
  29. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/api/security.py +0 -0
  30. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/cli/__init__.py +0 -0
  31. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/cli/api_executor.py +0 -0
  32. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/cli/client.py +0 -0
  33. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/cli/commands/__init__.py +0 -0
  34. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/cli/config.py +0 -0
  35. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/cli/executor.py +0 -0
  36. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/cli/local_executor.py +0 -0
  37. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/cli/main.py +0 -0
  38. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/cli/output.py +0 -0
  39. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/scripts/__init__.py +0 -0
  40. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/scripts/process_feedback.py +0 -0
  41. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/telemetry/__init__.py +0 -0
  42. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/telemetry/collector.py +0 -0
  43. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/telemetry/schema.py +0 -0
  44. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/telemetry/storage.py +0 -0
  45. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/utils/__init__.py +0 -0
  46. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/utils/error_remediation.py +0 -0
  47. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/utils/github_client.py +0 -0
  48. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/utils/hed_comprehensive_guide.py +0 -0
  49. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/utils/hed_rules.py +0 -0
  50. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/utils/image_processing.py +0 -0
  51. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/utils/json_schema_loader.py +0 -0
  52. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/utils/litellm_llm.py +0 -0
  53. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/utils/openrouter_llm.py +0 -0
  54. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/utils/schema_loader.py +0 -0
  55. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/validation/__init__.py +0 -0
  56. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/validation/hed_lsp.py +0 -0
  57. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/src/validation/hed_validator.py +0 -0
  58. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_annotation_agent.py +0 -0
  59. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_cli_client.py +0 -0
  60. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_cli_config.py +0 -0
  61. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_cli_integration.py +0 -0
  62. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_cli_main.py +0 -0
  63. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_comprehensive_guide.py +0 -0
  64. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_error_remediation.py +0 -0
  65. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_feedback_integration.py +0 -0
  66. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_feedback_triage.py +0 -0
  67. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_github_client.py +0 -0
  68. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_hed_lsp.py +0 -0
  69. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_integration_openrouter.py +0 -0
  70. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_json_schema_loader.py +0 -0
  71. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_keyword_extraction.py +0 -0
  72. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_litellm_llm.py +0 -0
  73. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_no_extend_propagation.py +0 -0
  74. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_openrouter_llm.py +0 -0
  75. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_schema_loader.py +0 -0
  76. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_security.py +0 -0
  77. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_state.py +0 -0
  78. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_telemetry.py +0 -0
  79. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_validation.py +0 -0
  80. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_validation_agent.py +0 -0
  81. {hedit-0.7.5.dev1 → hedit-0.7.6.dev1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hedit
3
- Version: 0.7.5.dev1
3
+ Version: 0.7.6.dev1
4
4
  Summary: Multi-agent system for HED annotation generation and validation
5
5
  Author-email: Annotation Garden Initiative <info@annotation.garden>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hedit
3
- Version: 0.7.5.dev1
3
+ Version: 0.7.6.dev1
4
4
  Summary: Multi-agent system for HED annotation generation and validation
5
5
  Author-email: Annotation Garden Initiative <info@annotation.garden>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hedit"
7
- version = "0.7.5.dev1"
7
+ version = "0.7.6.dev1"
8
8
  description = "Multi-agent system for HED annotation generation and validation"
9
9
  readme = "PKG_README.md"
10
10
  requires-python = ">=3.12"
@@ -4,6 +4,7 @@ This module defines the multi-agent workflow that orchestrates
4
4
  annotation, validation, evaluation, and assessment.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import logging
8
9
  import time
9
10
  from pathlib import Path
@@ -239,28 +240,32 @@ class HedAnnotationWorkflow:
239
240
  semantic_hints: list[dict] = []
240
241
 
241
242
  if keywords and self.hed_lsp_client:
242
- try:
243
- # Query hed-lsp for each keyword individually for better results
244
- for keyword in keywords:
245
- result = self.hed_lsp_client.suggest(keyword)
246
- if result.success:
247
- for s in result.suggestions:
248
- semantic_hints.append(
249
- {
250
- "tag": s.tag,
251
- "keyword": keyword,
252
- "score": s.score or 0.0,
253
- "source": "hed-lsp",
254
- }
255
- )
256
- else:
257
- logger.debug(
258
- "[WORKFLOW] hed-lsp suggestion failed for '%s': %s",
259
- keyword,
260
- result.error,
243
+ # Query hed-lsp for each keyword individually for better results
244
+ for keyword in keywords:
245
+ try:
246
+ result = await asyncio.to_thread(self.hed_lsp_client.suggest, keyword)
247
+ except Exception as e:
248
+ logger.warning("[WORKFLOW] hed-lsp error for '%s': %s", keyword, e)
249
+ continue
250
+ if result.success:
251
+ for s in result.suggestions:
252
+ semantic_hints.append(
253
+ {
254
+ "tag": s.tag,
255
+ "keyword": keyword,
256
+ "score": s.score or 0.0,
257
+ "source": "hed-lsp",
258
+ }
261
259
  )
262
-
263
- # Deduplicate by tag, keeping highest score
260
+ else:
261
+ logger.debug(
262
+ "[WORKFLOW] hed-lsp suggestion failed for '%s': %s",
263
+ keyword,
264
+ result.error,
265
+ )
266
+
267
+ # Deduplicate by tag, keeping highest score
268
+ if semantic_hints:
264
269
  seen_tags: dict[str, dict] = {}
265
270
  for hint in semantic_hints:
266
271
  tag = hint["tag"]
@@ -268,13 +273,11 @@ class HedAnnotationWorkflow:
268
273
  seen_tags[tag] = hint
269
274
  semantic_hints = sorted(seen_tags.values(), key=lambda h: h["score"], reverse=True)
270
275
 
271
- logger.info(
272
- "[WORKFLOW] hed-lsp suggested %d unique tags from %d keywords",
273
- len(semantic_hints),
274
- len(keywords),
275
- )
276
- except Exception as e:
277
- logger.warning("[WORKFLOW] hed-lsp error: %s", e, exc_info=True)
276
+ logger.info(
277
+ "[WORKFLOW] hed-lsp suggested %d unique tags from %d keywords",
278
+ len(semantic_hints),
279
+ len(keywords),
280
+ )
278
281
  elif keywords:
279
282
  # LSP not available; still store keywords for the annotation agent
280
283
  logger.info(
@@ -668,11 +668,16 @@ async def annotate(
668
668
  or req.headers.get("x-openrouter-model")
669
669
  or os.getenv("ANNOTATION_MODEL", "openai/gpt-oss-120b")
670
670
  )
671
- temperature = (
672
- request.temperature
673
- or float(req.headers.get("x-openrouter-temperature", 0))
674
- or _byok_config.get("temperature", 0.1)
675
- )
671
+ temperature = request.temperature
672
+ if temperature is None:
673
+ temp_header = req.headers.get("x-openrouter-temperature")
674
+ if temp_header is not None:
675
+ try:
676
+ temperature = float(temp_header)
677
+ except ValueError:
678
+ temperature = None
679
+ if temperature is None:
680
+ temperature = _byok_config.get("temperature", 0.1)
676
681
 
677
682
  event = TelemetryEvent.create(
678
683
  description=request.description,
@@ -874,11 +879,16 @@ async def annotate_from_image(
874
879
  or req.headers.get("x-openrouter-model")
875
880
  or os.getenv("ANNOTATION_MODEL", "openai/gpt-oss-120b")
876
881
  )
877
- temperature = (
878
- request.temperature
879
- or float(req.headers.get("x-openrouter-temperature", 0))
880
- or _byok_config.get("temperature", 0.1)
881
- )
882
+ temperature = request.temperature
883
+ if temperature is None:
884
+ temp_header = req.headers.get("x-openrouter-temperature")
885
+ if temp_header is not None:
886
+ try:
887
+ temperature = float(temp_header)
888
+ except ValueError:
889
+ temperature = None
890
+ if temperature is None:
891
+ temperature = _byok_config.get("temperature", 0.1)
882
892
 
883
893
  event = TelemetryEvent.create(
884
894
  description=image_description, # Use generated image description
@@ -927,6 +937,64 @@ async def annotate_from_image(
927
937
  ) from e
928
938
 
929
939
 
940
+ async def _collect_stream_telemetry(
941
+ request: AnnotationRequest | ImageAnnotationRequest,
942
+ req: Request,
943
+ current_state: dict,
944
+ start_time: float,
945
+ source: str,
946
+ description: str,
947
+ ) -> None:
948
+ """Collect telemetry for streaming endpoints.
949
+
950
+ Shared helper used by both /annotate/stream and /annotate-from-image/stream.
951
+ Silently returns if telemetry is disabled or collector is not initialized.
952
+
953
+ Args:
954
+ request: The annotation request (text or image)
955
+ req: FastAPI request for header extraction
956
+ current_state: Current workflow state dict
957
+ start_time: Workflow start time (from time.time())
958
+ source: Telemetry source identifier (e.g., "api-stream", "api-image-stream")
959
+ description: Input description text (or image description for image endpoints)
960
+ """
961
+ if not request.telemetry_enabled or not telemetry_collector:
962
+ return
963
+
964
+ latency_ms = int((time.time() - start_time) * 1000)
965
+
966
+ # Get model info from request body, BYOK headers, or server config
967
+ model_name = (
968
+ request.model
969
+ or req.headers.get("x-openrouter-model")
970
+ or os.getenv("ANNOTATION_MODEL", "openai/gpt-oss-120b")
971
+ )
972
+ temperature = request.temperature
973
+ if temperature is None:
974
+ temp_header = req.headers.get("x-openrouter-temperature")
975
+ if temp_header is not None:
976
+ try:
977
+ temperature = float(temp_header)
978
+ except ValueError:
979
+ temperature = None
980
+ if temperature is None:
981
+ temperature = _byok_config.get("temperature", 0.1)
982
+
983
+ event = TelemetryEvent.create(
984
+ description=description,
985
+ schema_version=request.schema_version,
986
+ hed_string=current_state.get("current_annotation", ""),
987
+ iterations=current_state.get("validation_attempts", 0),
988
+ validation_errors=current_state.get("validation_errors", []),
989
+ model=model_name,
990
+ provider=request.provider or req.headers.get("x-openrouter-provider"),
991
+ temperature=temperature,
992
+ latency_ms=latency_ms,
993
+ source=source,
994
+ )
995
+ await telemetry_collector.collect(event)
996
+
997
+
930
998
  @app.post("/annotate/stream")
931
999
  async def annotate_stream(
932
1000
  request: AnnotationRequest,
@@ -1042,6 +1110,9 @@ async def annotate_stream(
1042
1110
  # SSE padding comment to force Safari to open the stream
1043
1111
  yield ": stream opened\n\n"
1044
1112
 
1113
+ start_time = time.time()
1114
+ current_state = initial_state.copy()
1115
+
1045
1116
  try:
1046
1117
  # Send initial start event
1047
1118
  yield send_event(
@@ -1049,7 +1120,6 @@ async def annotate_stream(
1049
1120
  )
1050
1121
 
1051
1122
  # Track state and progress
1052
- current_state = initial_state.copy()
1053
1123
  last_stage = None
1054
1124
  validation_attempt = 0
1055
1125
 
@@ -1132,6 +1202,20 @@ async def annotate_stream(
1132
1202
  }
1133
1203
 
1134
1204
  yield send_event("result", result)
1205
+
1206
+ # Collect telemetry after sending result but before done event
1207
+ try:
1208
+ await _collect_stream_telemetry(
1209
+ request=request,
1210
+ req=req,
1211
+ current_state=current_state,
1212
+ start_time=start_time,
1213
+ source="api-stream",
1214
+ description=request.description,
1215
+ )
1216
+ except Exception:
1217
+ logging.warning("Telemetry collection failed for streaming request", exc_info=True)
1218
+
1135
1219
  yield send_event("done", {"message": "Workflow completed"})
1136
1220
 
1137
1221
  except asyncio.CancelledError:
@@ -1145,6 +1229,18 @@ async def annotate_stream(
1145
1229
  "error_type": "timeout",
1146
1230
  },
1147
1231
  )
1232
+ # Collect telemetry on error
1233
+ try:
1234
+ await _collect_stream_telemetry(
1235
+ request=request,
1236
+ req=req,
1237
+ current_state=current_state,
1238
+ start_time=start_time,
1239
+ source="api-stream",
1240
+ description=request.description,
1241
+ )
1242
+ except Exception:
1243
+ logging.warning("Telemetry collection failed on timeout", exc_info=True)
1148
1244
  yield send_event("done", {"message": "Workflow ended with error"})
1149
1245
  except RateLimitError:
1150
1246
  logging.exception("Streaming workflow rate limit")
@@ -1155,6 +1251,18 @@ async def annotate_stream(
1155
1251
  "error_type": "rate_limit",
1156
1252
  },
1157
1253
  )
1254
+ # Collect telemetry on error
1255
+ try:
1256
+ await _collect_stream_telemetry(
1257
+ request=request,
1258
+ req=req,
1259
+ current_state=current_state,
1260
+ start_time=start_time,
1261
+ source="api-stream",
1262
+ description=request.description,
1263
+ )
1264
+ except Exception:
1265
+ logging.warning("Telemetry collection failed on rate limit", exc_info=True)
1158
1266
  yield send_event("done", {"message": "Workflow ended with error"})
1159
1267
  except Exception:
1160
1268
  logging.exception("Streaming workflow error")
@@ -1165,6 +1273,18 @@ async def annotate_stream(
1165
1273
  "error_type": "internal",
1166
1274
  },
1167
1275
  )
1276
+ # Collect telemetry on error
1277
+ try:
1278
+ await _collect_stream_telemetry(
1279
+ request=request,
1280
+ req=req,
1281
+ current_state=current_state,
1282
+ start_time=start_time,
1283
+ source="api-stream",
1284
+ description=request.description,
1285
+ )
1286
+ except Exception:
1287
+ logging.warning("Telemetry collection failed on error", exc_info=True)
1168
1288
  yield send_event("done", {"message": "Workflow ended with error"})
1169
1289
 
1170
1290
  return StreamingResponse(
@@ -1309,6 +1429,10 @@ async def annotate_from_image_stream(
1309
1429
  # SSE padding comment to force Safari to open the stream
1310
1430
  yield ": stream opened\n\n"
1311
1431
 
1432
+ start_time = time.time()
1433
+ current_state: dict = {}
1434
+ image_description = ""
1435
+
1312
1436
  try:
1313
1437
  # Send initial start event
1314
1438
  yield send_event(
@@ -1429,6 +1553,22 @@ async def annotate_from_image_stream(
1429
1553
  }
1430
1554
 
1431
1555
  yield send_event("result", result)
1556
+
1557
+ # Collect telemetry after sending result but before done event
1558
+ try:
1559
+ await _collect_stream_telemetry(
1560
+ request=request,
1561
+ req=req,
1562
+ current_state=current_state,
1563
+ start_time=start_time,
1564
+ source="api-image-stream",
1565
+ description=image_description,
1566
+ )
1567
+ except Exception:
1568
+ logging.debug(
1569
+ "Telemetry collection failed for image streaming request", exc_info=True
1570
+ )
1571
+
1432
1572
  yield send_event("done", {"message": "Workflow completed"})
1433
1573
 
1434
1574
  except asyncio.CancelledError:
@@ -1442,6 +1582,18 @@ async def annotate_from_image_stream(
1442
1582
  "error_type": "timeout",
1443
1583
  },
1444
1584
  )
1585
+ # Collect telemetry on error
1586
+ try:
1587
+ await _collect_stream_telemetry(
1588
+ request=request,
1589
+ req=req,
1590
+ current_state=current_state,
1591
+ start_time=start_time,
1592
+ source="api-image-stream",
1593
+ description=image_description or "image-annotation-failed",
1594
+ )
1595
+ except Exception:
1596
+ logging.warning("Telemetry collection failed on image timeout", exc_info=True)
1445
1597
  yield send_event("done", {"message": "Workflow ended with error"})
1446
1598
  except RateLimitError:
1447
1599
  logging.exception("Streaming image workflow rate limit")
@@ -1452,6 +1604,18 @@ async def annotate_from_image_stream(
1452
1604
  "error_type": "rate_limit",
1453
1605
  },
1454
1606
  )
1607
+ # Collect telemetry on error
1608
+ try:
1609
+ await _collect_stream_telemetry(
1610
+ request=request,
1611
+ req=req,
1612
+ current_state=current_state,
1613
+ start_time=start_time,
1614
+ source="api-image-stream",
1615
+ description=image_description or "image-annotation-failed",
1616
+ )
1617
+ except Exception:
1618
+ logging.warning("Telemetry collection failed on image rate limit", exc_info=True)
1455
1619
  yield send_event("done", {"message": "Workflow ended with error"})
1456
1620
  except Exception:
1457
1621
  logging.exception("Streaming image annotation workflow error")
@@ -1462,6 +1626,18 @@ async def annotate_from_image_stream(
1462
1626
  "error_type": "internal",
1463
1627
  },
1464
1628
  )
1629
+ # Collect telemetry on error
1630
+ try:
1631
+ await _collect_stream_telemetry(
1632
+ request=request,
1633
+ req=req,
1634
+ current_state=current_state,
1635
+ start_time=start_time,
1636
+ source="api-image-stream",
1637
+ description=image_description or "image-annotation-failed",
1638
+ )
1639
+ except Exception:
1640
+ logging.warning("Telemetry collection failed on image error", exc_info=True)
1465
1641
  yield send_event("done", {"message": "Workflow ended with error"})
1466
1642
 
1467
1643
  return StreamingResponse(
@@ -1,7 +1,7 @@
1
1
  """Version information for HEDit."""
2
2
 
3
- __version__ = "0.7.5.dev1"
4
- __version_info__ = (0, 7, 5, "dev")
3
+ __version__ = "0.7.6.dev1"
4
+ __version_info__ = (0, 7, 6, "dev")
5
5
 
6
6
 
7
7
  def get_version() -> str:
@@ -8,6 +8,7 @@ App is imported inside the fixture to avoid polluting global state.
8
8
 
9
9
  import importlib
10
10
  import os
11
+ import time
11
12
  from unittest.mock import MagicMock, patch
12
13
 
13
14
  import pytest
@@ -841,3 +842,433 @@ class TestTelemetryCollectorIntegration:
841
842
 
842
843
  assert event.source == "api-image"
843
844
  assert event.model.provider == "deepinfra/fp8"
845
+
846
+ def test_telemetry_event_stream_source(self):
847
+ """Test creating a telemetry event with api-stream source."""
848
+ from src.telemetry import TelemetryEvent
849
+
850
+ event = TelemetryEvent.create(
851
+ description="Streaming annotation request",
852
+ schema_version="8.4.0",
853
+ hed_string="Sensory-event, Visual-presentation",
854
+ iterations=2,
855
+ validation_errors=[],
856
+ model="anthropic/claude-haiku-4.5",
857
+ provider="anthropic",
858
+ temperature=0.1,
859
+ latency_ms=2000,
860
+ source="api-stream",
861
+ )
862
+
863
+ assert event.source == "api-stream"
864
+ assert event.input.description == "Streaming annotation request"
865
+ assert event.performance.latency_ms == 2000
866
+
867
+ def test_telemetry_event_image_stream_source(self):
868
+ """Test creating a telemetry event with api-image-stream source."""
869
+ from src.telemetry import TelemetryEvent
870
+
871
+ event = TelemetryEvent.create(
872
+ description="Image description from vision model",
873
+ schema_version="8.4.0",
874
+ hed_string="Visual-presentation",
875
+ iterations=1,
876
+ validation_errors=[],
877
+ model="openai/gpt-4o",
878
+ provider="deepinfra/fp8",
879
+ temperature=0.3,
880
+ latency_ms=4000,
881
+ source="api-image-stream",
882
+ )
883
+
884
+ assert event.source == "api-image-stream"
885
+ assert event.input.description == "Image description from vision model"
886
+
887
+
888
+ class TestStreamingTelemetry:
889
+ """Tests for telemetry collection in streaming endpoints."""
890
+
891
+ @pytest.fixture
892
+ def client_with_telemetry(self):
893
+ """Create a test client with mocked workflow and telemetry collector."""
894
+ original_env = {}
895
+ for key in ["REQUIRE_API_AUTH", "API_KEYS", "OPENROUTER_API_KEY"]:
896
+ if key in os.environ:
897
+ original_env[key] = os.environ[key]
898
+
899
+ os.environ["REQUIRE_API_AUTH"] = "true"
900
+ os.environ["API_KEYS"] = "test-api-key-for-unit-tests"
901
+ os.environ["OPENROUTER_API_KEY"] = "test-openrouter-key"
902
+
903
+ from src.api import security
904
+
905
+ importlib.reload(security)
906
+
907
+ # Create mock workflow
908
+ mock_workflow = MagicMock()
909
+ mock_graph = MagicMock()
910
+
911
+ async def mock_stream_events(*args, **kwargs):
912
+ yield {"event": "on_chain_start", "name": "annotate", "data": {}}
913
+ yield {
914
+ "event": "on_chain_end",
915
+ "name": "annotate",
916
+ "data": {
917
+ "output": {
918
+ "current_annotation": "Sensory-event, Visual-presentation",
919
+ "validation_attempts": 1,
920
+ }
921
+ },
922
+ }
923
+ yield {"event": "on_chain_start", "name": "validate", "data": {}}
924
+ yield {
925
+ "event": "on_chain_end",
926
+ "name": "validate",
927
+ "data": {"output": {"is_valid": True, "validation_errors": []}},
928
+ }
929
+ yield {"event": "on_chain_start", "name": "evaluate", "data": {}}
930
+ yield {
931
+ "event": "on_chain_end",
932
+ "name": "evaluate",
933
+ "data": {"output": {"is_faithful": True, "is_complete": True}},
934
+ }
935
+
936
+ mock_graph.astream_events = mock_stream_events
937
+ mock_workflow.graph = mock_graph
938
+
939
+ # Create mock telemetry collector with a list to track collected events
940
+ mock_collector = MagicMock()
941
+ collected_events = []
942
+
943
+ async def track_collect(event):
944
+ collected_events.append(event)
945
+ return True
946
+
947
+ mock_collector.collect = track_collect
948
+
949
+ with (
950
+ patch("src.api.main.workflow", mock_workflow),
951
+ patch("src.api.main.telemetry_collector", mock_collector),
952
+ ):
953
+ from src.api.main import app
954
+
955
+ client = TestClient(app, raise_server_exceptions=False)
956
+ yield client, collected_events
957
+
958
+ for key in ["REQUIRE_API_AUTH", "API_KEYS", "OPENROUTER_API_KEY"]:
959
+ if key in original_env:
960
+ os.environ[key] = original_env[key]
961
+ elif key in os.environ:
962
+ del os.environ[key]
963
+
964
+ importlib.reload(security)
965
+
966
+ @pytest.fixture
967
+ def client_with_telemetry_disabled(self):
968
+ """Create a test client with mocked workflow but no telemetry collector."""
969
+ original_env = {}
970
+ for key in ["REQUIRE_API_AUTH", "API_KEYS", "OPENROUTER_API_KEY"]:
971
+ if key in os.environ:
972
+ original_env[key] = os.environ[key]
973
+
974
+ os.environ["REQUIRE_API_AUTH"] = "true"
975
+ os.environ["API_KEYS"] = "test-api-key-for-unit-tests"
976
+ os.environ["OPENROUTER_API_KEY"] = "test-openrouter-key"
977
+
978
+ from src.api import security
979
+
980
+ importlib.reload(security)
981
+
982
+ mock_workflow = MagicMock()
983
+ mock_graph = MagicMock()
984
+
985
+ async def mock_stream_events(*args, **kwargs):
986
+ yield {"event": "on_chain_start", "name": "annotate", "data": {}}
987
+ yield {
988
+ "event": "on_chain_end",
989
+ "name": "annotate",
990
+ "data": {
991
+ "output": {
992
+ "current_annotation": "Sensory-event",
993
+ "validation_attempts": 1,
994
+ }
995
+ },
996
+ }
997
+ yield {"event": "on_chain_start", "name": "validate", "data": {}}
998
+ yield {
999
+ "event": "on_chain_end",
1000
+ "name": "validate",
1001
+ "data": {"output": {"is_valid": True, "validation_errors": []}},
1002
+ }
1003
+
1004
+ mock_graph.astream_events = mock_stream_events
1005
+ mock_workflow.graph = mock_graph
1006
+
1007
+ # Track if collect was called
1008
+ collected_events = []
1009
+
1010
+ async def track_collect(event):
1011
+ collected_events.append(event)
1012
+ return True
1013
+
1014
+ mock_collector = MagicMock()
1015
+ mock_collector.collect = track_collect
1016
+
1017
+ with (
1018
+ patch("src.api.main.workflow", mock_workflow),
1019
+ patch("src.api.main.telemetry_collector", mock_collector),
1020
+ ):
1021
+ from src.api.main import app
1022
+
1023
+ client = TestClient(app, raise_server_exceptions=False)
1024
+ yield client, collected_events
1025
+
1026
+ for key in ["REQUIRE_API_AUTH", "API_KEYS", "OPENROUTER_API_KEY"]:
1027
+ if key in original_env:
1028
+ os.environ[key] = original_env[key]
1029
+ elif key in os.environ:
1030
+ del os.environ[key]
1031
+
1032
+ importlib.reload(security)
1033
+
1034
+ @pytest.fixture
1035
+ def client_with_failing_workflow(self):
1036
+ """Create a test client with a workflow that raises an error."""
1037
+ original_env = {}
1038
+ for key in ["REQUIRE_API_AUTH", "API_KEYS", "OPENROUTER_API_KEY"]:
1039
+ if key in os.environ:
1040
+ original_env[key] = os.environ[key]
1041
+
1042
+ os.environ["REQUIRE_API_AUTH"] = "true"
1043
+ os.environ["API_KEYS"] = "test-api-key-for-unit-tests"
1044
+ os.environ["OPENROUTER_API_KEY"] = "test-openrouter-key"
1045
+
1046
+ from src.api import security
1047
+
1048
+ importlib.reload(security)
1049
+
1050
+ mock_workflow = MagicMock()
1051
+ mock_graph = MagicMock()
1052
+
1053
+ async def mock_stream_events_error(*args, **kwargs):
1054
+ yield {"event": "on_chain_start", "name": "annotate", "data": {}}
1055
+ yield {
1056
+ "event": "on_chain_end",
1057
+ "name": "annotate",
1058
+ "data": {
1059
+ "output": {
1060
+ "current_annotation": "Partial-annotation",
1061
+ "validation_attempts": 0,
1062
+ }
1063
+ },
1064
+ }
1065
+ raise RuntimeError("Simulated workflow failure")
1066
+
1067
+ mock_graph.astream_events = mock_stream_events_error
1068
+ mock_workflow.graph = mock_graph
1069
+
1070
+ collected_events = []
1071
+
1072
+ async def track_collect(event):
1073
+ collected_events.append(event)
1074
+ return True
1075
+
1076
+ mock_collector = MagicMock()
1077
+ mock_collector.collect = track_collect
1078
+
1079
+ with (
1080
+ patch("src.api.main.workflow", mock_workflow),
1081
+ patch("src.api.main.telemetry_collector", mock_collector),
1082
+ ):
1083
+ from src.api.main import app
1084
+
1085
+ client = TestClient(app, raise_server_exceptions=False)
1086
+ yield client, collected_events
1087
+
1088
+ for key in ["REQUIRE_API_AUTH", "API_KEYS", "OPENROUTER_API_KEY"]:
1089
+ if key in original_env:
1090
+ os.environ[key] = original_env[key]
1091
+ elif key in os.environ:
1092
+ del os.environ[key]
1093
+
1094
+ importlib.reload(security)
1095
+
1096
+ def test_stream_telemetry_collected_on_success(self, client_with_telemetry):
1097
+ """Test that telemetry is collected for successful streaming requests."""
1098
+ client, collected_events = client_with_telemetry
1099
+ request_data = {
1100
+ "description": "A red circle appears on screen",
1101
+ "schema_version": "8.3.0",
1102
+ "telemetry_enabled": True,
1103
+ }
1104
+ response = client.post("/annotate/stream", json=request_data, headers=TEST_AUTH_HEADERS)
1105
+ assert response.status_code == 200
1106
+ assert len(collected_events) == 1
1107
+
1108
+ event = collected_events[0]
1109
+ assert event.source == "api-stream"
1110
+ assert event.input.description == "A red circle appears on screen"
1111
+ assert event.input.schema_version == "8.3.0"
1112
+ assert event.performance.latency_ms >= 0
1113
+
1114
+ def test_stream_telemetry_not_collected_when_disabled(self, client_with_telemetry_disabled):
1115
+ """Test that telemetry is not collected when telemetry_enabled=False."""
1116
+ client, collected_events = client_with_telemetry_disabled
1117
+ request_data = {
1118
+ "description": "A red circle appears on screen",
1119
+ "schema_version": "8.3.0",
1120
+ "telemetry_enabled": False,
1121
+ }
1122
+ response = client.post("/annotate/stream", json=request_data, headers=TEST_AUTH_HEADERS)
1123
+ assert response.status_code == 200
1124
+ assert len(collected_events) == 0
1125
+
1126
+ def test_stream_telemetry_collected_on_workflow_error(self, client_with_failing_workflow):
1127
+ """Test that telemetry is collected even when workflow fails."""
1128
+ client, collected_events = client_with_failing_workflow
1129
+ request_data = {
1130
+ "description": "A red circle appears on screen",
1131
+ "schema_version": "8.3.0",
1132
+ "telemetry_enabled": True,
1133
+ }
1134
+ response = client.post("/annotate/stream", json=request_data, headers=TEST_AUTH_HEADERS)
1135
+ assert response.status_code == 200
1136
+ # Telemetry should still be collected on error
1137
+ assert len(collected_events) == 1
1138
+
1139
+ event = collected_events[0]
1140
+ assert event.source == "api-stream"
1141
+ # Partial state should be captured
1142
+ assert event.output.hed_string == "Partial-annotation"
1143
+
1144
+ def test_stream_telemetry_has_correct_model_info(self, client_with_telemetry):
1145
+ """Test that telemetry captures model info from headers."""
1146
+ client, collected_events = client_with_telemetry
1147
+ request_data = {
1148
+ "description": "A blue square flashes",
1149
+ "schema_version": "8.4.0",
1150
+ "telemetry_enabled": True,
1151
+ "model": "anthropic/claude-haiku-4.5",
1152
+ "provider": "anthropic",
1153
+ "temperature": 0.3,
1154
+ }
1155
+ response = client.post("/annotate/stream", json=request_data, headers=TEST_AUTH_HEADERS)
1156
+ assert response.status_code == 200
1157
+ assert len(collected_events) == 1
1158
+
1159
+ event = collected_events[0]
1160
+ assert event.model.model == "anthropic/claude-haiku-4.5"
1161
+ assert event.model.provider == "anthropic"
1162
+ assert event.model.temperature == 0.3
1163
+
1164
+ def test_stream_telemetry_result_still_sent(self, client_with_telemetry):
1165
+ """Test that result and done events are still sent with telemetry."""
1166
+ client, collected_events = client_with_telemetry
1167
+ request_data = {
1168
+ "description": "Test event",
1169
+ "schema_version": "8.3.0",
1170
+ "telemetry_enabled": True,
1171
+ }
1172
+ response = client.post("/annotate/stream", json=request_data, headers=TEST_AUTH_HEADERS)
1173
+ assert response.status_code == 200
1174
+ content = response.text
1175
+ # Result and done events should still be present
1176
+ assert "event: result" in content
1177
+ assert "event: done" in content
1178
+
1179
+
1180
+ class TestCollectStreamTelemetryHelper:
1181
+ """Tests for the _collect_stream_telemetry helper function."""
1182
+
1183
+ def test_helper_function_exists(self):
1184
+ """Test that _collect_stream_telemetry is importable."""
1185
+ from src.api.main import _collect_stream_telemetry
1186
+
1187
+ assert callable(_collect_stream_telemetry)
1188
+
1189
+ @pytest.mark.asyncio
1190
+ async def test_helper_skips_when_telemetry_disabled(self):
1191
+ """Test helper returns without collecting when telemetry is disabled."""
1192
+ from src.api.main import _collect_stream_telemetry
1193
+ from src.api.models import AnnotationRequest
1194
+
1195
+ request = AnnotationRequest(description="Test", telemetry_enabled=False)
1196
+ mock_req = MagicMock()
1197
+
1198
+ # Should not raise even with no collector
1199
+ with patch("src.api.main.telemetry_collector", None):
1200
+ await _collect_stream_telemetry(
1201
+ request=request,
1202
+ req=mock_req,
1203
+ current_state={},
1204
+ start_time=time.time(),
1205
+ source="api-stream",
1206
+ description="Test",
1207
+ )
1208
+
1209
+ @pytest.mark.asyncio
1210
+ async def test_helper_skips_when_collector_is_none(self):
1211
+ """Test helper returns without collecting when collector is None."""
1212
+ from src.api.main import _collect_stream_telemetry
1213
+ from src.api.models import AnnotationRequest
1214
+
1215
+ request = AnnotationRequest(description="Test", telemetry_enabled=True)
1216
+ mock_req = MagicMock()
1217
+
1218
+ with patch("src.api.main.telemetry_collector", None):
1219
+ await _collect_stream_telemetry(
1220
+ request=request,
1221
+ req=mock_req,
1222
+ current_state={},
1223
+ start_time=time.time(),
1224
+ source="api-stream",
1225
+ description="Test",
1226
+ )
1227
+
1228
+ @pytest.mark.asyncio
1229
+ async def test_helper_collects_when_enabled(self):
1230
+ """Test helper collects telemetry when enabled with collector."""
1231
+ from src.api.main import _collect_stream_telemetry
1232
+ from src.api.models import AnnotationRequest
1233
+
1234
+ request = AnnotationRequest(
1235
+ description="Test event description",
1236
+ schema_version="8.4.0",
1237
+ telemetry_enabled=True,
1238
+ model="test/model",
1239
+ temperature=0.2,
1240
+ )
1241
+
1242
+ mock_req = MagicMock()
1243
+ mock_req.headers = {}
1244
+
1245
+ collected = []
1246
+
1247
+ async def mock_collect(event):
1248
+ collected.append(event)
1249
+ return True
1250
+
1251
+ mock_collector = MagicMock()
1252
+ mock_collector.collect = mock_collect
1253
+
1254
+ with patch("src.api.main.telemetry_collector", mock_collector):
1255
+ await _collect_stream_telemetry(
1256
+ request=request,
1257
+ req=mock_req,
1258
+ current_state={
1259
+ "current_annotation": "Sensory-event",
1260
+ "validation_attempts": 2,
1261
+ "validation_errors": [],
1262
+ },
1263
+ start_time=time.time() - 1.5,
1264
+ source="api-stream",
1265
+ description="Test event description",
1266
+ )
1267
+
1268
+ assert len(collected) == 1
1269
+ event = collected[0]
1270
+ assert event.source == "api-stream"
1271
+ assert event.input.description == "Test event description"
1272
+ assert event.output.hed_string == "Sensory-event"
1273
+ assert event.output.iterations == 2
1274
+ assert event.performance.latency_ms >= 1400 # ~1.5 seconds
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes