research pipeline v2: topology-aware start + persisted draft
Three connected changes that turn "Start research" from a status flip into a real pipeline that produces a reviewable artifact: - Migration 0036: adds research_topics.topology_kind (default 'hub_spoke') and a new research_outcomes table (id, topic_id, version DESC, body_md, produced_by_run_id, created_at) so each run's final synthesis is versioned and persistent. - Wizard now has a topology picker in the Outcome step — hub_spoke / pipeline / hierarchical / star_moe — with copy that steers users to the right shape (Pipeline for research → distill → analyze → implement rosters, hub_spoke for the coordinator- and-specialists default). - start_topic reads the chosen topology_kind, parses it into a cm_topology::TopologyKind, and dispatches a per-shape coordinator prompt via build_coordinator_task. Pipeline explicitly tells stage 1 not to write the final artifact and propagates a "final stage MUST emit a complete markdown document with measurable acceptance criteria" instruction downstream. The graph builder is called with the topology the user actually picked instead of hard-coded HubSpoke. - topology_worker::freeze_research_outcome fires after every successful complete(). It looks up research_topic_id on the run; if set and final_output is non-empty, it inserts a new research_outcomes row (version auto-derived server-side via coalesce(max(version), 0) + 1). Best-effort — a DB hiccup logs but doesn't fail the run. - TopicDetail now includes topology_kind and latest_outcome. ResearchCanvas swaps in the outcome's body_md (rendered as pre-wrap markdown, versioned header, produced-at timestamp) whenever an outcome exists; the original prompt collapses into an "Original prompt" <details> below so it's still one click away. Pre-run topics still show the description as before. Follow-ups still open: reject-with-revision loop feeding the coordinator, publishing → published transition + real artifact export (md / pdf), and an approvals inbox surface for reviewers.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
//! Persisted research artifacts — one row per run's final synthesis. When
|
||||
//! `topology_worker` completes a run tagged with a `research_topic_id`, it
|
||||
//! extracts the orchestrator's `RunRecord.final_output` and calls
|
||||
//! [`insert`] here. The frontend canvas then renders the latest outcome
|
||||
//! instead of the topic description when the topic has moved past
|
||||
//! `standby`, so reviewers see the actual draft.
|
||||
//!
|
||||
//! Version is per-topic and monotonically increasing so reject-with-
|
||||
//! revision loops accumulate history rather than clobber prior drafts.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Outcome {
|
||||
pub id: Uuid,
|
||||
pub topic_id: Uuid,
|
||||
pub version: i32,
|
||||
pub body_md: String,
|
||||
pub produced_by_run_id: Option<Uuid>,
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// Insert a new outcome. Version is derived server-side as `max(version) + 1`
|
||||
/// for the topic (starting at 1) so callers never need to know the current
|
||||
/// count. Returns the persisted row.
|
||||
pub async fn insert(
|
||||
pool: &PgPool,
|
||||
topic_id: Uuid,
|
||||
body_md: &str,
|
||||
produced_by_run_id: Option<Uuid>,
|
||||
) -> Result<Outcome, DbError> {
|
||||
let id = Uuid::now_v7();
|
||||
let row = sqlx::query!(
|
||||
"INSERT INTO research_outcomes (id, topic_id, version, body_md, produced_by_run_id)
|
||||
SELECT $1, $2, coalesce(max(version), 0) + 1, $3, $4
|
||||
FROM research_outcomes
|
||||
WHERE topic_id = $2
|
||||
RETURNING id, topic_id, version, body_md, produced_by_run_id, created_at",
|
||||
id,
|
||||
topic_id,
|
||||
body_md,
|
||||
produced_by_run_id,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(Outcome {
|
||||
id: row.id,
|
||||
topic_id: row.topic_id,
|
||||
version: row.version,
|
||||
body_md: row.body_md,
|
||||
produced_by_run_id: row.produced_by_run_id,
|
||||
created_at: row.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Newest outcome for a topic, or `None` if no run has completed yet.
|
||||
pub async fn latest(pool: &PgPool, topic_id: Uuid) -> Result<Option<Outcome>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, topic_id, version, body_md, produced_by_run_id, created_at
|
||||
FROM research_outcomes
|
||||
WHERE topic_id = $1
|
||||
ORDER BY version DESC
|
||||
LIMIT 1",
|
||||
topic_id,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| Outcome {
|
||||
id: r.id,
|
||||
topic_id: r.topic_id,
|
||||
version: r.version,
|
||||
body_md: r.body_md,
|
||||
produced_by_run_id: r.produced_by_run_id,
|
||||
created_at: r.created_at,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user