research: backend routes + repo + wizard refine (behind /api/research)
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:
@@ -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,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user