From 010945a81339a29088c7cb8d0f04ae35d0e2eb77 Mon Sep 17 00:00:00 2001 From: cgasgarth <64235119+cgasgarth@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:01:51 -0500 Subject: [PATCH 1/2] Add batch agent review flow --- darktable/src/develop/develop.h | 4 + darktable/src/libs/select.c | 161 +++++++++++- darktable/src/views/darkroom.c | 286 +++++++++++++++++++++ darktable/src/views/view.h | 4 + server/app.py | 39 +++ server/batch_orchestrator.py | 154 +++++++++++ server/chat_batch.py | 112 ++++++++ server/codex_bridge/prompts/turn_prompt.j2 | 1 + server/mock_planner.py | 5 + server/tests/test_batch_api.py | 243 +++++++++++++++++ server/tests/test_batch_orchestrator.py | 185 +++++++++++++ server/tests/test_batch_protocol.py | 173 +++++++++++++ shared/__init__.py | 2 + shared/batch_protocol.py | 127 +++++++++ shared/chat_batch_protocol.py | 68 +++++ shared/protocol.py | 4 + shared/review_protocol.py | 34 +++ 17 files changed, 1601 insertions(+), 1 deletion(-) create mode 100644 server/batch_orchestrator.py create mode 100644 server/chat_batch.py create mode 100644 server/tests/test_batch_api.py create mode 100644 server/tests/test_batch_orchestrator.py create mode 100644 server/tests/test_batch_protocol.py create mode 100644 shared/batch_protocol.py create mode 100644 shared/chat_batch_protocol.py create mode 100644 shared/review_protocol.py diff --git a/darktable/src/develop/develop.h b/darktable/src/develop/develop.h index 7c1e788..6bd256f 100644 --- a/darktable/src/develop/develop.h +++ b/darktable/src/develop/develop.h @@ -358,12 +358,16 @@ typedef struct dt_develop_t guint active_request_refinement_max_passes; guint active_request_tool_calls_used; guint active_request_tool_calls_max; + GArray *batch_image_ids; + gchar *batch_prompt; + guint batch_index; gboolean multi_turn_enabled; gboolean fast_mode_enabled; gboolean active_request_canceling; gboolean is_loading; gboolean autorun_sent; gboolean exit_after_autorun; + gboolean batch_active; } agent_chat; // late scaling down from full roi diff --git a/darktable/src/libs/select.c b/darktable/src/libs/select.c index 020a43a..bae7a76 100644 --- a/darktable/src/libs/select.c +++ b/darktable/src/libs/select.c @@ -26,6 +26,7 @@ #include "gui/accelerators.h" #include "gui/gtk.h" #include "libs/lib.h" +#include "views/view.h" #include #ifdef USE_LUA #include "lua/call.h" @@ -63,8 +64,158 @@ typedef struct dt_lib_select_t GtkWidget *select_invert_button; GtkWidget *select_film_roll_button; GtkWidget *select_untouched_button; + GtkWidget *agent_batch_button; } dt_lib_select_t; +typedef struct dt_lib_select_batch_launch_t +{ + GList *image_ids; + gchar *prompt; + guint attempts; +} dt_lib_select_batch_launch_t; + +static void _batch_launch_free(dt_lib_select_batch_launch_t *launch) +{ + if(!launch) + return; + + g_list_free(launch->image_ids); + g_free(launch->prompt); + g_free(launch); +} + +static void _batch_launch_restore_selection(const dt_lib_select_batch_launch_t *launch) +{ + if(!launch || !launch->image_ids) + return; + + dt_selection_clear(darktable.selection); + dt_selection_select_list(darktable.selection, launch->image_ids); +} + +static gboolean _batch_launch_when_ready(gpointer user_data) +{ + dt_lib_select_batch_launch_t *launch = user_data; + if(dt_view_get_current() != DT_VIEW_DARKROOM + || !darktable.view_manager->proxy.darkroom.view + || !darktable.view_manager->proxy.darkroom.run_batch_agent_review) + { + launch->attempts++; + if(launch->attempts < 40) + return G_SOURCE_CONTINUE; + + GtkWidget *dialog = gtk_message_dialog_new(GTK_WINDOW(dt_ui_main_window(darktable.gui->ui)), + GTK_DIALOG_DESTROY_WITH_PARENT, + GTK_MESSAGE_ERROR, + GTK_BUTTONS_CLOSE, + "%s", + _("darkroom was not ready to start the batch review")); + gtk_dialog_run(GTK_DIALOG(dialog)); + gtk_widget_destroy(dialog); + _batch_launch_restore_selection(launch); + _batch_launch_free(launch); + return G_SOURCE_REMOVE; + } + + GError *error = NULL; + if(!darktable.view_manager->proxy.darkroom.run_batch_agent_review( + darktable.view_manager->proxy.darkroom.view, launch->image_ids, launch->prompt, &error)) + { + GtkWidget *dialog = gtk_message_dialog_new(GTK_WINDOW(dt_ui_main_window(darktable.gui->ui)), + GTK_DIALOG_DESTROY_WITH_PARENT, + GTK_MESSAGE_ERROR, + GTK_BUTTONS_CLOSE, + "%s", + error && error->message ? error->message + : _("failed to start the batch review")); + gtk_dialog_run(GTK_DIALOG(dialog)); + gtk_widget_destroy(dialog); + g_clear_error(&error); + _batch_launch_restore_selection(launch); + } + + _batch_launch_free(launch); + return G_SOURCE_REMOVE; +} + +static void _start_agent_batch_review(void) +{ + GList *image_ids = dt_selection_get_list(darktable.selection, FALSE, TRUE); + const guint image_count = g_list_length(image_ids); + if(image_count == 0 || image_count > 10) + { + GtkWidget *dialog = gtk_message_dialog_new(GTK_WINDOW(dt_ui_main_window(darktable.gui->ui)), + GTK_DIALOG_DESTROY_WITH_PARENT, + GTK_MESSAGE_WARNING, + GTK_BUTTONS_CLOSE, + "%s", + _("select between 1 and 10 images to start a batch review")); + gtk_dialog_run(GTK_DIALOG(dialog)); + gtk_widget_destroy(dialog); + g_list_free(image_ids); + return; + } + + GtkWidget *dialog = gtk_dialog_new_with_buttons(_("start batch review"), + GTK_WINDOW(dt_ui_main_window(darktable.gui->ui)), + GTK_DIALOG_DESTROY_WITH_PARENT, + _("_cancel"), GTK_RESPONSE_CANCEL, + _("_start"), GTK_RESPONSE_ACCEPT, + NULL); + GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_PIXEL_APPLY_DPI(8)); + gtk_container_set_border_width(GTK_CONTAINER(box), DT_PIXEL_APPLY_DPI(12)); + gtk_box_pack_start(GTK_BOX(content), box, TRUE, TRUE, 0); + + GtkWidget *label = gtk_label_new(_("Run the assistant across the selected images one by one. Each image is tagged for easy review when the batch finishes.")); + gtk_label_set_line_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0); + + GtkWidget *entry = gtk_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(entry), _("describe the edit you want applied across the batch")); + const char *saved_prompt = dt_conf_get_string_const("plugins/lighttable/selection/agent_batch_prompt"); + if(saved_prompt && saved_prompt[0]) + gtk_entry_set_text(GTK_ENTRY(entry), saved_prompt); + gtk_box_pack_start(GTK_BOX(box), entry, FALSE, FALSE, 0); + + gtk_widget_show_all(dialog); + gtk_widget_grab_focus(entry); + const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); + g_autofree gchar *prompt = g_strdup(gtk_entry_get_text(GTK_ENTRY(entry))); + gtk_widget_destroy(dialog); + + if(response != GTK_RESPONSE_ACCEPT) + { + g_list_free(image_ids); + return; + } + + g_strstrip(prompt); + if(!prompt[0]) + { + GtkWidget *warning = gtk_message_dialog_new(GTK_WINDOW(dt_ui_main_window(darktable.gui->ui)), + GTK_DIALOG_DESTROY_WITH_PARENT, + GTK_MESSAGE_WARNING, + GTK_BUTTONS_CLOSE, + "%s", + _("enter a prompt to start the batch review")); + gtk_dialog_run(GTK_DIALOG(warning)); + gtk_widget_destroy(warning); + g_list_free(image_ids); + return; + } + + dt_conf_set_string("plugins/lighttable/selection/agent_batch_prompt", prompt); + dt_selection_select_single(darktable.selection, GPOINTER_TO_INT(image_ids->data)); + dt_ctl_switch_mode_to("darkroom"); + + dt_lib_select_batch_launch_t *launch = g_malloc0(sizeof(*launch)); + launch->image_ids = image_ids; + launch->prompt = g_strdup(prompt); + g_timeout_add(50, _batch_launch_when_ready, launch); +} + void gui_update(dt_lib_module_t *self) { dt_lib_select_t *d = self->data; @@ -81,6 +232,7 @@ void gui_update(dt_lib_module_t *self) gtk_widget_set_sensitive(GTK_WIDGET(d->select_untouched_button), collection_cnt > 0); gtk_widget_set_sensitive(GTK_WIDGET(d->select_film_roll_button), selected_cnt > 0); + gtk_widget_set_sensitive(GTK_WIDGET(d->agent_batch_button), selected_cnt > 0 && selected_cnt <= 10); } static void _image_selection_changed_callback(gpointer instance, dt_lib_module_t *self) @@ -117,6 +269,9 @@ static void button_clicked(GtkWidget *widget, gpointer user_data) case 4: // untouched dt_selection_select_unaltered(darktable.selection); break; + case 5: + _start_agent_batch_review(); + return; default: // case 3: same film roll dt_selection_select_filmroll(darktable.selection); } @@ -156,9 +311,13 @@ void gui_init(dt_lib_module_t *self) gtk_grid_attach(grid, d->select_film_roll_button, 1, line++, 1, 1); d->select_untouched_button = dt_action_button_new(self, N_("select untouched"), button_clicked, GINT_TO_POINTER(4), - _("select untouched images in\ncurrent collection"), 0, 0); + _("select untouched images in\ncurrent collection"), 0, 0); gtk_grid_attach(grid, d->select_untouched_button, 0, line, 2, 1); + d->agent_batch_button = dt_action_button_new(self, N_("agent batch review"), button_clicked, GINT_TO_POINTER(5), + _("run the assistant on up to 10 selected images and tag them for review"), 0, 0); + gtk_grid_attach(grid, d->agent_batch_button, 0, ++line, 2, 1); + gtk_label_set_ellipsize(GTK_LABEL(gtk_bin_get_child(GTK_BIN(d->select_all_button))), PANGO_ELLIPSIZE_START); gtk_label_set_ellipsize(GTK_LABEL(gtk_bin_get_child(GTK_BIN(d->select_none_button))), PANGO_ELLIPSIZE_START); gtk_label_set_ellipsize(GTK_LABEL(gtk_bin_get_child(GTK_BIN(d->select_film_roll_button))), PANGO_ELLIPSIZE_START); diff --git a/darktable/src/views/darkroom.c b/darktable/src/views/darkroom.c index 5bb48b5..88d7ace 100644 --- a/darktable/src/views/darkroom.c +++ b/darktable/src/views/darkroom.c @@ -91,6 +91,11 @@ DT_MODULE(1) #define DT_AGENT_CHAT_TEST_AUTORUN_QUIT_MS_ENV "DARKTABLE_AGENT_TEST_AUTORUN_QUIT_AFTER_MS" #define DT_AGENT_CHAT_TEST_MULTI_TURN_ENABLED_ENV "DARKTABLE_AGENT_TEST_MULTI_TURN_ENABLED" #define DT_AGENT_CHAT_TEST_MULTI_TURN_MAX_TURNS_ENV "DARKTABLE_AGENT_TEST_MULTI_TURN_MAX_TURNS" +#define DT_AGENT_BATCH_MAX_IMAGES 10 +#define DT_AGENT_BATCH_TAG_BASE "darktable|agent|batch-review" +#define DT_AGENT_BATCH_TAG_QUEUE DT_AGENT_BATCH_TAG_BASE "|queued" +#define DT_AGENT_BATCH_TAG_DONE DT_AGENT_BATCH_TAG_BASE "|edited" +#define DT_AGENT_BATCH_TAG_FAILED DT_AGENT_BATCH_TAG_BASE "|failed" static void _update_softproof_gamut_checking(dt_develop_t *d); @@ -142,6 +147,9 @@ static struct dt_agent_chat_session_t *_agent_chat_lookup_session(dt_develop_t * dt_imgid_t image_id); static void _agent_chat_session_set_status(dt_agent_chat_session_t *session, const char *status); +static void _agent_chat_append_message(dt_develop_t *dev, + const char *speaker, + const char *message); static void _agent_chat_update_sensitivity(dt_develop_t *dev); static void _agent_chat_set_status(dt_develop_t *dev, const char *status); static void _agent_chat_set_error(dt_develop_t *dev, const char *error); @@ -165,10 +173,24 @@ static gboolean _agent_chat_submit_internal(dt_develop_t *dev, const dt_agent_refinement_mode_t refinement_mode, const guint refinement_pass_index, const guint refinement_max_passes); +static void _agent_chat_submit(dt_develop_t *dev, const char *prompt, const gboolean autorun); +static gboolean _agent_chat_run_batch_agent_review(dt_view_t *view, + const GList *image_ids, + const char *prompt, + GError **error); static gboolean _agent_chat_apply_operation_range(const GPtrArray *operations, const guint start_index, dt_agent_execution_report_t *execution_report, GError **error); +static void _agent_chat_batch_clear(dt_develop_t *dev); +static void _agent_chat_batch_restore_selection(dt_develop_t *dev); +static gboolean _agent_chat_batch_advance(dt_develop_t *dev); +static gboolean _agent_chat_batch_set_status_tags(const GList *image_ids, + const char *status_tag, + GError **error); +static gboolean _agent_chat_batch_set_image_status(dt_imgid_t imgid, + const char *status_tag, + GError **error); const char *name(const dt_view_t *self) { @@ -300,6 +322,9 @@ void cleanup(dt_view_t *self) g_free(dev->agent_chat.active_request_id); g_free(dev->agent_chat.autorun_message); g_free(dev->agent_chat.test_report_path); + g_free(dev->agent_chat.batch_prompt); + if(dev->agent_chat.batch_image_ids) + g_array_unref(dev->agent_chat.batch_image_ids); if(dev->agent_chat.image_sessions) g_hash_table_unref(dev->agent_chat.image_sessions); @@ -2325,6 +2350,226 @@ static dt_develop_t *_agent_chat_get_darkroom_dev(void) return darktable.view_manager->proxy.darkroom.view->data; } +static GList *_agent_chat_batch_list_from_array(const GArray *image_ids) +{ + GList *list = NULL; + if(!image_ids) + return NULL; + + for(guint i = 0; i < image_ids->len; i++) + { + const dt_imgid_t imgid = g_array_index(image_ids, dt_imgid_t, i); + list = g_list_append(list, GINT_TO_POINTER(imgid)); + } + + return list; +} + +static gboolean _agent_chat_batch_lookup_tag(const char *tag_name, + guint *tagid, + GError **error) +{ + if(!tag_name || !tag_name[0]) + return FALSE; + + guint resolved_tagid = 0; + if(!dt_tag_new(tag_name, &resolved_tagid)) + { + g_set_error(error, g_quark_from_static_string("dt-agent-chat-ui"), 1, + _("failed to create batch review tag: %s"), tag_name); + return FALSE; + } + + if(tagid) + *tagid = resolved_tagid; + return TRUE; +} + +static gboolean _agent_chat_batch_reset_status_tags(const GList *image_ids, + GError **error) +{ + const char *status_tags[] = { + DT_AGENT_BATCH_TAG_QUEUE, + DT_AGENT_BATCH_TAG_DONE, + DT_AGENT_BATCH_TAG_FAILED, + }; + + for(guint i = 0; i < G_N_ELEMENTS(status_tags); i++) + { + guint tagid = 0; + if(!_agent_chat_batch_lookup_tag(status_tags[i], &tagid, error)) + return FALSE; + dt_tag_detach_images(tagid, image_ids, FALSE); + } + + return TRUE; +} + +static gboolean _agent_chat_batch_set_status_tags(const GList *image_ids, + const char *status_tag, + GError **error) +{ + if(!_agent_chat_batch_reset_status_tags(image_ids, error)) + return FALSE; + + guint base_tagid = 0; + guint status_tagid = 0; + if(!_agent_chat_batch_lookup_tag(DT_AGENT_BATCH_TAG_BASE, &base_tagid, error) + || !_agent_chat_batch_lookup_tag(status_tag, &status_tagid, error)) + return FALSE; + + if(!dt_tag_attach_images(base_tagid, image_ids, FALSE)) + { + g_set_error(error, g_quark_from_static_string("dt-agent-chat-ui"), 1, + _("failed to attach the batch review tag")); + return FALSE; + } + + if(!dt_tag_attach_images(status_tagid, image_ids, FALSE)) + { + g_set_error(error, g_quark_from_static_string("dt-agent-chat-ui"), 1, + _("failed to attach the batch status tag")); + return FALSE; + } + + return TRUE; +} + +static gboolean _agent_chat_batch_set_image_status(dt_imgid_t imgid, + const char *status_tag, + GError **error) +{ + GList *image_ids = g_list_append(NULL, GINT_TO_POINTER(imgid)); + const gboolean ok = _agent_chat_batch_set_status_tags(image_ids, status_tag, error); + g_list_free(image_ids); + return ok; +} + +static void _agent_chat_batch_restore_selection(dt_develop_t *dev) +{ + GList *image_ids = _agent_chat_batch_list_from_array(dev ? dev->agent_chat.batch_image_ids : NULL); + if(!image_ids) + return; + + dt_selection_clear(darktable.selection); + dt_selection_select_list(darktable.selection, image_ids); + g_list_free(image_ids); +} + +static void _agent_chat_batch_clear(dt_develop_t *dev) +{ + if(!dev) + return; + + dev->agent_chat.batch_active = FALSE; + dev->agent_chat.batch_index = 0; + g_clear_pointer(&dev->agent_chat.batch_prompt, g_free); + g_clear_pointer(&dev->agent_chat.batch_image_ids, g_array_unref); +} + +static void _agent_chat_batch_stop(dt_develop_t *dev, + const dt_imgid_t failed_imgid, + const char *message) +{ + if(!dev || !dev->agent_chat.batch_active) + return; + + if(failed_imgid > 0) + { + GError *batch_error = NULL; + if(!_agent_chat_batch_set_image_status(failed_imgid, DT_AGENT_BATCH_TAG_FAILED, &batch_error)) + dt_print(DT_DEBUG_ALWAYS, + "[agent-chat] failed to update batch failed tag: %s", + batch_error && batch_error->message ? batch_error->message : "unknown error"); + g_clear_error(&batch_error); + } + + if(message && message[0] != '\0') + _agent_chat_append_message(dev, _("system"), message); + _agent_chat_batch_restore_selection(dev); + _agent_chat_batch_clear(dev); + dt_ctl_switch_mode_to("lighttable"); +} + +static gboolean _agent_chat_batch_advance(dt_develop_t *dev) +{ + if(!dev || !dev->agent_chat.batch_active || !dev->agent_chat.batch_image_ids + || !dev->agent_chat.batch_prompt) + return FALSE; + + dev->agent_chat.batch_index++; + if(dev->agent_chat.batch_index >= dev->agent_chat.batch_image_ids->len) + { + _agent_chat_append_message(dev, _("system"), _("Batch review complete. Returning to lighttable for review.")); + _agent_chat_batch_restore_selection(dev); + _agent_chat_batch_clear(dev); + dt_ctl_switch_mode_to("lighttable"); + return FALSE; + } + + const dt_imgid_t next_imgid + = g_array_index(dev->agent_chat.batch_image_ids, dt_imgid_t, dev->agent_chat.batch_index); + _dev_change_image(dev, next_imgid); + _agent_chat_append_message(dev, _("system"), _("Continuing batch review on the next selected image.")); + _agent_chat_submit(dev, dev->agent_chat.batch_prompt, FALSE); + return TRUE; +} + +static gboolean _agent_chat_run_batch_agent_review(dt_view_t *view, + const GList *image_ids, + const char *prompt, + GError **error) +{ + if(!view || !view->data) + return FALSE; + + dt_develop_t *dev = view->data; + g_autofree gchar *message = g_strstrip(g_strdup(prompt ? prompt : "")); + const guint image_count = g_list_length((GList *)image_ids); + if(image_count == 0 || image_count > DT_AGENT_BATCH_MAX_IMAGES) + { + g_set_error(error, g_quark_from_static_string("dt-agent-chat-ui"), 1, + _("select between 1 and %u images to start a batch review"), + DT_AGENT_BATCH_MAX_IMAGES); + return FALSE; + } + + if(!message[0]) + { + g_set_error(error, g_quark_from_static_string("dt-agent-chat-ui"), 1, + "%s", _("enter a batch review prompt first")); + return FALSE; + } + + if(dev->agent_chat.is_loading) + { + g_set_error(error, g_quark_from_static_string("dt-agent-chat-ui"), 1, + "%s", _("wait for the current request to finish before starting a batch review")); + return FALSE; + } + + g_autoptr(GArray) batch_image_ids = g_array_sized_new(FALSE, FALSE, sizeof(dt_imgid_t), image_count); + for(const GList *iter = image_ids; iter; iter = g_list_next(iter)) + { + const dt_imgid_t imgid = GPOINTER_TO_INT(iter->data); + g_array_append_val(batch_image_ids, imgid); + } + + if(!_agent_chat_batch_set_status_tags(image_ids, DT_AGENT_BATCH_TAG_QUEUE, error)) + return FALSE; + + _agent_chat_batch_clear(dev); + dev->agent_chat.batch_image_ids = g_steal_pointer(&batch_image_ids); + dev->agent_chat.batch_prompt = g_strdup(message); + dev->agent_chat.batch_index = 0; + dev->agent_chat.batch_active = TRUE; + + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(dev->agent_chat.button), TRUE); + _agent_chat_append_message(dev, _("system"), _("Starting batch review for the current selection.")); + _agent_chat_submit(dev, message, FALSE); + return dev->agent_chat.is_loading; +} + static gboolean _agent_chat_darkroom_is_visible(void) { return dt_view_get_current() == DT_VIEW_DARKROOM; @@ -3236,6 +3481,10 @@ static void _agent_chat_request_finished(const dt_agent_client_result_t *result, result->transport_error ? result->transport_error : _("chat request canceled"), submission ? submission->exposure_before : NAN); + if(dev->agent_chat.batch_active) + _agent_chat_batch_stop(dev, + submission && submission->has_image_id ? submission->image_id : NO_IMGID, + _("Batch review stopped after a canceled image. Returning to lighttable for review.")); _agent_chat_maybe_schedule_test_quit(dev, submission->autorun); return; } @@ -3260,6 +3509,10 @@ static void _agent_chat_request_finished(const dt_agent_client_result_t *result, _agent_chat_write_test_report(dev, "stale", result, NULL, _("ignored stale response"), submission ? submission->exposure_before : NAN); + if(dev->agent_chat.batch_active) + _agent_chat_batch_stop(dev, + submission && submission->has_image_id ? submission->image_id : NO_IMGID, + _("Batch review stopped because an image response became stale. Returning to lighttable for review.")); return; } @@ -3294,6 +3547,10 @@ static void _agent_chat_request_finished(const dt_agent_client_result_t *result, } _agent_chat_write_test_report(dev, "revision_mismatch", result, NULL, message, submission ? submission->exposure_before : NAN); + if(dev->agent_chat.batch_active) + _agent_chat_batch_stop(dev, + submission && submission->has_image_id ? submission->image_id : NO_IMGID, + _("Batch review stopped after the image changed unexpectedly. Returning to lighttable for review.")); _agent_chat_maybe_schedule_test_quit(dev, submission->autorun); return; } @@ -3359,6 +3616,10 @@ static void _agent_chat_request_finished(const dt_agent_client_result_t *result, } _agent_chat_maybe_schedule_test_quit(dev, submission->autorun); + if(dev->agent_chat.batch_active) + _agent_chat_batch_stop(dev, + submission && submission->has_image_id ? submission->image_id : NO_IMGID, + _("Batch review stopped before darkroom could finish the current image. Returning to lighttable for review.")); return; } @@ -3369,6 +3630,10 @@ static void _agent_chat_request_finished(const dt_agent_client_result_t *result, result->transport_error ? result->transport_error : _("failed to contact the agent server"), submission ? submission->exposure_before : NAN); + if(dev->agent_chat.batch_active) + _agent_chat_batch_stop(dev, + submission && submission->has_image_id ? submission->image_id : NO_IMGID, + _("Batch review stopped after a failed image request. Returning to lighttable for review.")); } else { @@ -3393,6 +3658,26 @@ static void _agent_chat_request_finished(const dt_agent_client_result_t *result, _agent_chat_write_test_report(dev, status, result, &execution_report, response_error, submission ? submission->exposure_before : NAN); dt_agent_execution_report_clear(&execution_report); + if(dev->agent_chat.batch_active && submission && submission->has_image_id) + { + GError *batch_error = NULL; + if(handled) + { + if(!_agent_chat_batch_set_image_status(submission->image_id, DT_AGENT_BATCH_TAG_DONE, &batch_error)) + dt_print(DT_DEBUG_ALWAYS, + "[agent-chat] failed to update batch done tag: %s", + batch_error && batch_error->message ? batch_error->message : "unknown error"); + g_clear_error(&batch_error); + if(_agent_chat_batch_advance(dev)) + return; + } + else + { + _agent_chat_batch_stop(dev, + submission->image_id, + _("Batch review stopped after a failed image. Returning to lighttable for review.")); + } + } _agent_chat_maybe_schedule_test_quit(dev, submission->autorun); return; } @@ -5077,6 +5362,7 @@ void gui_init(dt_view_t *self) } darktable.view_manager->proxy.darkroom.get_layout = _lib_darkroom_get_layout; + darktable.view_manager->proxy.darkroom.run_batch_agent_review = _agent_chat_run_batch_agent_review; dev->full.border_size = DT_PIXEL_APPLY_DPI(dt_conf_get_int("plugins/darkroom/ui/border_size")); // Fullscreen preview key diff --git a/darktable/src/views/view.h b/darktable/src/views/view.h index 7bdd82d..fd403ef 100644 --- a/darktable/src/views/view.h +++ b/darktable/src/views/view.h @@ -315,6 +315,10 @@ typedef struct dt_view_manager_t { struct dt_view_t *view; dt_darkroom_layout_t (*get_layout)(struct dt_view_t *view); + gboolean (*run_batch_agent_review)(struct dt_view_t *view, + const GList *image_ids, + const char *prompt, + GError **error); } darkroom; /* lighttable view proxy object */ diff --git a/server/app.py b/server/app.py index a5f2f1d..33d669d 100644 --- a/server/app.py +++ b/server/app.py @@ -13,8 +13,12 @@ from pydantic import BaseModel, Field from server.bridge_types import PlannerBridge, PlannerTurnResult, RequestProgressPayload +from server.batch_orchestrator import BatchOrchestrator +from server.chat_batch import run_chat_batch from server.codex_app_server import CodexAppServerBridge, CodexAppServerError from server.mock_planner import MockPlannerBridge +from shared.batch_protocol import BatchChatRequest, BatchChatResponse +from shared.chat_batch_protocol import BatchRequestEnvelope, BatchResponseEnvelope from shared.protocol import ( ErrorInfo, ProtocolError, @@ -83,6 +87,10 @@ def get_codex_bridge() -> PlannerBridge: return _codex_bridge +def build_planner_bridge() -> PlannerBridge: + return get_codex_bridge() + + def build_request_error_refinement(request: RequestEnvelope) -> RefinementStatus: return RefinementStatus( mode=request.refinement.mode, @@ -197,6 +205,24 @@ def _log_fulfilled_request( ) +def _log_batch_request( + request: BatchChatRequest, batch_id: str, review_tag: str +) -> None: + logger.info( + "accepted_batch_request", + extra={ + "structured": { + "event": "accepted_batch_request", + "batchId": batch_id, + "reviewTag": review_tag, + "submittedCount": len(request.items), + "selectedMax": request.selection.maxImages, + "selectionStrategy": request.selection.strategy, + } + }, + ) + + def _encode_sse(event: str, payload: Mapping[str, Any]) -> str: return f"event: {event}\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n" @@ -335,6 +361,19 @@ async def chat(request: RequestEnvelope) -> ResponseEnvelope | JSONResponse: return response +@app.post("/v1/batch/chat", response_model=BatchChatResponse) +async def batch_chat(request: BatchChatRequest) -> BatchChatResponse: + orchestrator = BatchOrchestrator(build_planner_bridge) + response = await orchestrator.run(request) + _log_batch_request(request, response.batchId, response.reviewTag) + return response + + +@app.post("/v1/chat/batch", response_model=BatchResponseEnvelope) +async def chat_batch(request: BatchRequestEnvelope) -> BatchResponseEnvelope: + return await run_chat_batch(request, build_planner_bridge) + + @app.post("/v1/chat/stream") async def chat_stream(request: RequestEnvelope) -> StreamingResponse: _log_accepted_request(request) diff --git a/server/batch_orchestrator.py b/server/batch_orchestrator.py new file mode 100644 index 0000000..223f9e1 --- /dev/null +++ b/server/batch_orchestrator.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable + +from server.bridge_types import PlannerBridge +from server.codex_app_server import CodexAppServerError +from shared.batch_protocol import ( + BatchChatItem, + BatchChatItemResult, + BatchChatRequest, + BatchChatResponse, + build_batch_id, + build_review_tag, +) +from shared.protocol import ErrorInfo, build_response_from_plan + +logger = logging.getLogger("darktable_agent.server") + + +class BatchOrchestrator: + def __init__(self, bridge_factory: Callable[[], PlannerBridge]) -> None: + self._bridge_factory = bridge_factory + + async def run(self, request: BatchChatRequest) -> BatchChatResponse: + batch_id = build_batch_id(request.batchId) + review_tag = build_review_tag(batch_id, request.reviewTag) + selected_items = request.items[: request.selection.maxImages] + skipped_items = request.items[request.selection.maxImages :] + bridge = self._bridge_factory() + + selected_results = await asyncio.gather( + *[ + self._run_selected_item( + bridge=bridge, + item=item, + selection_rank=index, + review_tag=review_tag, + ) + for index, item in enumerate(selected_items, start=1) + ] + ) + skipped_results = [ + self._build_skipped_result(item=item, review_tag=review_tag) + for item in skipped_items + ] + ordered_results = [*selected_results, *skipped_results] + + return BatchChatResponse( + batchId=batch_id, + reviewTag=review_tag, + submittedCount=len(request.items), + selectedCount=len(selected_items), + skippedCount=len(skipped_items), + results=ordered_results, + ) + + async def _run_selected_item( + self, + *, + bridge: PlannerBridge, + item: BatchChatItem, + selection_rank: int, + review_tag: str, + ) -> BatchChatItemResult: + try: + turn_result = await asyncio.to_thread(bridge.plan, item.request) + response = build_response_from_plan(item.request, turn_result.plan) + return BatchChatItemResult( + candidateId=item.candidateId, + requestId=item.request.requestId, + imageSessionId=item.request.session.imageSessionId, + imageId=item.request.uiContext.imageId, + imageName=item.request.uiContext.imageName, + selected=True, + selectionRank=selection_rank, + reviewTag=review_tag, + status="ok", + response=response, + error=None, + skipReason=None, + ) + except CodexAppServerError as exc: + return self._build_error_result( + item=item, + selection_rank=selection_rank, + review_tag=review_tag, + code=exc.code, + message=exc.message, + ) + except Exception: + logger.exception( + "batch_chat_item_unexpected_error", + extra={ + "structured": { + "event": "batch_chat_item_unexpected_error", + "candidateId": item.candidateId, + "requestId": item.request.requestId, + "imageSessionId": item.request.session.imageSessionId, + "conversationId": item.request.session.conversationId, + "turnId": item.request.session.turnId, + } + }, + ) + return self._build_error_result( + item=item, + selection_rank=selection_rank, + review_tag=review_tag, + code="internal_error", + message="Unexpected server error", + ) + + def _build_error_result( + self, + *, + item: BatchChatItem, + selection_rank: int, + review_tag: str, + code: str, + message: str, + ) -> BatchChatItemResult: + return BatchChatItemResult( + candidateId=item.candidateId, + requestId=item.request.requestId, + imageSessionId=item.request.session.imageSessionId, + imageId=item.request.uiContext.imageId, + imageName=item.request.uiContext.imageName, + selected=True, + selectionRank=selection_rank, + reviewTag=review_tag, + status="error", + response=None, + error=ErrorInfo(code=code, message=message), + skipReason=None, + ) + + def _build_skipped_result( + self, *, item: BatchChatItem, review_tag: str + ) -> BatchChatItemResult: + return BatchChatItemResult( + candidateId=item.candidateId, + requestId=item.request.requestId, + imageSessionId=item.request.session.imageSessionId, + imageId=item.request.uiContext.imageId, + imageName=item.request.uiContext.imageName, + selected=False, + selectionRank=None, + reviewTag=review_tag, + status="skipped", + response=None, + error=None, + skipReason="batch-limit", + ) diff --git a/server/chat_batch.py b/server/chat_batch.py new file mode 100644 index 0000000..c39fe7d --- /dev/null +++ b/server/chat_batch.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from collections.abc import Callable + +from server.batch_orchestrator import BatchOrchestrator +from server.bridge_types import PlannerBridge +from shared.batch_protocol import BatchChatRequest, BatchChatItemResult +from shared.chat_batch_protocol import ( + BatchRequestEnvelope, + BatchRequestItem, + BatchResponseEnvelope, + BatchResponseItem, +) +from shared.protocol import ( + AssistantMessage, + ErrorInfo, + RefinementStatus, + RequestEnvelope, + ResponseSession, +) + + +def build_batch_item_request( + batch_request: BatchRequestEnvelope, item: BatchRequestItem +) -> RequestEnvelope: + return RequestEnvelope( + schemaVersion=batch_request.schemaVersion, + requestId=f"{batch_request.requestId}:{item.batchItemId}", + session=item.session, + message=batch_request.message, + fast=batch_request.fast, + refinement=batch_request.refinement, + uiContext=item.uiContext, + capabilityManifest=item.capabilityManifest, + imageSnapshot=item.imageSnapshot, + ) + + +def build_chat_batch_response_item( + item: BatchRequestItem, result: BatchChatItemResult +) -> BatchResponseItem: + if result.response is not None: + return BatchResponseItem( + batchItemId=item.batchItemId, + **result.response.model_dump(mode="json"), + ) + + error = result.error or ErrorInfo( + code="internal_error", message="Unexpected server error" + ) + return BatchResponseItem( + batchItemId=item.batchItemId, + requestId=result.requestId, + session=ResponseSession.model_validate(item.session.model_dump(mode="json")), + status="error", + assistantMessage=AssistantMessage(role="assistant", text=error.message), + refinement=RefinementStatus( + mode="single-turn", + enabled=False, + passIndex=1, + maxPasses=1, + continueRefining=False, + stopReason="single-turn", + ), + plan=None, + operationResults=[], + review=None, + error=error, + ) + + +async def run_chat_batch( + request: BatchRequestEnvelope, + bridge_factory: Callable[[], PlannerBridge], +) -> BatchResponseEnvelope: + internal_request = BatchChatRequest.model_validate( + { + "batchId": request.requestId, + "items": [ + { + "candidateId": item.batchItemId, + "request": build_batch_item_request(request, item).model_dump( + mode="json" + ), + } + for item in request.items + ], + } + ) + orchestrator = BatchOrchestrator(bridge_factory) + response = await orchestrator.run(internal_request) + batch_items = [ + build_chat_batch_response_item(item, result) + for item, result in zip(request.items, response.results, strict=True) + ] + error_count = sum(1 for item in batch_items if item.status == "error") + success_count = len(batch_items) - error_count + if error_count == 0: + status = "ok" + elif success_count == 0: + status = "error" + else: + status = "partial-error" + return BatchResponseEnvelope( + requestId=request.requestId, + status=status, + itemCount=len(batch_items), + successCount=success_count, + errorCount=error_count, + reviewTag=response.reviewTag, + items=batch_items, + ) diff --git a/server/codex_bridge/prompts/turn_prompt.j2 b/server/codex_bridge/prompts/turn_prompt.j2 index 2999c2a..cdaa709 100644 --- a/server/codex_bridge/prompts/turn_prompt.j2 +++ b/server/codex_bridge/prompts/turn_prompt.j2 @@ -43,4 +43,5 @@ In multi-turn mode the final JSON should summarize the run; continueRefining mus {% else %}Single-turn mode: do not call apply_operations; return raw operations directly in the final JSON for this path. To crop in this single-turn/raw path, set the 'crop' or 'clipping' module's normalized [0.0, 1.0] parameters directly: cx=left edge, cy=top edge, cw=right edge, ch=bottom edge. No crop = cx=0, cy=0, cw=1, ch=1. Example: bottom-right quadrant = cx=0.5, cy=0.5, cw=1.0, ch=1.0. {% endif %}Respect refinement state: treat passIndex/maxPasses as budget, set continueRefining=false once safe gains are exhausted. +Always populate the optional `review` field when you can identify whether this image should be applied directly, flagged for review, or skipped. Return only the JSON object required by the output schema. diff --git a/server/mock_planner.py b/server/mock_planner.py index 7a9ea57..41eee11 100644 --- a/server/mock_planner.py +++ b/server/mock_planner.py @@ -75,6 +75,11 @@ def plan(self, request: RequestEnvelope) -> CodexTurnResult: { "assistantText": assistant_text, "continueRefining": continue_refining, + "review": { + "decision": "apply", + "summary": "Mock planner produced a deterministic exposure edit.", + "tags": ["mock", "exposure"], + }, "operations": [ { "operationId": f"mock-exposure-{request.refinement.passIndex}", diff --git a/server/tests/test_batch_api.py b/server/tests/test_batch_api.py new file mode 100644 index 0000000..5c4c6df --- /dev/null +++ b/server/tests/test_batch_api.py @@ -0,0 +1,243 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from server.app import app + + +def _sample_request_payload(request_id: str, image_id: int) -> dict: + return { + "schemaVersion": "3.0", + "requestId": request_id, + "session": { + "appSessionId": "app-1", + "imageSessionId": f"img-{image_id}", + "conversationId": f"conv-{image_id}", + "turnId": f"turn-{image_id}", + }, + "message": {"role": "user", "text": "Increase exposure by exactly 0.7 EV."}, + "fast": False, + "refinement": { + "mode": "single-turn", + "enabled": False, + "maxPasses": 1, + "passIndex": 1, + "goalText": "Increase exposure by exactly 0.7 EV.", + }, + "uiContext": { + "view": "darkroom", + "imageId": image_id, + "imageName": f"IMG_{image_id}.CR3", + }, + "capabilityManifest": { + "manifestVersion": "manifest-1", + "targets": [ + { + "moduleId": "exposure", + "moduleLabel": "exposure", + "capabilityId": "exposure.primary", + "label": "Exposure", + "kind": "set-float", + "targetType": "darktable-action", + "actionPath": "iop/exposure/exposure", + "supportedModes": ["set", "delta"], + "minNumber": -18.0, + "maxNumber": 18.0, + "defaultNumber": 0.0, + "stepNumber": 0.01, + } + ], + }, + "imageSnapshot": { + "imageRevisionId": f"image-{image_id}-history-1", + "metadata": { + "imageId": image_id, + "imageName": f"IMG_{image_id}.CR3", + "cameraMaker": "Sony", + "cameraModel": "ILCE-7RM5", + "width": 9504, + "height": 6336, + "exifExposureSeconds": 0.01, + "exifAperture": 4.0, + "exifIso": 100.0, + "exifFocalLength": 35.0, + }, + "historyPosition": 1, + "historyCount": 1, + "editableSettings": [ + { + "moduleId": "exposure", + "moduleLabel": "exposure", + "settingId": "setting.exposure.primary", + "capabilityId": "exposure.primary", + "label": "Exposure", + "actionPath": "iop/exposure/exposure", + "kind": "set-float", + "currentNumber": 0.0, + "supportedModes": ["set", "delta"], + "minNumber": -18.0, + "maxNumber": 18.0, + "defaultNumber": 0.0, + "stepNumber": 0.01, + } + ], + "history": [], + "preview": None, + "histogram": None, + }, + } + + +@pytest.mark.anyio +async def test_batch_chat_returns_review_tag_and_skips_over_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DARKTABLE_AGENT_USE_MOCK_RESPONSES", "1") + payload = { + "selection": {"maxImages": 10}, + "items": [ + { + "candidateId": f"candidate-{index}", + "request": _sample_request_payload(f"req-{index}", index), + } + for index in range(1, 13) + ], + } + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + response = await client.post("/v1/batch/chat", json=payload) + + assert response.status_code == 200 + body = response.json() + assert body["selectedCount"] == 10 + assert body["skippedCount"] == 2 + assert body["reviewTag"].startswith("darktable|agent-batch|") + assert body["results"][0]["status"] == "ok" + assert body["results"][0]["response"]["assistantMessage"]["text"].startswith( + "Mock single-turn edit" + ) + assert body["results"][10]["status"] == "skipped" + assert body["results"][10]["skipReason"] == "batch-limit" + + +@pytest.mark.anyio +async def test_batch_chat_rejects_more_than_ten_selected_images_in_config() -> None: + payload = { + "selection": {"maxImages": 11}, + "items": [ + { + "candidateId": "candidate-1", + "request": _sample_request_payload("req-1", 1), + } + ], + } + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + response = await client.post("/v1/batch/chat", json=payload) + + assert response.status_code == 422 + body = response.json() + assert body["error"]["code"] == "invalid_request" + assert "selection/maxImages" in body["error"]["message"] + + +@pytest.mark.anyio +async def test_batch_chat_rejects_live_refinement_requests() -> None: + payload = { + "items": [ + { + "candidateId": "candidate-1", + "request": { + **_sample_request_payload("req-1", 1), + "refinement": { + "mode": "multi-turn", + "enabled": True, + "maxPasses": 3, + "passIndex": 1, + "goalText": "Increase exposure by exactly 0.7 EV.", + }, + }, + } + ] + } + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + response = await client.post("/v1/batch/chat", json=payload) + + assert response.status_code == 422 + body = response.json() + assert body["error"]["code"] == "invalid_request" + assert "single-turn refinement only" in body["error"]["message"] + + +@pytest.mark.anyio +async def test_chat_batch_uses_shared_message_and_returns_review_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DARKTABLE_AGENT_USE_MOCK_RESPONSES", "1") + payload = { + "schemaVersion": "3.0", + "requestId": "batch-1", + "message": {"role": "user", "text": "Increase exposure by exactly 0.7 EV."}, + "fast": False, + "refinement": { + "mode": "single-turn", + "enabled": False, + "maxPasses": 1, + "passIndex": 1, + "goalText": "Increase exposure by exactly 0.7 EV.", + }, + "items": [ + { + "batchItemId": "item-1", + "session": { + "appSessionId": "app-1", + "imageSessionId": "img-1", + "conversationId": "conv-1", + "turnId": "turn-1", + }, + "uiContext": { + "view": "darkroom", + "imageId": 1, + "imageName": "IMG_1.CR3", + }, + "capabilityManifest": _sample_request_payload("req-1", 1)[ + "capabilityManifest" + ], + "imageSnapshot": _sample_request_payload("req-1", 1)["imageSnapshot"], + }, + { + "batchItemId": "item-2", + "session": { + "appSessionId": "app-1", + "imageSessionId": "img-2", + "conversationId": "conv-2", + "turnId": "turn-2", + }, + "uiContext": { + "view": "darkroom", + "imageId": 2, + "imageName": "IMG_2.CR3", + }, + "capabilityManifest": _sample_request_payload("req-2", 2)[ + "capabilityManifest" + ], + "imageSnapshot": _sample_request_payload("req-2", 2)["imageSnapshot"], + }, + ], + } + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + response = await client.post("/v1/chat/batch", json=payload) + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + assert body["itemCount"] == 2 + assert body["reviewTag"].startswith("darktable|agent-batch|") + assert body["items"][0]["batchItemId"] == "item-1" + assert body["items"][0]["review"]["decision"] == "apply" + assert body["items"][0]["session"]["imageSessionId"] == "img-1" diff --git a/server/tests/test_batch_orchestrator.py b/server/tests/test_batch_orchestrator.py new file mode 100644 index 0000000..4563273 --- /dev/null +++ b/server/tests/test_batch_orchestrator.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from server.batch_orchestrator import BatchOrchestrator +from server.codex_app_server import CodexAppServerError +from shared.batch_protocol import BatchChatRequest +from shared.protocol import AgentPlan + + +def _sample_request_payload(request_id: str, image_id: int) -> dict: + return { + "schemaVersion": "3.0", + "requestId": request_id, + "session": { + "appSessionId": "app-1", + "imageSessionId": f"img-{image_id}", + "conversationId": f"conv-{image_id}", + "turnId": f"turn-{image_id}", + }, + "message": {"role": "user", "text": "Make it brighter"}, + "fast": False, + "refinement": { + "mode": "single-turn", + "enabled": False, + "maxPasses": 1, + "passIndex": 1, + "goalText": "Make it brighter", + }, + "uiContext": { + "view": "darkroom", + "imageId": image_id, + "imageName": f"IMG_{image_id}.CR3", + }, + "capabilityManifest": { + "manifestVersion": "manifest-1", + "targets": [ + { + "moduleId": "exposure", + "moduleLabel": "exposure", + "capabilityId": "exposure.primary", + "label": "Exposure", + "kind": "set-float", + "targetType": "darktable-action", + "actionPath": "iop/exposure/exposure", + "supportedModes": ["set", "delta"], + "minNumber": -18.0, + "maxNumber": 18.0, + "defaultNumber": 0.0, + "stepNumber": 0.01, + } + ], + }, + "imageSnapshot": { + "imageRevisionId": f"image-{image_id}-history-1", + "metadata": { + "imageId": image_id, + "imageName": f"IMG_{image_id}.CR3", + "cameraMaker": "Sony", + "cameraModel": "ILCE-7RM5", + "width": 9504, + "height": 6336, + "exifExposureSeconds": 0.01, + "exifAperture": 4.0, + "exifIso": 100.0, + "exifFocalLength": 35.0, + }, + "historyPosition": 1, + "historyCount": 1, + "editableSettings": [ + { + "moduleId": "exposure", + "moduleLabel": "exposure", + "settingId": "setting.exposure.primary", + "capabilityId": "exposure.primary", + "label": "Exposure", + "actionPath": "iop/exposure/exposure", + "kind": "set-float", + "currentNumber": 0.0, + "supportedModes": ["set", "delta"], + "minNumber": -18.0, + "maxNumber": 18.0, + "defaultNumber": 0.0, + "stepNumber": 0.01, + } + ], + "history": [], + "preview": None, + "histogram": None, + }, + } + + +def _sample_batch_payload(count: int, *, max_images: int = 10) -> dict: + return { + "selection": {"maxImages": max_images}, + "items": [ + { + "candidateId": f"candidate-{index}", + "request": _sample_request_payload(f"req-{index}", index), + } + for index in range(1, count + 1) + ], + } + + +@dataclass +class _FakeTurnResult: + plan: AgentPlan + thread_id: str + turn_id: str + raw_message: str + + +class _SuccessBridge: + def plan(self, request): + plan = AgentPlan.model_validate( + { + "assistantText": f"Edited {request.uiContext.imageName}", + "continueRefining": False, + "operations": [ + { + "operationId": f"op-{request.requestId}", + "sequence": 1, + "kind": "set-float", + "target": { + "type": "darktable-action", + "actionPath": "iop/exposure/exposure", + "settingId": "setting.exposure.primary", + }, + "value": {"mode": "delta", "number": 0.5}, + "reason": None, + "constraints": { + "onOutOfRange": "clamp", + "onRevisionMismatch": "fail", + }, + } + ], + } + ) + return _FakeTurnResult( + plan=plan, + thread_id=f"thread-{request.requestId}", + turn_id=f"turn-{request.requestId}", + raw_message=plan.model_dump_json(), + ) + + +class _MixedBridge: + def plan(self, request): + if request.requestId.endswith("2"): + raise CodexAppServerError( + "planner_failed", "Planner rejected this candidate", status_code=422 + ) + return _SuccessBridge().plan(request) + + +@pytest.mark.anyio +async def test_batch_orchestrator_marks_excess_items_skipped() -> None: + request = BatchChatRequest.model_validate(_sample_batch_payload(12, max_images=10)) + orchestrator = BatchOrchestrator(_SuccessBridge) + + response = await orchestrator.run(request) + + assert response.selectedCount == 10 + assert response.skippedCount == 2 + assert response.results[9].status == "ok" + assert response.results[10].status == "skipped" + assert response.results[10].skipReason == "batch-limit" + assert response.results[10].selected is False + + +@pytest.mark.anyio +async def test_batch_orchestrator_captures_per_item_errors() -> None: + request = BatchChatRequest.model_validate(_sample_batch_payload(3, max_images=3)) + orchestrator = BatchOrchestrator(_MixedBridge) + + response = await orchestrator.run(request) + + assert [result.status for result in response.results] == ["ok", "error", "ok"] + assert response.results[1].error is not None + assert response.results[1].error.code == "planner_failed" + assert response.results[1].reviewTag == response.reviewTag diff --git a/server/tests/test_batch_protocol.py b/server/tests/test_batch_protocol.py new file mode 100644 index 0000000..243368b --- /dev/null +++ b/server/tests/test_batch_protocol.py @@ -0,0 +1,173 @@ +import pytest +from pydantic import ValidationError + +from shared.batch_protocol import ( + BatchChatItemResult, + BatchChatRequest, + build_batch_id, + build_review_tag, +) + + +def _sample_request_payload(request_id: str = "req-1", image_id: int = 12) -> dict: + return { + "schemaVersion": "3.0", + "requestId": request_id, + "session": { + "appSessionId": "app-1", + "imageSessionId": f"img-{image_id}", + "conversationId": f"conv-{image_id}", + "turnId": f"turn-{image_id}", + }, + "message": {"role": "user", "text": "Make it brighter"}, + "fast": False, + "refinement": { + "mode": "single-turn", + "enabled": False, + "maxPasses": 1, + "passIndex": 1, + "goalText": "Make it brighter", + }, + "uiContext": { + "view": "darkroom", + "imageId": image_id, + "imageName": f"IMG_{image_id}.CR3", + }, + "capabilityManifest": { + "manifestVersion": "manifest-1", + "targets": [ + { + "moduleId": "exposure", + "moduleLabel": "exposure", + "capabilityId": "exposure.primary", + "label": "Exposure", + "kind": "set-float", + "targetType": "darktable-action", + "actionPath": "iop/exposure/exposure", + "supportedModes": ["set", "delta"], + "minNumber": -18.0, + "maxNumber": 18.0, + "defaultNumber": 0.0, + "stepNumber": 0.01, + } + ], + }, + "imageSnapshot": { + "imageRevisionId": f"image-{image_id}-history-1", + "metadata": { + "imageId": image_id, + "imageName": f"IMG_{image_id}.CR3", + "cameraMaker": "Sony", + "cameraModel": "ILCE-7RM5", + "width": 9504, + "height": 6336, + "exifExposureSeconds": 0.01, + "exifAperture": 4.0, + "exifIso": 100.0, + "exifFocalLength": 35.0, + }, + "historyPosition": 1, + "historyCount": 1, + "editableSettings": [ + { + "moduleId": "exposure", + "moduleLabel": "exposure", + "settingId": "setting.exposure.primary", + "capabilityId": "exposure.primary", + "label": "Exposure", + "actionPath": "iop/exposure/exposure", + "kind": "set-float", + "currentNumber": 0.0, + "supportedModes": ["set", "delta"], + "minNumber": -18.0, + "maxNumber": 18.0, + "defaultNumber": 0.0, + "stepNumber": 0.01, + } + ], + "history": [], + "preview": None, + "histogram": None, + }, + } + + +def test_batch_request_rejects_duplicate_candidate_ids() -> None: + payload = { + "items": [ + {"candidateId": "dup", "request": _sample_request_payload("req-1", 1)}, + {"candidateId": "dup", "request": _sample_request_payload("req-2", 2)}, + ] + } + + with pytest.raises(ValidationError): + BatchChatRequest.model_validate(payload) + + +def test_build_batch_id_and_review_tag_generate_defaults() -> None: + batch_id = build_batch_id(None) + review_tag = build_review_tag(batch_id, None) + + assert batch_id.startswith("batch-") + assert review_tag.startswith("darktable|agent-batch|") + + +def test_batch_request_rejects_live_refinement_items() -> None: + payload = { + "items": [ + { + "candidateId": "candidate-1", + "request": { + **_sample_request_payload("req-1", 1), + "refinement": { + "mode": "multi-turn", + "enabled": True, + "maxPasses": 3, + "passIndex": 1, + "goalText": "Make it brighter", + }, + }, + } + ] + } + + with pytest.raises(ValidationError): + BatchChatRequest.model_validate(payload) + + +def test_batch_item_result_validates_skipped_state_shape() -> None: + with pytest.raises(ValidationError): + BatchChatItemResult.model_validate( + { + "candidateId": "candidate-1", + "requestId": "req-1", + "imageSessionId": "img-1", + "imageId": 1, + "imageName": "IMG_1.CR3", + "selected": False, + "selectionRank": None, + "reviewTag": "darktable|agent-batch|foo", + "status": "skipped", + "response": None, + "error": None, + "skipReason": None, + } + ) + + +def test_review_metadata_rejects_duplicate_tags() -> None: + payload = { + "assistantText": "Edit image", + "continueRefining": False, + "review": { + "decision": "review", + "summary": "Needs eyes-on review.", + "tags": ["portrait", "Portrait"], + }, + "operations": [], + } + + from shared.protocol import AgentPlan + + with pytest.raises(ValidationError): + AgentPlan.model_validate(payload) diff --git a/shared/__init__.py b/shared/__init__.py index 09e95a4..88563ee 100644 --- a/shared/__init__.py +++ b/shared/__init__.py @@ -1 +1,3 @@ +from .batch_protocol import BatchChatRequest, BatchChatResponse +from .chat_batch_protocol import BatchRequestEnvelope, BatchResponseEnvelope from .protocol import ErrorInfo, ProtocolError, RequestEnvelope, ResponseEnvelope diff --git a/shared/batch_protocol.py b/shared/batch_protocol.py new file mode 100644 index 0000000..1e30711 --- /dev/null +++ b/shared/batch_protocol.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import re +import uuid +from typing import Literal + +from pydantic import Field, model_validator + +from .protocol import ( + ErrorInfo, + RequestEnvelope, + ResponseEnvelope, + SCHEMA_VERSION, + StrictBaseModel, +) + +_DEFAULT_BATCH_LIMIT = 10 +_TAG_SAFE_CHARS_RE = re.compile(r"[^a-z0-9._-]+") + + +class BatchChatItem(StrictBaseModel): + candidateId: str = Field(min_length=1) + request: RequestEnvelope + + +class BatchSelectionConfig(StrictBaseModel): + maxImages: int = Field(default=_DEFAULT_BATCH_LIMIT, ge=1, le=_DEFAULT_BATCH_LIMIT) + strategy: Literal["request-order"] = "request-order" + + +class BatchChatRequest(StrictBaseModel): + schemaVersion: Literal["3.0"] = SCHEMA_VERSION + batchId: str | None = None + reviewTag: str | None = None + selection: BatchSelectionConfig = Field(default_factory=BatchSelectionConfig) + items: list[BatchChatItem] = Field(min_length=1) + + @model_validator(mode="after") + def validate_candidate_ids(self) -> "BatchChatRequest": + candidate_ids = [item.candidateId for item in self.items] + if len(candidate_ids) != len(set(candidate_ids)): + raise ValueError( + "batch items must not contain duplicate candidateId values" + ) + for item in self.items: + if item.request.refinement.enabled: + raise ValueError( + "batch requests currently support single-turn refinement only" + ) + return self + + +class BatchChatItemResult(StrictBaseModel): + candidateId: str + requestId: str + imageSessionId: str + imageId: int | None + imageName: str | None + selected: bool + selectionRank: int | None + reviewTag: str | None + status: Literal["ok", "error", "skipped"] + response: ResponseEnvelope | None + error: ErrorInfo | None + skipReason: Literal["batch-limit"] | None + + @model_validator(mode="after") + def validate_status_payload(self) -> "BatchChatItemResult": + if self.status == "ok": + if ( + self.response is None + or self.error is not None + or self.skipReason is not None + ): + raise ValueError("ok batch results must include response only") + elif self.status == "error": + if ( + self.response is not None + or self.error is None + or self.skipReason is not None + ): + raise ValueError("error batch results must include error only") + else: + if ( + self.response is not None + or self.error is not None + or self.skipReason is None + ): + raise ValueError("skipped batch results must include skipReason only") + return self + + +class BatchChatResponse(StrictBaseModel): + schemaVersion: Literal["3.0"] = SCHEMA_VERSION + batchId: str = Field(min_length=1) + reviewTag: str = Field(min_length=1) + submittedCount: int = Field(ge=1) + selectedCount: int = Field(ge=0) + skippedCount: int = Field(ge=0) + results: list[BatchChatItemResult] = Field(min_length=1) + + @model_validator(mode="after") + def validate_counts(self) -> "BatchChatResponse": + if self.submittedCount != len(self.results): + raise ValueError("submittedCount must match number of results") + selected_count = sum(1 for result in self.results if result.selected) + skipped_count = sum(1 for result in self.results if result.status == "skipped") + if self.selectedCount != selected_count: + raise ValueError("selectedCount must match selected results") + if self.skippedCount != skipped_count: + raise ValueError("skippedCount must match skipped results") + return self + + +def build_batch_id(batch_id: str | None) -> str: + if batch_id: + return batch_id + return f"batch-{uuid.uuid4().hex[:12]}" + + +def build_review_tag(batch_id: str, review_tag: str | None) -> str: + if review_tag: + return review_tag + normalized_batch_id = _TAG_SAFE_CHARS_RE.sub("-", batch_id.lower()).strip("-") + if not normalized_batch_id: + normalized_batch_id = "batch" + return f"darktable|agent-batch|{normalized_batch_id}" diff --git a/shared/chat_batch_protocol.py b/shared/chat_batch_protocol.py new file mode 100644 index 0000000..6cb024c --- /dev/null +++ b/shared/chat_batch_protocol.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import Field, model_validator + +from .protocol import ( + RefinementRequest, + RequestSession, + ResponseEnvelope, + SCHEMA_VERSION, + StrictBaseModel, + UIContext, + UserMessage, + CapabilityManifest, + ImageSnapshot, +) + + +class BatchRequestItem(StrictBaseModel): + batchItemId: str = Field(min_length=1) + session: RequestSession + uiContext: UIContext + capabilityManifest: CapabilityManifest + imageSnapshot: ImageSnapshot + + +class BatchRequestEnvelope(StrictBaseModel): + schemaVersion: Literal["3.0"] = SCHEMA_VERSION + requestId: str = Field(min_length=1) + message: UserMessage + fast: bool + refinement: RefinementRequest + items: list[BatchRequestItem] = Field(min_length=1, max_length=10) + + @model_validator(mode="after") + def validate_batch_shape(self) -> "BatchRequestEnvelope": + if self.refinement.enabled: + raise ValueError( + "batch requests currently support single-turn refinement only" + ) + batch_item_ids = [item.batchItemId for item in self.items] + if len(batch_item_ids) != len(set(batch_item_ids)): + raise ValueError( + "batch requests must not contain duplicate batchItemId values" + ) + return self + + +class BatchResponseItem(ResponseEnvelope): + batchItemId: str = Field(min_length=1) + + +class BatchResponseEnvelope(StrictBaseModel): + schemaVersion: Literal["3.0"] = SCHEMA_VERSION + requestId: str = Field(min_length=1) + status: str + itemCount: int = Field(ge=0) + successCount: int = Field(ge=0) + errorCount: int = Field(ge=0) + reviewTag: str = Field(min_length=1) + items: list[BatchResponseItem] + + @model_validator(mode="after") + def validate_counts(self) -> "BatchResponseEnvelope": + if self.itemCount != len(self.items): + raise ValueError("itemCount must match number of items") + return self diff --git a/shared/protocol.py b/shared/protocol.py index 1af6d26..7a27f95 100644 --- a/shared/protocol.py +++ b/shared/protocol.py @@ -6,6 +6,7 @@ from .canonical_plan import CanonicalEditAction from .analysis_signals import ImageAnalysisSignals +from .review_protocol import ReviewMetadata SCHEMA_VERSION = "3.0" DEFAULT_REFINEMENT_MAX_PASSES = 15 @@ -410,6 +411,7 @@ class AgentPlan(StrictBaseModel): continueRefining: bool operations: list[PlannedOperationDraft] canonicalActions: list[CanonicalEditAction] | None = None + review: ReviewMetadata | None = None @model_validator(mode="after") def validate_operation_ids(self) -> "AgentPlan": @@ -459,6 +461,7 @@ class ResponseEnvelope(StrictBaseModel): refinement: RefinementStatus plan: PlanEnvelope | None operationResults: list[OperationResult] + review: ReviewMetadata | None = None error: ErrorInfo | None @model_validator(mode="after") @@ -507,6 +510,7 @@ def build_response_from_plan( ) for operation in plan.operations ], + review=plan.review, error=None, ) diff --git a/shared/review_protocol.py b/shared/review_protocol.py new file mode 100644 index 0000000..ecb31c7 --- /dev/null +++ b/shared/review_protocol.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class StrictBaseModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +ReviewDecision = Literal["apply", "review", "skip"] + + +class ReviewMetadata(StrictBaseModel): + decision: ReviewDecision + summary: str = Field(min_length=1) + tags: list[str] = Field(default_factory=list, max_length=12) + + @model_validator(mode="after") + def validate_tags(self) -> "ReviewMetadata": + normalized_tags: list[str] = [] + seen_tags: set[str] = set() + for raw_tag in self.tags: + tag = raw_tag.strip() + if not tag: + raise ValueError("review tags must not be empty") + folded_tag = tag.casefold() + if folded_tag in seen_tags: + raise ValueError("review tags must be unique") + seen_tags.add(folded_tag) + normalized_tags.append(tag) + self.tags = normalized_tags + return self From a92c41eebae0c4dbbe1461b47918d5cba6addcdd Mon Sep 17 00:00:00 2001 From: Connor Gasgarth Date: Tue, 7 Apr 2026 16:54:34 -0500 Subject: [PATCH 2/2] fix build --- scripts/run_server.sh | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/scripts/run_server.sh b/scripts/run_server.sh index 70c7403..b72e310 100755 --- a/scripts/run_server.sh +++ b/scripts/run_server.sh @@ -4,11 +4,6 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" HOST="${HOST:-${DARKTABLE_AGENT_SERVER_HOST:-127.0.0.1}}" PORT="${PORT:-${DARKTABLE_AGENT_SERVER_PORT:-8001}}" -PYTHON_BIN="${PYTHON_BIN:-$ROOT_DIR/.venv/bin/python}" - -if [[ ! -x "$PYTHON_BIN" ]]; then - PYTHON_BIN="python3" -fi cd "$ROOT_DIR" -exec "$PYTHON_BIN" -m uvicorn server.app:app --host "$HOST" --port "$PORT" +exec uv run python -m uvicorn server.app:app --host "$HOST" --port "$PORT"