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:
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO research_topics\n (id, workspace_id, title, description, outcome_kind, status, created_by)\n VALUES ($1, $2, $3, $4, $5, 'standby', $6)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "040eedfd8cf97e0dbb5719fd150f961b938f3792d764d3e4f724c6da48294acd"
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at\n FROM research_topics\n WHERE workspace_id = $1\n ORDER BY updated_at DESC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "outcome_kind",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "080aa9dc0fe2e51c239e721dab1c33cd6ad31e679484e277a954c0781fdaa342"
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE research_topics\n SET title = $3, description = $4, outcome_kind = $5, updated_at = now()\n WHERE id = $1 AND workspace_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "2541fc9bef45124c48cdaa60c070e9ee7ade0d8c7859bbdd81068fb8e28e4a1c"
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE research_topics\n SET status = $3,\n updated_at = now(),\n published_at = CASE\n WHEN $3 = 'publishing' AND published_at IS NULL THEN now()\n ELSE published_at\n END\n WHERE id = $1 AND workspace_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "28aff26774a5f6a91adfc56252062b5a1cadb42d498276fc7018a0d0f0fe98d5"
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO research_topic_agents (topic_id, agent_id, role_slot)\n VALUES ($1, $2, $3)\n ON CONFLICT (topic_id, agent_id) DO UPDATE\n SET role_slot = EXCLUDED.role_slot",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "494d34241ef7c27c739ca5cc11114ada1a86bdcc30fe04ac5e7e58416d317679"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM research_topic_agents WHERE topic_id = $1 AND agent_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "5f8550702d365534f8778f1109b8a0d504e3b724e4d38d247de6ac2cbdd916e3"
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT agent_id, role_slot FROM research_topic_agents WHERE topic_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "agent_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "role_slot",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "c03f113360a03744f2e448979385c1348f6c49f88263e4583be71c527fba0ccc"
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at\n FROM research_topics\n WHERE id = $1 AND workspace_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "outcome_kind",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "eecbf1bc6a6ed59635ea26dffbba8ed014e58a6e0da19576b01912936317ed1a"
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ cm-brain = { path = "../cm-brain" }
|
|||||||
cm-config = { path = "../cm-config" }
|
cm-config = { path = "../cm-config" }
|
||||||
cm-db = { path = "../cm-db" }
|
cm-db = { path = "../cm-db" }
|
||||||
cm-domain = { path = "../cm-domain" }
|
cm-domain = { path = "../cm-domain" }
|
||||||
|
cm-llm = { path = "../cm-llm" }
|
||||||
cm-orchestrator = { path = "../cm-orchestrator", features = ["provider"] }
|
cm-orchestrator = { path = "../cm-orchestrator", features = ["provider"] }
|
||||||
cm-runtime = { path = "../cm-runtime" }
|
cm-runtime = { path = "../cm-runtime" }
|
||||||
cm-sandbox = { path = "../cm-sandbox" }
|
cm-sandbox = { path = "../cm-sandbox" }
|
||||||
|
|||||||
@@ -379,6 +379,30 @@ pub fn router(state: AppState) -> Router {
|
|||||||
get(routes::orgs::get_org).delete(routes::orgs::delete_org),
|
get(routes::orgs::get_org).delete(routes::orgs::delete_org),
|
||||||
)
|
)
|
||||||
.route("/api/orgs/{id}/run", post(routes::orgs::run_org))
|
.route("/api/orgs/{id}/run", post(routes::orgs::run_org))
|
||||||
|
.route(
|
||||||
|
"/api/research",
|
||||||
|
get(routes::research::list_topics).post(routes::research::create_topic),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/research/{id}",
|
||||||
|
get(routes::research::get_topic).patch(routes::research::patch_topic),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/research/{id}/agents",
|
||||||
|
post(routes::research::attach_agent),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/research/{id}/agents/{agent_id}",
|
||||||
|
axum::routing::delete(routes::research::detach_agent),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/research/{id}/start",
|
||||||
|
post(routes::research::start_topic),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/research/wizard/refine",
|
||||||
|
post(routes::research::refine_wizard),
|
||||||
|
)
|
||||||
.route("/api/structure/stats", get(routes::structure::stats))
|
.route("/api/structure/stats", get(routes::structure::stats))
|
||||||
.route("/api/structure/{level}/{id}", get(routes::structure::node))
|
.route("/api/structure/{level}/{id}", get(routes::structure::node))
|
||||||
.route("/api/topology-runs", get(routes::topology::list_runs))
|
.route("/api/topology-runs", get(routes::topology::list_runs))
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod nodes;
|
|||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod orgs;
|
pub mod orgs;
|
||||||
pub mod planner;
|
pub mod planner;
|
||||||
|
pub mod research;
|
||||||
pub mod routines;
|
pub mod routines;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ pub mod node_tools;
|
|||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
pub mod orgs;
|
pub mod orgs;
|
||||||
pub mod outbox;
|
pub mod outbox;
|
||||||
|
pub mod research_topics;
|
||||||
pub mod routine_runs;
|
pub mod routine_runs;
|
||||||
pub mod routines;
|
pub mod routines;
|
||||||
pub mod run_events;
|
pub mod run_events;
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! Research topics — user-scoped inquiry containers that group agents around
|
||||||
|
//! a shared question and drive them toward a named outcome (spec, prod_plan,
|
||||||
|
//! roadmap, paper). The status column is a small state machine; see the
|
||||||
|
//! 0030 migration header for the transitions. Runs are owned via
|
||||||
|
//! `topology_runs.research_topic_id`, so all durable execution state lives
|
||||||
|
//! there — this repo only manages the container + status + agent binding.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::DbError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ResearchTopic {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub workspace_id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub outcome_kind: String,
|
||||||
|
pub status: String,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
pub updated_at: OffsetDateTime,
|
||||||
|
pub published_at: Option<OffsetDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AgentSlot {
|
||||||
|
pub agent_id: Uuid,
|
||||||
|
pub role_slot: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a topic in `standby`. Returns the new row's id.
|
||||||
|
pub async fn create(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
title: &str,
|
||||||
|
description: &str,
|
||||||
|
outcome_kind: &str,
|
||||||
|
created_by: Uuid,
|
||||||
|
) -> Result<Uuid, DbError> {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO research_topics
|
||||||
|
(id, workspace_id, title, description, outcome_kind, status, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'standby', $6)",
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
outcome_kind,
|
||||||
|
created_by,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Workspace's topics, newest-updated first.
|
||||||
|
pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<ResearchTopic>, DbError> {
|
||||||
|
let rows = sqlx::query_as!(
|
||||||
|
ResearchTopic,
|
||||||
|
"SELECT id, workspace_id, title, description, outcome_kind, status,
|
||||||
|
created_by, created_at, updated_at, published_at
|
||||||
|
FROM research_topics
|
||||||
|
WHERE workspace_id = $1
|
||||||
|
ORDER BY updated_at DESC",
|
||||||
|
workspace_id,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
) -> Result<Option<ResearchTopic>, DbError> {
|
||||||
|
let row = sqlx::query_as!(
|
||||||
|
ResearchTopic,
|
||||||
|
"SELECT id, workspace_id, title, description, outcome_kind, status,
|
||||||
|
created_by, created_at, updated_at, published_at
|
||||||
|
FROM research_topics
|
||||||
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-status fields; the state machine transitions are their own endpoints.
|
||||||
|
pub async fn update_fields(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
title: &str,
|
||||||
|
description: &str,
|
||||||
|
outcome_kind: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE research_topics
|
||||||
|
SET title = $3, description = $4, outcome_kind = $5, updated_at = now()
|
||||||
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
outcome_kind,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State-machine transition. Caller enforces which transitions are valid;
|
||||||
|
/// this is the single write path so we can bump `updated_at` (and
|
||||||
|
/// `published_at` on landing in `publishing`).
|
||||||
|
pub async fn set_status(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
status: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE research_topics
|
||||||
|
SET status = $3,
|
||||||
|
updated_at = now(),
|
||||||
|
published_at = CASE
|
||||||
|
WHEN $3 = 'publishing' AND published_at IS NULL THEN now()
|
||||||
|
ELSE published_at
|
||||||
|
END
|
||||||
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn attach_agent(
|
||||||
|
pool: &PgPool,
|
||||||
|
topic_id: Uuid,
|
||||||
|
agent_id: Uuid,
|
||||||
|
role_slot: Option<&str>,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO research_topic_agents (topic_id, agent_id, role_slot)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (topic_id, agent_id) DO UPDATE
|
||||||
|
SET role_slot = EXCLUDED.role_slot",
|
||||||
|
topic_id,
|
||||||
|
agent_id,
|
||||||
|
role_slot,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn detach_agent(pool: &PgPool, topic_id: Uuid, agent_id: Uuid) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM research_topic_agents WHERE topic_id = $1 AND agent_id = $2",
|
||||||
|
topic_id,
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn agents(pool: &PgPool, topic_id: Uuid) -> Result<Vec<AgentSlot>, DbError> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
"SELECT agent_id, role_slot FROM research_topic_agents WHERE topic_id = $1",
|
||||||
|
topic_id,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| AgentSlot {
|
||||||
|
agent_id: r.agent_id,
|
||||||
|
role_slot: r.role_slot,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user