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:
@@ -15,6 +15,7 @@ pub mod node_tools;
|
||||
pub mod nodes;
|
||||
pub mod orgs;
|
||||
pub mod outbox;
|
||||
pub mod research_topics;
|
||||
pub mod routine_runs;
|
||||
pub mod routines;
|
||||
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