simpleaudit 0.2.0__tar.gz → 0.2.1__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 (102) hide show
  1. {simpleaudit-0.2.0/simpleaudit.egg-info → simpleaudit-0.2.1}/PKG-INFO +11 -4
  2. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/README.md +10 -3
  3. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/pyproject.toml +1 -1
  4. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/cli.py +11 -2
  5. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/experiment.py +9 -1
  6. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/model_auditor.py +36 -0
  7. {simpleaudit-0.2.0 → simpleaudit-0.2.1/simpleaudit.egg-info}/PKG-INFO +11 -4
  8. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_experiment_streaming.py +73 -0
  9. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_model_auditor.py +169 -1
  10. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/LICENSE +0 -0
  11. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/setup.cfg +0 -0
  12. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/__init__.py +0 -0
  13. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/checklist.py +0 -0
  14. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/context_marks.py +0 -0
  15. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/cross_judge.py +0 -0
  16. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/judges/__init__.py +0 -0
  17. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/judges/abstention.py +0 -0
  18. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/judges/binary_abstention.py +0 -0
  19. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/judges/checklist.py +0 -0
  20. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/judges/factuality.py +0 -0
  21. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/judges/harm.py +0 -0
  22. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/judges/helpfulness.py +0 -0
  23. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/judges/helsedir_sexhealth_no.py +0 -0
  24. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/judges/helsedir_sexhealth_no_rag.py +0 -0
  25. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/judges/safety.py +0 -0
  26. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/perturbations.py +0 -0
  27. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/reframing.py +0 -0
  28. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/repeated_results.py +0 -0
  29. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/results.py +0 -0
  30. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/__init__.py +0 -0
  31. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/arbeidstilsynet_arbeidstid.py +0 -0
  32. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/bullshitbench_health.py +0 -0
  33. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/bullshitbench_v1_v2.py +0 -0
  34. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/health.py +0 -0
  35. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/hei_refusal.py +0 -0
  36. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/helfo.py +0 -0
  37. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/helpmed.py +0 -0
  38. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/human_rights_education.py +0 -0
  39. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/human_rights_fair_trial.py +0 -0
  40. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/human_rights_water.py +0 -0
  41. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/images/blank_chart.png +0 -0
  42. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/images/illegible_label.png +0 -0
  43. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/images/injection.png +0 -0
  44. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/images/make_images.py +0 -0
  45. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/images/product_a.png +0 -0
  46. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/images/product_b.png +0 -0
  47. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/images/quarterly_revenue.png +0 -0
  48. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/images/truncated_axis.png +0 -0
  49. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/lanekassen.py +0 -0
  50. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/nav_aap.py +0 -0
  51. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/nb_kryss_ordning.py +0 -0
  52. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/rag.py +0 -0
  53. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/safety.py +0 -0
  54. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/skatteetaten.py +0 -0
  55. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/skatteetaten_legitimasjon.py +0 -0
  56. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/system_prompt.py +0 -0
  57. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/toll_reisegodskvote.py +0 -0
  58. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/ung.py +0 -0
  59. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/scenarios/vision_integrity.py +0 -0
  60. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/spans.py +0 -0
  61. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/utils.py +0 -0
  62. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/visualization/scenario_viewer.html +0 -0
  63. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/visualization/server.py +0 -0
  64. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/visualization/thumbnail.png +0 -0
  65. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit/visualization/visualizer.html +0 -0
  66. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit.egg-info/SOURCES.txt +0 -0
  67. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit.egg-info/dependency_links.txt +0 -0
  68. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit.egg-info/entry_points.txt +0 -0
  69. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit.egg-info/requires.txt +0 -0
  70. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/simpleaudit.egg-info/top_level.txt +0 -0
  71. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_anthropic_judge_default_path.py +0 -0
  72. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_audit_flow.py +0 -0
  73. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_basic.py +0 -0
  74. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_checklist.py +0 -0
  75. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_checklist_judge.py +0 -0
  76. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_checklist_pipeline.py +0 -0
  77. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_checklist_reframing.py +0 -0
  78. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_cross_judge.py +0 -0
  79. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_custom_prompts.py +0 -0
  80. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_document_expansion.py +0 -0
  81. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_fakes.py +0 -0
  82. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_file_uri.py +0 -0
  83. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_fragility.py +0 -0
  84. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_generation_params.py +0 -0
  85. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_json_format.py +0 -0
  86. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_judge_notes.py +0 -0
  87. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_judge_postprocess.py +0 -0
  88. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_judge_registry.py +0 -0
  89. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_judge_response_schema.py +0 -0
  90. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_judge_robustness_pipeline.py +0 -0
  91. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_perturbations.py +0 -0
  92. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_reframing.py +0 -0
  93. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_rejudge.py +0 -0
  94. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_repeated_experiments.py +0 -0
  95. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_resumable_experiments.py +0 -0
  96. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_scenario_data.py +0 -0
  97. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_scenario_pack_conventions.py +0 -0
  98. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_spans.py +0 -0
  99. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_strip_thinking.py +0 -0
  100. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_target_api_key.py +0 -0
  101. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_token_counting.py +0 -0
  102. {simpleaudit-0.2.0 → simpleaudit-0.2.1}/tests/test_vision_integrity_pack.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simpleaudit
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Lightweight AI Safety Auditing Framework
5
5
  Author: SimpleAudit Contributors
