Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions crates/agent/src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ use plotium_core::automation::{
};
use serde_json::{Value, json};

pub(crate) const EDIT_OPERATIONS: [&str; 27] = [
pub(crate) const EDIT_OPERATIONS: [&str; 28] = [
"analysis.diffusion.plot",
"plot.field.create",
"analysis.dosy.build",
"analysis.regions.extract",
"analysis.diffusion.fit",
"nmr.volume.edit",
"nmr.peaks3d",
"processing.edit",
"processing.apply_scheme",
"diffusion.set_input",
Expand All @@ -36,7 +37,9 @@ pub(crate) const EDIT_OPERATIONS: [&str; 27] = [

/// One line per editable operation, so a caller can map a task to an operation
/// before fetching its schema.
const PURPOSES: [(&str, &str); 30] = [
const PURPOSES: [(&str, &str); 32] = [
("nmr.peaks3d", "Add, edit, delete or locate manual 3D peaks"),
("nmr.peaks3d.list", "Read with plotium_peaks3d_inspect"),
(
"nmr.volume.edit",
"Select a 3D spectrum plane and optionally adjust per-axis phase",
Expand Down Expand Up @@ -193,6 +196,7 @@ fn tool_index() -> Vec<Value> {
"plotium_tasks_cancel"=>(vec!["tasks.cancel"],"{task:TaskReceipt}; task_not_found never authorizes replay","TaskId; callable while busy or after project change; requires reversible_modify"),
"plotium_analysis_results_inspect"=>(vec!["analysis.results.inspect"],"ToolResult.value: paginated native DOSY/ILT columns, validity, counts and provenance","one result field ID"),
"plotium_diffusion_inspect"=>(vec!["diffusion.inspect"],"ToolResult.value: {resolution_source,increment_count,input:DiffusionInputReport,field_requirement,executable,acquisition_evidence}","one dataset or field ID"),
"plotium_peaks3d_inspect"=>(vec!["nmr.peaks3d.list"],"ToolResult.value: {dataset,dataset_name,source,processing,axes,peaks,position,intensity}","one 3D dataset ID"),
"plotium_processing_inspect"=>(vec!["processing.inspect"],"ToolResult.value: {dataset,axes:[{axis,pipeline|settings,pending_apply}]}","one dataset ID"),
"plotium_get_state"=>(vec!["state.get"],"{project_context,revision,availability,project:{dataset_count,canvas_count},selection:{items,total,next_offset,canvas:{items:[{target,kind,name,visible}],total,part}},hand_edits:{items:[{target,key,label,value}],total,next_offset},permissions,capability_fingerprint,save_state}; latest state per call, not a frozen selection snapshot","application; project, selection and hand_edits are null while busy"),
"plotium_capabilities_get"=>(vec!["capabilities.get"],"{} returns {capability_fingerprint,connection_capabilities:index}; {section,key?} returns {capability_fingerprint,section,key?,contract}; immutable contracts, not current authorization","static; no project or revision required; callable while busy"),
Expand Down Expand Up @@ -238,10 +242,11 @@ fn operation_constraints() -> serde_json::Map<String, Value> {
.get(id)
.expect("editable operations are registered")
.parameter_schema;
// Scientific edits start background work at preflight, so planning them needs modify.
// Keep discovery aligned with the scientific-plan permission gate.
let scientific = id.starts_with("processing.")
|| id.starts_with("diffusion.")
|| id.starts_with("analysis.");
|| id.starts_with("analysis.")
|| id.starts_with("nmr.");
constraints.insert(id.into(), json!({"schema":schema,"preflight_permission":if scientific {"reversible_modify"} else {"read"},"execute_permission":"reversible_modify"}));
}
constraints["dataset.delete"]["result_contract"] = json!(
Expand All @@ -258,7 +263,7 @@ fn sections() -> serde_json::Map<String, Value> {
let constraints = operation_constraints();
let details = crate::operation_details::build(&constraints["figure.edit"]);
let scientific = plotium_core::automation::ToolRegistry::built_in().descriptors()
.filter(|d|d.id.starts_with("processing.")||d.id.starts_with("diffusion.")||d.id.starts_with("analysis."))
.filter(|d|d.id.starts_with("processing.")||d.id.starts_with("diffusion.")||d.id.starts_with("analysis.")||d.id.starts_with("nmr."))
.map(|d| {
let mut entry = json!({"descriptor":d,"required_permission":if d.effect==plotium_core::automation::EffectLevel::ReadOnly{"read"}else{"reversible_modify"}});
if EDIT_OPERATIONS.contains(&d.id.as_str()) {
Expand Down
3 changes: 3 additions & 0 deletions crates/agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,6 @@ mod spectral_processing_tests;

#[cfg(test)]
mod scientific_plot_tests;

#[cfg(test)]
mod peaks3d_tests;
83 changes: 83 additions & 0 deletions crates/agent/src/peaks3d_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use crate::Session;
use plotium_core::state::PlotiumApp;
use serde_json::{Value, json};

fn execute(session: &mut Session, app: &mut PlotiumApp, tool: &str, parameters: Value) -> Value {
let request = json!({"tool_id":tool,"parameters":parameters,
"ids":[app.doc.datasets[0].resource_id().to_string()],
"expected_revision":app.doc.automation_revision,
"expected_project_context":app.session.project_context});
let plan = session.call(app, "plotium_plan", request).unwrap();
session
.call(app, "plotium_execute", json!({"plan_id":plan["plan_id"]}))
.unwrap()
}
#[test]
fn mcp_peak_edits_and_readonly_export_match_desktop_core() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../io/tests/fixtures/nmr/bruker-3d-qf");
let (dataset, _) = plotium_core::workflow::dataset_from_acquisition(
plotium_io::load_path(path).unwrap().acquisition,
)
.unwrap();
let owner = dataset.resource_id();
let volume = dataset.as_nmr2d().unwrap().volume.as_ref().unwrap();
let ppm: [f64; 3] = std::array::from_fn(|a| volume.processed.axis_coordinate(a, 1).unwrap());
let mut direct = PlotiumApp::new();
direct.doc.datasets.push(dataset.clone());
let mut app = PlotiumApp::new();
app.doc.datasets.push(dataset);
let mut session = Session::default();
session.set_allow_modify(true);
let id = direct.add_peak_3d(owner, ppm).unwrap();
execute(
&mut session,
&mut app,
"nmr.peaks3d",
json!({"operation":"add","ppm":ppm}),
);
direct
.edit_peak_3d(owner, id, ppm, "overlap".into())
.unwrap();
execute(
&mut session,
&mut app,
"nmr.peaks3d",
json!({"operation":"edit","id":id,"note":"overlap"}),
);
direct.locate_peak_3d(owner, id).unwrap();
execute(
&mut session,
&mut app,
"nmr.peaks3d",
json!({"operation":"locate","id":id}),
);
assert_eq!(
app.session.ui.selected_peak_3d,
direct.session.ui.selected_peak_3d
);
session.set_allow_modify(false);
let revision = app.doc.automation_revision;
let query = json!({"ids":[owner.to_string()],"expected_revision":revision,
"expected_project_context":app.session.project_context});
let result = session
.call(&mut app, "plotium_peaks3d_inspect", query)
.unwrap();
assert_eq!(app.doc.automation_revision, revision);
assert_eq!(result["value"], direct.peak_table_3d(owner).unwrap());
session.set_allow_modify(true);
direct.delete_peak_3d(owner, id).unwrap();
let result = execute(
&mut session,
&mut app,
"nmr.peaks3d",
json!({"operation":"delete","id":id}),
);
assert_eq!(result["value"], direct.peak_table_3d(owner).unwrap());
app.undo();
direct.undo();
assert_eq!(
app.peak_table_3d(owner).unwrap(),
direct.peak_table_3d(owner).unwrap()
);
}
1 change: 1 addition & 0 deletions crates/agent/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ pub(crate) fn definitions() -> Vec<Tool> {
tools.push(Tool::new(name,"Read native table data at explicit document and table revisions; no selection fallback.",schema.as_object().expect("local schema").clone()));
}
for (name, id) in [
("plotium_peaks3d_inspect", "nmr.peaks3d.list"),
(
"plotium_analysis_results_inspect",
plotium_core::automation::ANALYSIS_RESULTS_INSPECT,
Expand Down
12 changes: 2 additions & 10 deletions crates/agent/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,22 +411,14 @@ impl Session {
name,
"plotium_diffusion_inspect"
| "plotium_processing_inspect"
| "plotium_peaks3d_inspect"
| "plotium_analysis_results_inspect"
) {
return self.scientific_read(app, name, arguments);
}
if name == "plotium_plan" {
let args = Plan::parse(arguments)?;
if matches!(
args.tool_id.as_str(),
PROCESSING_EDIT
| "nmr.volume.edit"
| DIFFUSION_SET_INPUT
| DOSY_BUILD
| REGIONS_EXTRACT
| DIFFUSION_FIT
| "processing.apply_scheme"
) {
if science::is_scientific_edit(&args.tool_id) {
return self.scientific_plan(app, args);
}
if args.tool_id == "figure.export" {
Expand Down
16 changes: 16 additions & 0 deletions crates/agent/src/session/science.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
//! Scientific calls use core descriptors and the existing plan/execute gate.
use super::*;

pub(super) fn is_scientific_edit(tool: &str) -> bool {
matches!(
tool,
PROCESSING_EDIT
| "nmr.volume.edit"
| "nmr.peaks3d"
| DIFFUSION_SET_INPUT
| DOSY_BUILD
| REGIONS_EXTRACT
| DIFFUSION_FIT
| "processing.apply_scheme"
)
}

impl Session {
pub(super) fn scientific_tasks(
&mut self,
Expand Down Expand Up @@ -114,6 +128,8 @@ impl Session {
let query: Query = decode(args)?;
let id = if name == "plotium_analysis_results_inspect" {
ANALYSIS_RESULTS_INSPECT
} else if name == "plotium_peaks3d_inspect" {
"nmr.peaks3d.list"
} else if name == "plotium_diffusion_inspect" {
DIFFUSION_INSPECT
} else {
Expand Down
1 change: 1 addition & 0 deletions crates/agent/src/tool_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ impl ToolEffect {
| "plotium_analysis_results_inspect"
| "plotium_diffusion_inspect"
| "plotium_processing_inspect"
| "plotium_peaks3d_inspect"
| "plotium_tasks_get"
| "plotium_tasks_list"
| "plotium_save_status"
Expand Down
1 change: 1 addition & 0 deletions crates/agent/src/tool_effects_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ fn every_advertised_tool_has_explicit_conservative_annotations() {
"plotium_analysis_results_inspect",
"plotium_diffusion_inspect",
"plotium_processing_inspect",
"plotium_peaks3d_inspect",
"plotium_tasks_get",
"plotium_tasks_list",
"plotium_save_status",
Expand Down
13 changes: 12 additions & 1 deletion crates/app/src/ui/canvas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ mod panel_interactions;
mod panel_notes;
mod panel_selection;
mod peaks;
mod peaks3d;
mod phase;
mod plot_annotation_menu;
mod plot_labels;
Expand Down Expand Up @@ -390,7 +391,16 @@ pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) {
handle_integral_drag(app, ci, object_id, di, plot, ui, &resp)
}
}
Tool::Peaks => handle_peaks(app, ci, object_id, di, plot, ui, &resp),
Tool::Peaks => {
if app.doc.datasets[di]
.as_nmr2d()
.is_some_and(|n| n.volume.is_some())
{
peaks3d::handle_volume_peaks(app, ci, object_id, di, plot, ui, &resp);
} else {
handle_peaks(app, ci, object_id, di, plot, ui, &resp);
}
}
Tool::InspectCursor | Tool::DeltaCursor => {
handle_cursor_tool(app, ci, object_id, di, plot, ui)
}
Expand All @@ -411,6 +421,7 @@ pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) {
paint_integrals(app, ci, object_id, di, plot, &painter, chrome);
paint_integrals_2d(app, ci, object_id, di, plot, &painter, chrome);
paint_peaks(app, ci, object_id, di, plot, &painter, chrome);
peaks3d::paint_volume_peaks(app, ci, object_id, di, plot, &painter);
if pointer_owned {
paint_reference_pick(app, ci, object_id, di, plot, ui, &painter, chrome);
}
Expand Down
Loading
Loading