Skip to content

Commit e7c1e4a

Browse files
feat(red-team): import approved generated cases (#216)
1 parent 97a157b commit e7c1e4a

10 files changed

Lines changed: 991 additions & 124 deletions

File tree

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,23 @@ Generate cases for a product-specific context without writing to the database, t
160160

161161
Generation is bounded by category count, cases per category, context size, requests, output tokens, observed total tokens, observed cost, response size, and per-request timeout. Valid categories remain reviewable when another category fails. Each candidate and the complete generation carry checksums for stable review. Usage is observed from successful provider responses; failed or timed-out external requests may still incur provider-side usage that Aludel cannot report.
162162

163-
Catalog materialization and generated-case review are Elixir API features. Materialized curated cases use the normal dataset and suite workflows in the dashboard, `mix aludel.eval`, ExUnit, and the library API. There is no separate red-team CLI command or generation form in the dashboard yet.
163+
After reviewing the candidates, explicitly approve their stable IDs and import them atomically:
164+
165+
```elixir
166+
approved_case_ids =
167+
generation.cases
168+
|> Enum.filter(&approved_by_reviewer?/1)
169+
|> Enum.map(& &1.id)
170+
171+
{:ok, %{created: created, skipped: skipped}} =
172+
Aludel.RedTeam.import_generated(dataset, generation,
173+
approved_case_ids: approved_case_ids
174+
)
175+
```
176+
177+
Import revalidates the generation and candidate checksums, requires at least one unique approved ID, attaches each candidate's recommended rubric judge, records generation and review provenance, and skips only an exact prior import. Any conflict rolls back the complete approved selection.
178+
179+
Catalog materialization and generated-case generation, review, and import are Elixir API features. Persisted cases use the normal dataset and suite workflows in the dashboard, `mix aludel.eval`, ExUnit, and the library API. There is no separate red-team CLI command or generation/import form in the dashboard yet.
164180

165181
See the [red-team guide](https://hexdocs.pm/aludel/red_team.html), [curated datasets wiki guide](https://github.com/ccarvalho-eng/aludel/wiki/Red-Team-Datasets), and [generated cases wiki guide](https://github.com/ccarvalho-eng/aludel/wiki/Generated-Red-Team-Cases) for category filters, review, budgets, provenance, and rerun behavior.
166182

guides/evaluations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ For security regression coverage, you can materialize versioned adversarial case
6060

6161
See the [red-team guide](red_team.md) for the complete catalog and deduplication behavior.
6262

63-
For product-specific cases, call `Aludel.RedTeam.generate/2` and review the returned `generation.cases`, `generation.failures`, usage, and limits before authoring dataset entries. Generation never writes to the dataset directly. The [red-team guide](red_team.md#generated-cases) covers limits and partial failures.
63+
For product-specific cases, call `Aludel.RedTeam.generate/2` and review the returned `generation.cases`, `generation.failures`, usage, and limits. Generation never writes to the dataset directly. After review, `Aludel.RedTeam.import_generated/3` requires explicit candidate IDs and atomically creates ordinary dataset entries with a rubric judge and complete provenance. The [red-team guide](red_team.md#generated-cases) covers limits, partial failures, approval, and import.
6464

6565
### Contains and excludes
6666

guides/features.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,9 @@ Dataset pages support create, edit, delete, entry management, and JSON containme
9393

9494
`Aludel.RedTeam` provides seven versioned adversarial cases covering direct and indirect prompt injection, system prompt leakage, sensitive information disclosure, excessive agency, misinformation, and unsafe assistance. The library API materializes selected cases into a reusable dataset with deterministic canary assertions, optional rubric judges, and category, severity, provenance, checksum, and deduplication metadata. Matching reruns are idempotent; content or judge-configuration drift under the same key fails explicitly. See the [red-team guide](red_team.md).
9595

96-
The same API can generate product-specific cases through an Aludel provider. Generation validates a strict response schema, bounds calls and output, reports sanitized partial failures and usage, and returns inert checksummed candidates without database writes or execution.
96+
The same API can generate product-specific cases through an Aludel provider. Generation validates a strict response schema, bounds calls and output, reports sanitized partial failures and usage, and returns inert checksummed candidates without database writes or execution. A separate import call requires explicit approved candidate IDs, revalidates the review record, and creates the selected entries atomically with rubric judges and provenance.
9797

98-
Materialized entries use the normal dataset workflow in the dashboard. Once they have been copied into a suite, that suite can be executed through the dashboard, `mix aludel.eval`, ExUnit, or `Aludel.Evals`. Catalog browsing, materialization, and generated-case review are currently library API features.
98+
Materialized and approved generated entries use the normal dataset workflow in the dashboard. Once they have been copied into a suite, that suite can be executed through the dashboard, `mix aludel.eval`, ExUnit, or `Aludel.Evals`. Catalog browsing, materialization, generated-case review, and approval/import are currently library API features.
9999

100100
## Documents and storage
101101

guides/red_team.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,34 @@ IO.inspect(generation.limits)
142142

143143
Failures contain only the category, stable failure type, and a safe message. Raw provider errors and malformed model output are not retained. The raw target context is represented by a checksum in the generation result. Each candidate and the complete generation record have checksums for stable review.
144144

145-
Generation deliberately stops at the review boundary: it does not create, update, delete, or execute dataset entries. Use the candidate prompt, rationale, category, severity, technique, and recommended judge as review inputs before authoring an evaluation case.
145+
Generation deliberately stops at the review boundary: it does not create, update, delete, or execute dataset entries. Use the candidate prompt, rationale, category, severity, technique, and recommended judge as review inputs.
146+
147+
### Approve and import candidates
148+
149+
Import requires the stable IDs of at least one explicitly approved candidate:
150+
151+
```elixir
152+
approved_case_ids =
153+
generation.cases
154+
|> Enum.filter(&approved_by_reviewer?/1)
155+
|> Enum.map(& &1.id)
156+
157+
{:ok, %{created: created, skipped: skipped}} =
158+
Aludel.RedTeam.import_generated(dataset, generation,
159+
approved_case_ids: approved_case_ids,
160+
variable: "input",
161+
judge_provider_id: generator_provider.id,
162+
judge_threshold: 80
163+
)
164+
```
165+
166+
The judge provider defaults to the provider that generated the candidates. Each imported case receives its recommended built-in rubric judge. You can select another provider UUID and a threshold from 0 through 100.
167+
168+
Before locking the dataset, Aludel revalidates the complete generation checksum, its outcome accounting, and every candidate checksum. Candidate IDs must be non-empty, unique, and present in that generation. Approved cases retain generation status, failures, observed usage, applied limits, provider and model identity, target-context checksum, rationale, classification, review evidence, and import checksums in metadata. Raw target context is not copied into the entry.
169+
170+
The complete approved selection is written atomically in generation order. Repeating the same import returns the existing entries in `skipped`. A changed payload, generation receipt, review record, variable, or judge configuration under the same deduplication key returns `{:error, {:deduplication_conflict, key}}` and rolls back every new entry in that call.
171+
172+
Generation and approval/import are Elixir API features. The dashboard can inspect and edit imported entries and populate suites from their dataset, while `mix aludel.eval`, ExUnit, and the evaluation API run the resulting persisted suite. There is no separate generation or import command in the Mix CLI and no generation/import form in the dashboard.
146173

147174
## Use the entries
148175

lib/aludel/red_team.ex

Lines changed: 52 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ defmodule Aludel.RedTeam do
77
same case version and prompt variable. It records provenance, risk category,
88
severity, content checksum, and a stable deduplication key in entry metadata.
99
10-
Curated cases include deterministic canary assertions. Generated cases are
11-
returned as inert review values without database writes or execution.
10+
Curated cases include deterministic canary assertions. Generation returns
11+
inert review values without database writes or execution; a separate import
12+
call requires explicit approved candidate IDs.
1213
"""
1314

14-
import Ecto.Query
15-
1615
alias Aludel.Datasets.{Dataset, DatasetEntry}
1716
alias Aludel.RedTeam.Catalog
17+
alias Aludel.RedTeam.DatasetImporter
18+
alias Aludel.RedTeam.GeneratedImporter
1819
alias Aludel.RedTeam.Generation
1920
alias Aludel.RedTeam.Generator
2021
alias Ecto.Changeset
@@ -34,7 +35,7 @@ defmodule Aludel.RedTeam do
3435
| :invalid_judge_provider_id
3536
| :invalid_judge_threshold
3637
| {:unknown_categories, [term()]}
37-
| {:unknown_case_ids, [term()]}
38+
| {:unknown_case_ids, [String.t()]}
3839
| {:deduplication_conflict, String.t()}
3940
| Changeset.t()
4041
@type generate_error ::
@@ -45,6 +46,17 @@ defmodule Aludel.RedTeam do
4546
| :invalid_target_context
4647
| :invalid_budget
4748
| {:unknown_categories, [term()]}
49+
@type generated_import_error ::
50+
:dataset_not_found
51+
| :invalid_generation
52+
| :invalid_options
53+
| :invalid_approved_case_ids
54+
| :invalid_variable
55+
| :invalid_judge_provider_id
56+
| :invalid_judge_threshold
57+
| {:unknown_case_ids, [term()]}
58+
| {:deduplication_conflict, String.t()}
59+
| Changeset.t()
4860

4961
@doc """
5062
Returns every case in stable catalog order.
@@ -106,6 +118,30 @@ defmodule Aludel.RedTeam do
106118
Generator.generate(provider_id, opts)
107119
end
108120

121+
@doc """
122+
Imports explicitly approved generated cases into an existing dataset.
123+
124+
The generation checksum and every approved case checksum are revalidated
125+
before the dataset is locked. The complete approved selection is written
126+
atomically and retains generation, review, usage, limit, and judge provenance.
127+
128+
Options:
129+
130+
* `:approved_case_ids` - required non-empty unique list of candidate IDs
131+
* `:variable` - prompt variable populated by each case; defaults to `"input"`
132+
* `:judge_provider_id` - provider UUID for rubric judging; defaults to the generator provider
133+
* `:judge_threshold` - rubric judge pass threshold from 0 to 100; defaults to 80
134+
135+
Repeating an exact import skips its existing entries. Changed payload,
136+
provenance, review, or judge configuration under the same key returns a
137+
conflict and rolls back the entire selection.
138+
"""
139+
@spec import_generated(Dataset.t(), Generation.t(), keyword()) ::
140+
{:ok, materialization()} | {:error, generated_import_error()}
141+
def import_generated(dataset, generation, opts \\ []) do
142+
GeneratedImporter.import(dataset, generation, opts)
143+
end
144+
109145
@doc """
110146
Materializes selected catalog cases into an existing dataset.
111147
@@ -245,73 +281,31 @@ defmodule Aludel.RedTeam do
245281
end
246282

247283
defp persist_cases(dataset_id, selected, opts) do
248-
repo().transaction(fn ->
249-
case lock_dataset(dataset_id) do
250-
nil -> repo().rollback(:dataset_not_found)
251-
locked_dataset -> materialize_locked(locked_dataset, selected, opts)
252-
end
253-
end)
284+
prepared_entries = Enum.map(selected, &prepared_entry(&1, opts))
285+
DatasetImporter.persist(dataset_id, prepared_entries)
254286
end
255287

256-
defp materialize_locked(dataset, selected, opts) do
257-
keys = Enum.map(selected, &deduplication_key(&1, opts[:variable]))
258-
entries = list_entries_with_keys(dataset.id, keys)
259-
next_position = next_position(dataset.id)
288+
defp prepared_entry(template, opts) do
289+
key = deduplication_key(template, opts[:variable])
260290

261-
selected
262-
|> Enum.reduce_while({[], [], next_position}, fn template, {created, skipped, position} ->
263-
case materialize_template(dataset.id, template, opts, entries, position) do
264-
{:created, entry} ->
265-
{:cont, {[entry | created], skipped, position + 1}}
266-
267-
{:skipped, entry} ->
268-
{:cont, {created, [entry | skipped], position}}
269-
270-
{:error, reason} ->
271-
repo().rollback(reason)
272-
end
273-
end)
274-
|> then(fn {created, skipped, _position} ->
275-
%{created: Enum.reverse(created), skipped: Enum.reverse(skipped)}
276-
end)
291+
%{
292+
deduplication_key: key,
293+
attrs: entry_attrs(template, opts)
294+
}
277295
end
278296

279-
defp entry_attrs(template, opts, position) do
297+
defp entry_attrs(template, opts) do
280298
variable = opts[:variable]
281299
assertions = assertions(template, opts)
282300

283301
%{
284302
name: template.name,
285303
variable_values: %{variable => template.prompt},
286304
assertions: assertions,
287-
metadata: metadata(template, variable, opts),
288-
position: position
305+
metadata: metadata(template, variable, opts)
289306
}
290307
end
291308

292-
defp materialize_template(dataset_id, template, opts, entries, position) do
293-
attrs = entry_attrs(template, opts, position)
294-
key = get_in(attrs, [:metadata, "red_team", "deduplication_key"])
295-
296-
case entries_with_key(entries, key) do
297-
[] ->
298-
case insert_entry(dataset_id, attrs) do
299-
{:ok, entry} -> {:created, entry}
300-
{:error, changeset} -> {:error, changeset}
301-
end
302-
303-
[entry] ->
304-
if matches_materialization?(entry, attrs) do
305-
{:skipped, entry}
306-
else
307-
{:error, {:deduplication_conflict, key}}
308-
end
309-
310-
_duplicates ->
311-
{:error, {:deduplication_conflict, key}}
312-
end
313-
end
314-
315309
defp assertions(template, opts) do
316310
case opts[:judge_provider_id] do
317311
nil ->
@@ -372,67 +366,6 @@ defmodule Aludel.RedTeam do
372366

373367
defp deduplication_key(template, variable) do
374368
"aludel:red_team:#{Catalog.name()}@#{Catalog.version()}:#{template.id}@#{template.version}:#{variable}"
375-
end
376-
377-
defp lock_dataset(dataset_id) do
378-
Dataset
379-
|> where([dataset], dataset.id == ^dataset_id)
380-
|> lock("FOR UPDATE")
381-
|> repo().one()
382-
end
383-
384-
defp list_entries_with_keys(_dataset_id, []) do
385-
[]
386-
end
387-
388-
defp list_entries_with_keys(dataset_id, keys) do
389-
DatasetEntry
390-
|> where(
391-
[entry],
392-
entry.dataset_id == ^dataset_id and entry.red_team_deduplication_key in ^keys
393-
)
394-
|> repo().all()
395-
end
396-
397-
defp entries_with_key(entries, key) do
398-
Enum.filter(entries, fn entry ->
399-
entry.red_team_deduplication_key == key
400-
end)
401-
end
402-
403-
defp matches_materialization?(entry, attrs) do
404-
entry.name == attrs.name and
405-
entry.variable_values == attrs.variable_values and
406-
entry.messages == [] and
407-
entry.assertions == attrs.assertions and
408-
entry.red_team_deduplication_key ==
409-
get_in(attrs, [:metadata, "red_team", "deduplication_key"]) and
410-
entry.metadata["red_team"] == attrs.metadata["red_team"]
411-
end
412-
413-
defp next_position(dataset_id) do
414-
DatasetEntry
415-
|> where([entry], entry.dataset_id == ^dataset_id)
416-
|> select([entry], max(entry.position))
417-
|> repo().one()
418-
|> case do
419-
nil -> 0
420-
position -> position + 1
421-
end
422-
end
423-
424-
defp insert_entry(dataset_id, attrs) do
425-
deduplication_key = get_in(attrs, [:metadata, "red_team", "deduplication_key"])
426-
427-
%DatasetEntry{
428-
dataset_id: dataset_id,
429-
red_team_deduplication_key: deduplication_key
430-
}
431-
|> DatasetEntry.changeset(attrs)
432-
|> repo().insert()
433-
end
434-
435-
defp repo do
436-
Aludel.Repo.get()
369+
|> DatasetImporter.bounded_key()
437370
end
438371
end

0 commit comments

Comments
 (0)