6
6
  License: MIT
@@ -162,10 +162,10 @@ results.save("./my_audit_results/audit_results.json")
162
162
  **💡 View results interactively:**
163
163
  ```bash
164
164
  # Option 1: Run directly with uvx (no installation needed, requires uv)
165
- uvx simpleaudit[visualize] serve --results_dir ./my_audit_results
165
+ uvx 'simpleaudit[visualize]' serve --results_dir ./my_audit_results
166
166
 
167
167
  # Option 2: Install and run locally
168
- pip install simpleaudit[visualize]
168
+ pip install 'simpleaudit[visualize]'
169
169
  simpleaudit serve --results_dir ./my_audit_results
170
170
  ```
171
171
  This will spin-up a local web server to explore results with scenario details. 👉 [Check for live demo.](https://simulamet-simpleauditvisualization.hf.space)
@@ -173,7 +173,14 @@ See [visualization/README.md](https://github.com/kelkalot/simpleaudit/blob/main/
173
173
 
174
174
  To share results as a single self-contained HTML file (no server, no JSON upload), use `simpleaudit export-html ./audit_results.json` or the **Download HTML** button in the visualizer.
175
175
 
176
- > **Note:** Option 1 requires [`uv`](https://pypi.org/project/uv/) to be installed ([install guide](https://docs.astral.sh/uv/getting-started/installation/)).
176
+ > **Note:** Option 1 requires [`uv`](https://pypi.org/project/uv/) to be installed ([install guide](https://docs.astral.sh/uv/getting-started/installation/)). The package spec is quoted so your shell doesn't glob-expand `[visualize]`.
177
+
178
+ <Troubleshooting>
179
+ If the command fails:
180
+ - **`Address already in use`** — port 8000 is taken. Use `--port 8080` or stop the other process.
181
+ - **`uvx: command not found`** — install `uv` first (`curl -LsSf https://astral.sh/uv/install.sh | sh`).
182
+ - **Weird import/module error** — stale cache. Run `uv cache clean` and retry.
183
+ </Troubleshooting>
177
184
 
178
185
  [![simpleaudit-visualization-ui](https://github.com/user-attachments/assets/f9bbb891-a847-48d4-85d6-6d6d99c9e017)](https://github.com/kelkalot/simpleaudit/blob/main/simpleaudit/visualization/README.md)
179
186
 
@@ -120,10 +120,10 @@ results.save("./my_audit_results/audit_results.json")
120
120
  **💡 View results interactively:**
121
121
  ```bash
122
122
  # Option 1: Run directly with uvx (no installation needed, requires uv)
123
- uvx simpleaudit[visualize] serve --results_dir ./my_audit_results
123
+ uvx 'simpleaudit[visualize]' serve --results_dir ./my_audit_results
124
124
 
125
125
  # Option 2: Install and run locally
126
- pip install simpleaudit[visualize]
126
+ pip install 'simpleaudit[visualize]'
127
127
  simpleaudit serve --results_dir ./my_audit_results
128
128
  ```
129
129
  This will spin-up a local web server to explore results with scenario details. 👉 [Check for live demo.](https://simulamet-simpleauditvisualization.hf.space)
@@ -131,7 +131,14 @@ See [visualization/README.md](https://github.com/kelkalot/simpleaudit/blob/main/
131
131
 
132
132
  To share results as a single self-contained HTML file (no server, no JSON upload), use `simpleaudit export-html ./audit_results.json` or the **Download HTML** button in the visualizer.
133
133
 
134
- > **Note:** Option 1 requires [`uv`](https://pypi.org/project/uv/) to be installed ([install guide](https://docs.astral.sh/uv/getting-started/installation/)).
134
+ > **Note:** Option 1 requires [`uv`](https://pypi.org/project/uv/) to be installed ([install guide](https://docs.astral.sh/uv/getting-started/installation/)). The package spec is quoted so your shell doesn't glob-expand `[visualize]`.
135
+
136
+ <Troubleshooting>
137
+ If the command fails:
138
+ - **`Address already in use`** — port 8000 is taken. Use `--port 8080` or stop the other process.
139
+ - **`uvx: command not found`** — install `uv` first (`curl -LsSf https://astral.sh/uv/install.sh | sh`).
140
+ - **Weird import/module error** — stale cache. Run `uv cache clean` and retry.
141
+ </Troubleshooting>
135
142
 
136
143
  [![simpleaudit-visualization-ui](https://github.com/user-attachments/assets/f9bbb891-a847-48d4-85d6-6d6d99c9e017)](https://github.com/kelkalot/simpleaudit/blob/main/simpleaudit/visualization/README.md)
137
144
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simpleaudit"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Lightweight AI Safety Auditing Framework"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -71,8 +71,17 @@ def main():
71
71
  results_dir = "."
72
72
  print("⚠️ Warning: --results_dir not specified, using current directory '.'")
73
73
  print(" Recommended: explicitly set --results_dir to avoid confusion\n")
74
-
75
- start_server(results_dir, args.host, args.port)
74
+
75
+ try:
76
+ start_server(results_dir, args.host, args.port)
77
+ except OSError as exc:
78
+ if "Address already in use" in str(exc) or "Errno 48" in str(exc):
79
+ print(f"Error: port {args.port} is already in use.")
80
+ print(f"Try a different port: simpleaudit serve --results_dir {results_dir} --port 8080")
81
+ print("Or stop the process currently using that port.\n")
82
+ else:
83
+ raise
84
+ sys.exit(1)
76
85
  elif args.command == "export-html":
77
86
  try:
78
87
  from .visualization.server import export_standalone_html
@@ -305,6 +305,7 @@ class AuditExperiment:
305
305
  max_turns: Optional[int],
306
306
  language: str,
307
307
  max_workers: int,
308
+ on_turn: Optional[Callable[[int, int, str], None]] = None,
308
309
  ) -> AuditResults:
309
310
  """Execute one rep with auto-retry on ERROR. Returns the final result."""
310
311
  attempts = 1 + self.max_retries_per_rep
@@ -316,6 +317,7 @@ class AuditExperiment:
316
317
  max_turns=max_turns,
317
318
  language=language,
318
319
  max_workers=max_workers,
320
+ on_turn=on_turn,
319
321
  )
320
322
  if not any(r.severity == "ERROR" for r in result):
321
323
  break
@@ -335,6 +337,7 @@ class AuditExperiment:
335
337
  scenario: Dict[str, Any],
336
338
  max_turns: Optional[int] = None,
337
339
  language: str = "English",
340
+ on_turn: Optional[Callable[[int, int, str], None]] = None,
338
341
  ) -> List[AuditResult]:
339
342
  """Run a single scenario N times for one model.
340
343
 
@@ -348,6 +351,10 @@ class AuditExperiment:
348
351
  scenario: A single scenario dict.
349
352
  max_turns: Override for max conversation turns.
350
353
  language: Language for probe generation.
354
+ on_turn: Optional callback fired at each phase boundary with
355
+ ``(turn_index, max_turns, role)`` where role is "auditor",
356
+ "target", or "judge". Called synchronously from within the
357
+ asyncio event loop.
351
358
 
352
359
  Returns:
353
360
  List of :class:`AuditResult`, one per completed rep. May be
@@ -397,7 +404,8 @@ class AuditExperiment:
397
404
 
398
405
  # Execute with auto-retry
399
406
  rep_result = await self._run_single_rep(
400
- merged, [scenario], max_turns, language, max_workers=1
407
+ merged, [scenario], max_turns, language, max_workers=1,
408
+ on_turn=on_turn,
401
409
  )
402
410
 
403
411
  # Persist
@@ -15,6 +15,7 @@ import asyncio
15
15
  import json
16
16
  import re
17
17
  import threading
18
+ import traceback
18
19
  import warnings
19
20
  from datetime import date
20
21
  from importlib.metadata import PackageNotFoundError
@@ -309,6 +310,7 @@ class ModelAuditor:
309
310
  target_params: Optional[Dict[str, Any]] = None,
310
311
  judge_params: Optional[Dict[str, Any]] = None,
311
312
  auditor_params: Optional[Dict[str, Any]] = None,
313
+ on_turn: Optional[Callable[[int, int, str], None]] = None,
312
314
  ):
313
315
  if max_retries < 0:
314
316
  raise ValueError(f"max_retries must be >= 0, got {max_retries}")
@@ -319,6 +321,7 @@ class ModelAuditor:
319
321
  self.json_format = json_format
320
322
  self.max_retries = max_retries
321
323
  self.retry_backoff = retry_backoff
324
+ self.on_turn = on_turn
322
325
  self.judge_fields = judge_fields
323
326
  self.params = params
324
327
  self.target_params = target_params
@@ -489,6 +492,26 @@ class ModelAuditor:
489
492
  message = f"[{name}] {message}"
490
493
  tqdm.write(message)
491
494
 
495
+ def _fire_on_turn(
496
+ self,
497
+ turn_index: int,
498
+ max_turns: int,
499
+ role: str,
500
+ callback: Optional[Callable[[int, int, str], None]] = None,
501
+ ) -> None:
502
+ """Invoke an on_turn callback if set. Never raises.
503
+
504
+ Uses ``callback`` when provided, otherwise falls back to the
505
+ construction-time ``self.on_turn``. A raising callback is logged,
506
+ not propagated — a progress observer must never fail an audit.
507
+ """
508
+ cb = callback if callback is not None else self.on_turn
509
+ if cb is not None:
510
+ try:
511
+ cb(turn_index, max_turns, role)
512
+ except Exception:
513
+ self._log(f"on_turn callback failed:\n{traceback.format_exc()}")
514
+
492
515
  @staticmethod
493
516
  def strip_thinking(text: str) -> str:
494
517
  # Remove complete <think>...</think> / <thinking>...</thinking> blocks.
@@ -809,6 +832,7 @@ Evaluate this conversation and respond with this exact JSON structure:
809
832
  target_params: Optional[Dict[str, Any]] = None,
810
833
  judge_params: Optional[Dict[str, Any]] = None,
811
834
  auditor_params: Optional[Dict[str, Any]] = None,
835
+ on_turn: Optional[Callable[[int, int, str], None]] = None,
812
836
  ) -> AuditResult:
813
837
  turns = max_turns or self.max_turns
814
838
  base = {**(self.params or {}), **(params or {})}
@@ -816,6 +840,9 @@ Evaluate this conversation and respond with this exact JSON structure:
816
840
  effective_judge = {**base, **(self.judge_params or {}), **(judge_params or {})}
817
841
  effective_auditor = {**base, **(self.auditor_params or {}), **(auditor_params or {})}
818
842
 
843
+ # A per-call on_turn overrides one set at construction time; either may be used.
844
+ effective_on_turn = on_turn if on_turn is not None else self.on_turn
845
+
819
846
  mode_str = " (Parallel)" if (max_workers or 1) > 1 else ""
820
847
  self._log(f"--- Started Scenario: {name}{mode_str} ---")
821
848
 
@@ -858,6 +885,7 @@ Evaluate this conversation and respond with this exact JSON structure:
858
885
  auditor_input_tokens += a_in
859
886
  auditor_output_tokens += a_out
860
887
  probe = ModelAuditor.strip_thinking(probe)
888
+ self._fire_on_turn(turn, turns, "auditor", effective_on_turn)
861
889
 
862
890
  probe_preview = probe[:80] + "..." if len(probe) > 80 else probe
863
891
  self._log(f"PROBE: {probe_preview}", name=name)
@@ -885,6 +913,7 @@ Evaluate this conversation and respond with this exact JSON structure:
885
913
  target_input_tokens += t_in
886
914
  target_output_tokens += t_out
887
915
  response = ModelAuditor.strip_thinking(response)
916
+ self._fire_on_turn(turn, turns, "target", effective_on_turn)
888
917
 
889
918
  response_preview = response[:80] + "..." if len(response) > 80 else response
890
919
  self._log(f"TARGET: {response_preview}", name=name)
@@ -927,6 +956,9 @@ Evaluate this conversation and respond with this exact JSON structure:
927
956
  )
928
957
  judge_input_tokens += j_in
929
958
  judge_output_tokens += j_out
959
+ # The judge runs once after all turns complete; report it against
960
+ # the final turn index so consumers can place it at the end.
961
+ self._fire_on_turn(turns - 1, turns, "judge", effective_on_turn)
930
962
  if fell_back and isinstance(judgment, dict):
931
963
  judgment["judge_fallback"] = "default"
932
964
  except Exception as exc:
@@ -989,6 +1021,7 @@ Evaluate this conversation and respond with this exact JSON structure:
989
1021
  target_params: Optional[Dict[str, Any]] = None,
990
1022
  judge_params: Optional[Dict[str, Any]] = None,
991
1023
  auditor_params: Optional[Dict[str, Any]] = None,
1024
+ on_turn: Optional[Callable[[int, int, str], None]] = None,
992
1025
  ) -> AuditResults:
993
1026
  if max_workers < 1:
994
1027
  raise ValueError(
@@ -1058,6 +1091,7 @@ Evaluate this conversation and respond with this exact JSON structure:
1058
1091
  target_params=target_params,
1059
1092
  judge_params=judge_params,
1060
1093
  auditor_params=auditor_params,
1094
+ on_turn=on_turn,
1061
1095
  )
1062
1096
  except Exception as exc:
1063
1097
  # Don't let one failing scenario abort the whole batch and
@@ -1114,6 +1148,7 @@ Evaluate this conversation and respond with this exact JSON structure:
1114
1148
  target_params: Optional[Dict[str, Any]] = None,
1115
1149
  judge_params: Optional[Dict[str, Any]] = None,
1116
1150
  auditor_params: Optional[Dict[str, Any]] = None,
1151
+ on_turn: Optional[Callable[[int, int, str], None]] = None,
1117
1152
  ) -> AuditResults:
1118
1153
  try:
1119
1154
  asyncio.get_running_loop()
@@ -1128,6 +1163,7 @@ Evaluate this conversation and respond with this exact JSON structure:
1128
1163
  target_params=target_params,
1129
1164
  judge_params=judge_params,
1130
1165
  auditor_params=auditor_params,
1166
+ on_turn=on_turn,
1131
1167
  )
1132
1168
  )
1133
1169
  msg = "ModelAuditor.run() cannot be called from an active event loop. Use await <object>.run_async()."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simpleaudit
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Lightweight AI Safety Auditing Framework
5
5
  Author: SimpleAudit Contributors
6
6
  License: MIT
@@ -162,10 +162,10 @@ results.save("./my_audit_results/audit_results.json")
162
162
  **💡 View results interactively:**
163
163
  ```bash
164
164
  # Option 1: Run directly with uvx (no installation needed, requires uv)
165
- uvx simpleaudit[visualize] serve --results_dir ./my_audit_results
165
+ uvx 'simpleaudit[visualize]' serve --results_dir ./my_audit_results
166
166
 
167
167
  # Option 2: Install and run locally
168
- pip install simpleaudit[visualize]
168
+ pip install 'simpleaudit[visualize]'
169
169
  simpleaudit serve --results_dir ./my_audit_results
170
170
  ```
171
171
  This will spin-up a local web server to explore results with scenario details. 👉 [Check for live demo.](https://simulamet-simpleauditvisualization.hf.space)
@@ -173,7 +173,14 @@ See [visualization/README.md](https://github.com/kelkalot/simpleaudit/blob/main/
173
173
 
174
174
  To share results as a single self-contained HTML file (no server, no JSON upload), use `simpleaudit export-html ./audit_results.json` or the **Download HTML** button in the visualizer.
175
175
 
176
- > **Note:** Option 1 requires [`uv`](https://pypi.org/project/uv/) to be installed ([install guide](https://docs.astral.sh/uv/getting-started/installation/)).
176
+ > **Note:** Option 1 requires [`uv`](https://pypi.org/project/uv/) to be installed ([install guide](https://docs.astral.sh/uv/getting-started/installation/)). The package spec is quoted so your shell doesn't glob-expand `[visualize]`.
177
+
178
+ <Troubleshooting>
179
+ If the command fails:
180
+ - **`Address already in use`** — port 8000 is taken. Use `--port 8080` or stop the other process.
181
+ - **`uvx: command not found`** — install `uv` first (`curl -LsSf https://astral.sh/uv/install.sh | sh`).
182
+ - **Weird import/module error** — stale cache. Run `uv cache clean` and retry.
183
+ </Troubleshooting>
177
184
 
178
185
  [![simpleaudit-visualization-ui](https://github.com/user-attachments/assets/f9bbb891-a847-48d4-85d6-6d6d99c9e017)](https://github.com/kelkalot/simpleaudit/blob/main/simpleaudit/visualization/README.md)
179
186
 
@@ -555,3 +555,76 @@ class TestExperimentEvent:
555
555
  assert e.rep_index == 2
556
556
  assert e.total_reps == 5
557
557
  assert e.result.severity == "pass"
558
+
559
+
560
+ # ---------------------------------------------------------------------------
561
+ # on_turn forwarding through run_scenario_reps
562
+ # ---------------------------------------------------------------------------
563
+
564
+ class TestOnTurnForwarding:
565
+ """Tests that on_turn is forwarded through run_scenario_reps."""
566
+
567
+ def test_on_turn_forwarded_to_each_rep(self):
568
+ """on_turn should be called for each rep's execution."""
569
+ calls = []
570
+
571
+ def on_turn(turn_index, max_turns, role):
572
+ calls.append((turn_index, max_turns, role))
573
+
574
+ with patch.object(ModelAuditor, '_create_anyllm_client', return_value=MagicMock()):
575
+ exp = AuditExperiment(
576
+ models=[{"model": "test-model", "provider": "openai"}],
577
+ judge_model="judge",
578
+ judge_provider="openai",
579
+ show_progress=False,
580
+ n_repetitions=2,
581
+ )
582
+
583
+ # Mock run_async to capture on_turn
584
+ async def fake_run_async(self_a, scenarios, **kwargs):
585
+ # Verify on_turn was passed
586
+ assert 'on_turn' in kwargs
587
+ if kwargs['on_turn']:
588
+ # Simulate a single turn with target phase
589
+ kwargs['on_turn'](0, 1, "target")
590
+ return _make_results(["pass"])
591
+
592
+ with patch.object(ModelAuditor, 'run_async', new=fake_run_async):
593
+ results = asyncio.run(exp.run_scenario_reps(
594
+ model_index=0,
595
+ scenario={"name": "test", "description": "desc"},
596
+ on_turn=on_turn,
597
+ ))
598
+
599
+ # Should have created 2 reps
600
+ assert len(results) == 2
601
+ # on_turn should have been called (at least once per rep)
602
+ assert len(calls) >= 2
603
+
604
+ def test_on_turn_not_forwarded_when_none(self):
605
+ """When on_turn is None, it should not cause errors."""
606
+ captured_kwargs = []
607
+
608
+ async def fake_run_async(self_a, scenarios, **kwargs):
609
+ captured_kwargs.append(kwargs)
610
+ return _make_results(["pass"])
611
+
612
+ with patch.object(ModelAuditor, '_create_anyllm_client', return_value=MagicMock()), \
613
+ patch.object(ModelAuditor, 'run_async', new=fake_run_async):
614
+
615
+ exp = AuditExperiment(
616
+ models=[{"model": "test-model", "provider": "openai"}],
617
+ judge_model="judge",
618
+ judge_provider="openai",
619
+ show_progress=False,
620
+ n_repetitions=1,
621
+ )
622
+
623
+ asyncio.run(exp.run_scenario_reps(
624
+ model_index=0,
625
+ scenario={"name": "test", "description": "desc"},
626
+ ))
627
+
628
+ # on_turn should not be in kwargs or should be None
629
+ for kwargs in captured_kwargs:
630
+ assert 'on_turn' not in kwargs or kwargs['on_turn'] is None
@@ -6,7 +6,7 @@ Run with: pytest tests/test_model_auditor.py -v
6
6
 
7
7
  import asyncio
8
8
  import pytest
9
- from unittest.mock import Mock, patch, MagicMock
9
+ from unittest.mock import AsyncMock, Mock, patch, MagicMock
10
10
 
11
11
  from simpleaudit import ModelAuditor, get_scenarios, list_scenario_packs
12
12
  # Check for optional provider dependencies
@@ -354,3 +354,171 @@ def test_model_auditor_judge_fields_none_keeps_default():
354
354
  )
355
355
  assert auditor.judge_fields is None
356
356
  assert auditor.judge_response_schema is None # no explicit schema set
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # on_turn callback
361
+ # ---------------------------------------------------------------------------
362
+
363
+ class TestOnTurnCallback:
364
+ """Tests for the on_turn callback in run_scenario."""
365
+
366
+ def _make_auditor(self, **kwargs):
367
+ """Create a ModelAuditor with mocked LLM clients."""
368
+ with patch.object(ModelAuditor, "_create_anyllm_client", return_value=MagicMock()):
369
+ return ModelAuditor(
370
+ model="test-model",
371
+ provider="openai",
372
+ judge_model="judge-model",
373
+ judge_provider="openai",
374
+ api_key="test-key",
375
+ show_progress=False,
376
+ verbose=False,
377
+ **kwargs,
378
+ )
379
+
380
+ def test_on_turn_called_at_each_phase_exact_sequence(self):
381
+ """on_turn fires in the exact phase order with correct turn indices.
382
+
383
+ With a test_prompt, turn 0 skips probe generation (auditor), so the
384
+ sequence is: turn0 target -> turn1 auditor -> turn1 target -> judge.
385
+ The judge runs once after all turns, reported against the final index.
386
+ """
387
+ calls = []
388
+
389
+ def on_turn(turn_index, max_turns, role):
390
+ calls.append((turn_index, max_turns, role))
391
+
392
+ auditor = self._make_auditor()
393
+
394
+ with patch.object(ModelAuditor, "_generate_probe_async", new_callable=AsyncMock) as mock_probe, \
395
+ patch.object(ModelAuditor, "_call_async", new_callable=AsyncMock) as mock_call, \
396
+ patch.object(ModelAuditor, "_judge_conversation_async", new_callable=AsyncMock) as mock_judge:
397
+
398
+ mock_probe.return_value = ("probe text", 10, 20)
399
+ mock_call.return_value = ("response text", 30, 40)
400
+ mock_judge.return_value = ({"severity": "pass"}, 50, 60)
401
+
402
+ asyncio.run(auditor.run_scenario(
403
+ name="test-scenario",
404
+ description="test description",
405
+ expected_behavior=["should do X"],
406
+ test_prompt="initial prompt",
407
+ max_turns=2,
408
+ language="English",
409
+ on_turn=on_turn,
410
+ ))
411
+
412
+ assert calls == [
413
+ (0, 2, "target"), # turn 0: test_prompt sent verbatim, no probe
414
+ (1, 2, "auditor"), # turn 1: probe generated
415
+ (1, 2, "target"), # turn 1: target responds
416
+ (1, 2, "judge"), # judge runs once after all turns
417
+ ]
418
+
419
+ def test_on_turn_set_in_init_fires_without_per_call_arg(self):
420
+ """A callback set at construction time must fire even when run_scenario
421
+ is called without an explicit on_turn argument (regression: the local
422
+ param previously shadowed self.on_turn, so init-set callbacks never ran)."""
423
+ calls = []
424
+
425
+ def on_turn(turn_index, max_turns, role):
426
+ calls.append((turn_index, max_turns, role))
427
+
428
+ auditor = self._make_auditor(on_turn=on_turn)
429
+
430
+ with patch.object(ModelAuditor, "_generate_probe_async", new_callable=AsyncMock) as mock_probe, \
431
+ patch.object(ModelAuditor, "_call_async", new_callable=AsyncMock) as mock_call, \
432
+ patch.object(ModelAuditor, "_judge_conversation_async", new_callable=AsyncMock) as mock_judge:
433
+
434
+ mock_probe.return_value = ("probe", 10, 20)
435
+ mock_call.return_value = ("response", 30, 40)
436
+ mock_judge.return_value = ({"severity": "pass"}, 50, 60)
437
+
438
+ # No on_turn passed here — it must come from __init__.
439
+ asyncio.run(auditor.run_scenario(
440
+ name="test",
441
+ description="desc",
442
+ max_turns=1,
443
+ language="English",
444
+ ))
445
+
446
+ assert len(calls) >= 1
447
+ assert any(role == "target" for _, _, role in calls)
448
+
449
+ def test_on_turn_per_call_overrides_init(self):
450
+ """A per-call on_turn takes precedence over the construction-time one."""
451
+ init_calls = []
452
+ call_calls = []
453
+
454
+ auditor = self._make_auditor(on_turn=lambda *a: init_calls.append(a))
455
+
456
+ with patch.object(ModelAuditor, "_generate_probe_async", new_callable=AsyncMock) as mock_probe, \
457
+ patch.object(ModelAuditor, "_call_async", new_callable=AsyncMock) as mock_call, \
458
+ patch.object(ModelAuditor, "_judge_conversation_async", new_callable=AsyncMock) as mock_judge:
459
+
460
+ mock_probe.return_value = ("probe", 10, 20)
461
+ mock_call.return_value = ("response", 30, 40)
462
+ mock_judge.return_value = ({"severity": "pass"}, 50, 60)
463
+
464
+ asyncio.run(auditor.run_scenario(
465
+ name="test",
466
+ description="desc",
467
+ max_turns=1,
468
+ language="English",
469
+ on_turn=lambda *a: call_calls.append(a),
470
+ ))
471
+
472
+ assert len(call_calls) >= 1
473
+ assert init_calls == [] # init callback must NOT fire when per-call given
474
+
475
+ def test_on_turn_not_called_when_none(self):
476
+ """When on_turn is None, no callback should fire (no error)."""
477
+ auditor = self._make_auditor()
478
+
479
+ with patch.object(ModelAuditor, "_generate_probe_async", new_callable=AsyncMock) as mock_probe, \
480
+ patch.object(ModelAuditor, "_call_async", new_callable=AsyncMock) as mock_call, \
481
+ patch.object(ModelAuditor, "_judge_conversation_async", new_callable=AsyncMock) as mock_judge:
482
+
483
+ mock_probe.return_value = ("probe", 10, 20)
484
+ mock_call.return_value = ("response", 30, 40)
485
+ mock_judge.return_value = ({"severity": "pass"}, 50, 60)
486
+
487
+ # Should not raise
488
+ result = asyncio.run(auditor.run_scenario(
489
+ name="test",
490
+ description="desc",
491
+ max_turns=1,
492
+ language="English",
493
+ on_turn=None,
494
+ ))
495
+
496
+ def test_on_turn_receives_correct_turn_index(self):
497
+ """turn_index should increment correctly across turns."""
498
+ calls = []
499
+
500
+ def on_turn(turn_index, max_turns, role):
501
+ calls.append((turn_index, role))
502
+
503
+ auditor = self._make_auditor()
504
+
505
+ with patch.object(ModelAuditor, "_generate_probe_async", new_callable=AsyncMock) as mock_probe, \
506
+ patch.object(ModelAuditor, "_call_async", new_callable=AsyncMock) as mock_call, \
507
+ patch.object(ModelAuditor, "_judge_conversation_async", new_callable=AsyncMock) as mock_judge:
508
+
509
+ mock_probe.return_value = ("probe", 10, 20)
510
+ mock_call.return_value = ("response", 30, 40)
511
+ mock_judge.return_value = ({"severity": "pass"}, 50, 60)
512
+
513
+ asyncio.run(auditor.run_scenario(
514
+ name="test",
515
+ description="desc",
516
+ max_turns=3,
517
+ language="English",
518
+ on_turn=on_turn,
519
+ ))
520
+
521
+ # Extract turn indices for target calls
522
+ target_calls = [(idx, role) for idx, role in calls if role == "target"]
523
+ turn_indices = [idx for idx, _ in target_calls]
524
+ assert turn_indices == [0, 1, 2] # One target call per turn
File without changes
File without changes