research: reject-with-revision loop (R2)
Before: reviewer rejected a publish → audit log flipped, topic stayed in reviewing, no way to feed the critique back into the run pipeline. Reviewers with revision notes had to eat them or hand-message the coordinator. Now: reject accepts an optional `notes` field. When present: - Persisted on the research_publish_approvals row (migration 0039). - Topic flips `reviewing → standby` so the next `start_topic` is legal. - `start_topic` reads the most recent rejected-approval notes for the topic and prepends "PRIOR REVIEW NOTES (address these in this revision):\n<notes>\n---" to the coordinator task. Loop closes through the same run pipeline — no new spawn code path, which means the reviewer's guidance flows through the same topology_worker, run_events, outcome-writer chain and lands as a fresh research_outcomes row (versioned, prior drafts preserved). No notes on reject = legacy behavior (topic stays in reviewing, publish requests still allowed). Migration 0039 adds nullable `notes TEXT` to research_publish_approvals. `decide()` gains a `notes: Option<&str>` parameter (only one caller, updated inline). New `latest_rejection_notes(pool, topic_id)` helper for start_topic. Frontend: - rejectPublish(id, notes?) now sends a JSON body when notes are provided. - ResearchCanvas reject button opens an inline form with a textarea + Cancel/"Send back for revision" pair. Empty notes → plain reject. - Button label switches: "Send back for revision" when notes present, "Reject without notes" when empty. Follow-up: - Notes shown in the review UI on the resulting draft so the next reviewer sees what changed. - Multiple rejection rounds — currently only the LATEST rejection's notes surface. Accumulating history is a schema-only tweak.
This commit is contained in:
@@ -742,7 +742,7 @@ pub async fn start_topic(
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let task = build_coordinator_task(
|
||||
let mut task = build_coordinator_task(
|
||||
&topic.title,
|
||||
&topic.outcome_kind,
|
||||
&topic.description,
|
||||
@@ -750,6 +750,17 @@ pub async fn start_topic(
|
||||
&roster_lines,
|
||||
repo_context.as_ref(),
|
||||
);
|
||||
// R2 — reject-with-revision. If the last publish decision was a
|
||||
// reject with reviewer notes, prepend them to the coordinator task
|
||||
// as revision guidance. This is what closes the loop: the reviewer's
|
||||
// critique steers the next iteration through the same run pipeline.
|
||||
if let Ok(Some(notes)) =
|
||||
cm_db::repo::research_publish_approvals::latest_rejection_notes(&state.pool, id).await
|
||||
{
|
||||
task = format!(
|
||||
"PRIOR REVIEW NOTES (address these in this revision):\n{notes}\n\n---\n\n{task}"
|
||||
);
|
||||
}
|
||||
|
||||
let run_id = uuid::Uuid::now_v7();
|
||||
cm_db::repo::topology_runs::enqueue_run_for_research_topic(
|
||||
@@ -852,13 +863,18 @@ pub async fn list_pending_publish(
|
||||
}
|
||||
|
||||
/// Shared body of approve + reject. On approve, transition the topic
|
||||
/// `reviewing → publishing` (and set published_at via set_status). On
|
||||
/// reject, topic stays put; new requests are allowed.
|
||||
/// `reviewing → publishing → published` and teardown its container. On
|
||||
/// reject: audit the decision. If the reject carries revision `notes`,
|
||||
/// bump the topic `reviewing → standby` so a subsequent `start_topic`
|
||||
/// spawns a fresh run with the reviewer's guidance folded into the
|
||||
/// coordinator prompt (R2). Without notes: legacy behavior — topic
|
||||
/// stays in reviewing, new publish requests are allowed.
|
||||
async fn decide_publish(
|
||||
state: AppState,
|
||||
user: cm_auth::AuthedUser,
|
||||
id: Uuid,
|
||||
approve: bool,
|
||||
notes: Option<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let approval =
|
||||
cm_db::repo::research_publish_approvals::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||
@@ -867,12 +883,17 @@ async fn decide_publish(
|
||||
if approval.status != "pending" {
|
||||
return Err(ApiError::Conflict);
|
||||
}
|
||||
let notes_ref = notes
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let landed = cm_db::repo::research_publish_approvals::decide(
|
||||
&state.pool,
|
||||
id,
|
||||
user.workspace_id.as_uuid(),
|
||||
user.user_id.as_uuid(),
|
||||
approve,
|
||||
notes_ref,
|
||||
)
|
||||
.await?;
|
||||
// Someone else won the race — treat as a no-op success; the topic
|
||||
@@ -910,24 +931,46 @@ async fn decide_publish(
|
||||
// The research work is done — tear down the per-topic team
|
||||
// container. Artifact-writing already happened during runs.
|
||||
crate::research_container::teardown(approval.topic_id).await;
|
||||
} else if notes_ref.is_some() {
|
||||
// Reject-with-revision (R2): flip the topic back to standby so
|
||||
// the reviewer's guidance takes effect on the next `start_topic`
|
||||
// via `latest_rejection_notes` in the coordinator prompt.
|
||||
cm_db::repo::research_topics::set_status(
|
||||
&state.pool,
|
||||
approval.topic_id,
|
||||
user.workspace_id.as_uuid(),
|
||||
"standby",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
pub struct RejectPublishRequest {
|
||||
/// Optional revision guidance. When present + non-empty, decide_publish
|
||||
/// bumps the topic back to standby and start_topic reads the notes
|
||||
/// from the approval row to steer the next coordinator prompt.
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn approve_publish(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
decide_publish(state, user, id, true).await
|
||||
decide_publish(state, user, id, true, None).await
|
||||
}
|
||||
|
||||
pub async fn reject_publish(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path(id): Path<Uuid>,
|
||||
body: Option<Json<RejectPublishRequest>>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
decide_publish(state, user, id, false).await
|
||||
let notes = body.and_then(|Json(b)| b.notes);
|
||||
decide_publish(state, user, id, false, notes).await
|
||||
}
|
||||
|
||||
/// `GET /api/research/:id/artifact` — download the latest outcome as
|
||||
|
||||
Reference in New Issue
Block a user