research: backend routes + repo + wizard refine (behind /api/research)
ci / gates (push) Successful in 7s
ci / rust (push) Failing after 11s
ci / frontend (push) Successful in 27s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Second commit of the Research + Loops arc. Builds on 0030 by lighting up
the container CRUD, the agent-slot attach/detach, the standby→processing
transition, and the wizard's one-shot LLM refine.

  GET    /api/research                     list workspace's topics
  POST   /api/research                     create (accepts wizard output)
  GET    /api/research/:id                 detail (topic + attached agents)
  PATCH  /api/research/:id                 update non-status fields
  POST   /api/research/:id/agents          attach agent (idempotent)
  DELETE /api/research/:id/agents/:agent   detach
  POST   /api/research/:id/start           standby → processing
  POST   /api/research/wizard/refine       one-shot LLM refine

The refine endpoint accumulates the workspace's default LLM provider's
stream into a JSON object (`{title, description}`) with the tight system
prompt at the top of the module. Same provider routing as agent runs
(via runtime.provider()), so a workspace already using GLM/Kimi gets it
for free.

State machine's remaining transitions (processing → reviewing on last
run_completed; reviewing → publishing via approvals gate) land with the
orchestrator hookup + approvals extension. Publish approval and loops
are separate commits still to come.

Adds cm-llm as a direct cm-api dep (previously only pulled transitively
via cm-runtime) so the refine endpoint can build a ChatRequest. Uses
sqlx::query! for compile-time verification; .sqlx cache generated on
morpheus against a fresh migrated DB.
This commit is contained in:
Omar Sobh
2026-07-06 04:27:17 -07:00
parent 258113e4d8
commit fb047879c2
14 changed files with 817 additions and 0 deletions
+333
View File
@@ -0,0 +1,333 @@
//! Research topic endpoints — the container CRUD + the wizard's one-shot LLM
//! refine call. The state machine's advancing transitions (processing →
//! reviewing, reviewing → publishing) land in later commits alongside the
//! orchestrator + approval-gate wiring. What ships here:
//!
//! GET /api/research list workspace's topics
//! POST /api/research create (accepts wizard output)
//! GET /api/research/:id detail (topic + attached agents)
//! PATCH /api/research/:id update non-status fields
//! POST /api/research/:id/agents attach agent (idempotent)
//! DELETE /api/research/:id/agents/:agent detach
//! POST /api/research/:id/start standby → processing
//! POST /api/research/wizard/refine one-shot LLM refine helper
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
const VALID_OUTCOMES: &[&str] = &["spec", "prod_plan", "roadmap", "paper"];
fn check_outcome(kind: &str) -> Result<(), ApiError> {
if VALID_OUTCOMES.contains(&kind) {
Ok(())
} else {
Err(ApiError::BadRequest)
}
}
#[derive(Deserialize)]
pub struct CreateTopicRequest {
pub title: String,
pub description: String,
pub outcome_kind: String,
#[serde(default)]
pub agents: Vec<AgentSlotInput>,
}
#[derive(Deserialize)]
pub struct AgentSlotInput {
pub agent_id: Uuid,
#[serde(default)]
pub role_slot: Option<String>,
}
#[derive(Serialize)]
pub struct TopicCreated {
pub id: Uuid,
}
/// `POST /api/research` — create a topic in `standby` and attach any agents
/// the wizard captured. Attachments are idempotent so a client retry after
/// a partial failure is safe.
pub async fn create_topic(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<CreateTopicRequest>,
) -> Result<(StatusCode, Json<TopicCreated>), ApiError> {
if body.title.trim().is_empty() || body.description.trim().is_empty() {
return Err(ApiError::BadRequest);
}
check_outcome(&body.outcome_kind)?;
// Ownership check before any writes: every agent must be in the caller's
// workspace. Refuses to leak "agent exists" if it isn't visible.
for slot in &body.agents {
let agent = cm_db::repo::agents::get(&state.pool, slot.agent_id.into()).await?;
if agent.workspace_id.as_uuid() != user.workspace_id.as_uuid() {
return Err(ApiError::NotFound);
}
}
let id = cm_db::repo::research_topics::create(
&state.pool,
user.workspace_id.as_uuid(),
body.title.trim(),
body.description.trim(),
&body.outcome_kind,
user.user_id.as_uuid(),
)
.await?;
for slot in body.agents {
cm_db::repo::research_topics::attach_agent(
&state.pool,
id,
slot.agent_id,
slot.role_slot.as_deref(),
)
.await?;
}
Ok((StatusCode::CREATED, Json(TopicCreated { id })))
}
#[derive(Serialize)]
pub struct TopicListItem {
pub id: Uuid,
pub title: String,
pub outcome_kind: String,
pub status: String,
pub updated_at: String,
}
pub async fn list_topics(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<TopicListItem>>, ApiError> {
let rows =
cm_db::repo::research_topics::list(&state.pool, user.workspace_id.as_uuid()).await?;
Ok(Json(
rows.into_iter()
.map(|t| TopicListItem {
id: t.id,
title: t.title,
outcome_kind: t.outcome_kind,
status: t.status,
updated_at: t
.updated_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
})
.collect(),
))
}
#[derive(Serialize)]
pub struct TopicDetail {
#[serde(flatten)]
pub topic: cm_db::repo::research_topics::ResearchTopic,
pub agents: Vec<cm_db::repo::research_topics::AgentSlot>,
}
pub async fn get_topic(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<TopicDetail>, ApiError> {
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
let agents = cm_db::repo::research_topics::agents(&state.pool, id).await?;
Ok(Json(TopicDetail { topic, agents }))
}
#[derive(Deserialize)]
pub struct UpdateTopicRequest {
pub title: String,
pub description: String,
pub outcome_kind: String,
}
pub async fn patch_topic(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<UpdateTopicRequest>,
) -> Result<StatusCode, ApiError> {
if body.title.trim().is_empty() || body.description.trim().is_empty() {
return Err(ApiError::BadRequest);
}
check_outcome(&body.outcome_kind)?;
cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
cm_db::repo::research_topics::update_fields(
&state.pool,
id,
user.workspace_id.as_uuid(),
body.title.trim(),
body.description.trim(),
&body.outcome_kind,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn attach_agent(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<AgentSlotInput>,
) -> Result<StatusCode, ApiError> {
cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
let agent = cm_db::repo::agents::get(&state.pool, body.agent_id.into()).await?;
if agent.workspace_id.as_uuid() != user.workspace_id.as_uuid() {
return Err(ApiError::NotFound);
}
cm_db::repo::research_topics::attach_agent(
&state.pool,
id,
body.agent_id,
body.role_slot.as_deref(),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn detach_agent(
State(state): State<AppState>,
Authed(user): Authed,
Path((id, agent_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, ApiError> {
cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
cm_db::repo::research_topics::detach_agent(&state.pool, id, agent_id).await?;
Ok(StatusCode::NO_CONTENT)
}
/// `POST /api/research/:id/start` — flips status from `standby` to
/// `processing`. Only valid transition on this endpoint; the reviewing +
/// publishing hops come from the orchestrator and the publish approval gate.
pub async fn start_topic(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if topic.status != "standby" {
return Err(ApiError::Conflict);
}
cm_db::repo::research_topics::set_status(
&state.pool,
id,
user.workspace_id.as_uuid(),
"processing",
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── wizard refine ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct RefineRequest {
/// The user's raw topic prompt from Step 1 of the wizard.
pub prompt: String,
/// Optional prior refined draft, for iterative refinement rounds.
#[serde(default)]
pub prior_description: Option<String>,
pub outcome_kind: String,
}
#[derive(Serialize)]
pub struct RefineResponse {
/// The refined description (structured markdown) the wizard shows in
/// Step 2 for the user to accept/edit before Create.
pub description: String,
pub suggested_title: String,
}
const REFINE_SYSTEM: &str = "You are a research-scoping assistant. Given a raw topic prompt and \
a target outcome kind, produce a tightly scoped research description as markdown with these \
sections in this exact order: `## Framing` (2-3 sentences), `## Key questions` (3-5 bullets), \
`## Success criteria` (3-4 bullets tied to the outcome kind). Do NOT include any preamble, \
disclaimer, or commentary outside the markdown. Also produce a short punchy title (<=8 words). \
Return a single JSON object: {\"title\": \"\", \"description\": \"\"} with no code fences and \
no additional keys.";
/// `POST /api/research/wizard/refine` — one-shot LLM refine. Accumulates the
/// streaming provider into a single string and parses it. Uses the same
/// default model as agent runs (workspace's `clawmates.toml` provider).
pub async fn refine_wizard(
State(state): State<AppState>,
Authed(_user): Authed,
Json(body): Json<RefineRequest>,
) -> Result<Json<RefineResponse>, ApiError> {
if body.prompt.trim().is_empty() {
return Err(ApiError::BadRequest);
}
check_outcome(&body.outcome_kind)?;
let user_message = match body.prior_description.as_deref() {
Some(prior) if !prior.trim().is_empty() => format!(
"Outcome kind: {}\n\nCurrent topic prompt:\n{}\n\nPrior refined draft:\n{}\n\nRefine \
further, keeping the same section structure.",
body.outcome_kind, body.prompt, prior,
),
_ => format!(
"Outcome kind: {}\n\nTopic prompt:\n{}",
body.outcome_kind, body.prompt,
),
};
let request = ChatRequest {
system: REFINE_SYSTEM.into(),
messages: vec![ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::Text {
text: user_message,
}],
}],
tools: Vec::new(),
model: state.runtime.model().to_string(),
max_tokens: 2048,
web_search: false,
};
let provider = state.runtime.provider();
let mut stream = provider.stream(request).await.map_err(|_| ApiError::Internal)?;
let mut buf = String::new();
while let Some(event) = stream.next().await {
match event.map_err(|_| ApiError::Internal)? {
LlmEvent::TextDelta(delta) => buf.push_str(&delta),
LlmEvent::Stop(_) => break,
_ => {}
}
}
#[derive(Deserialize)]
struct Parsed {
title: String,
description: String,
}
let parsed: Parsed = serde_json::from_str(buf.trim()).map_err(|_| ApiError::Internal)?;
if parsed.title.trim().is_empty() || parsed.description.trim().is_empty() {
return Err(ApiError::Internal);
}
Ok(Json(RefineResponse {
description: parsed.description,
suggested_title: parsed.title,
}))
}