use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use super::decompose::{decompose, store_subtasks, Decomposition, Retriever};
use super::store::Store;
use super::team::{Role, Team};
use super::{card_from_history, history, Event, IdeaId, ItemKind, NodeId, PlanId};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FunnelMode {
Manual,
Auto,
}
impl FunnelMode {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
FunnelMode::Manual => "manual",
FunnelMode::Auto => "auto",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"manual" => Some(FunnelMode::Manual),
"auto" => Some(FunnelMode::Auto),
_ => None,
}
}
#[must_use]
pub fn child_lane(&self) -> &'static str {
match self {
FunnelMode::Auto => "planned",
FunnelMode::Manual => "proposed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssignSpec {
None,
Auto,
Member(String),
}
impl AssignSpec {
#[must_use]
pub fn parse(s: &str) -> Self {
match s.trim() {
"" | "none" => AssignSpec::None,
"auto" => AssignSpec::Auto,
other => AssignSpec::Member(other.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptResult {
pub idea_id: String,
pub mode: String,
pub plan_id: String,
pub node_ids: Vec<String>,
pub decomposition: Decomposition,
}
#[allow(clippy::too_many_arguments)]
pub fn run_prompt(
store: &mut Store,
text: &str,
source: &str,
mode: FunnelMode,
retrievers: &[&dyn Retriever],
top_k: usize,
max_subtasks: usize,
assign: &AssignSpec,
team: &Team,
) -> Result<PromptResult> {
let text = text.trim();
anyhow::ensure!(!text.is_empty(), "empty prompt text");
let idea_id = IdeaId::seq(store.funnel.next_idea.max(1));
store.record(Event::IdeaSubmitted {
id: idea_id.clone(),
source: source.to_string(),
text: text.to_string(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: Utc::now(),
})?;
store.record(Event::IdeaModeSet {
idea_id: idea_id.clone(),
mode: mode.as_str().to_string(),
ts: Utc::now(),
})?;
let decomposition = decompose(text, retrievers, top_k, max_subtasks);
let (plan_id, node_ids) = store_subtasks(store, &idea_id, &decomposition)?;
let to_lane = mode.child_lane();
for node_id in &node_ids {
store.record(Event::CardStateChanged {
node: node_id.as_str().to_string(),
from_lane: None,
to_lane: to_lane.to_string(),
why: Some(format!("{} prompt decompose", mode.as_str())),
ts: Utc::now(),
})?;
}
assign_produced(store, &idea_id, &plan_id, &node_ids, assign, team)?;
Ok(PromptResult {
idea_id: idea_id.as_str().to_string(),
mode: mode.as_str().to_string(),
plan_id: plan_id.as_str().to_string(),
node_ids: node_ids.iter().map(|n| n.as_str().to_string()).collect(),
decomposition,
})
}
fn assign_produced(
store: &mut Store,
idea_id: &IdeaId,
plan_id: &PlanId,
node_ids: &[NodeId],
assign: &AssignSpec,
team: &Team,
) -> Result<()> {
match assign {
AssignSpec::None => Ok(()),
AssignSpec::Member(member) => {
store.record(Event::CardAssigned {
node: idea_id.as_str().to_string(),
member: member.clone(),
role: None,
why: Some("explicit --assign".into()),
ts: Utc::now(),
})?;
for node_id in node_ids {
store.record(Event::CardAssigned {
node: node_id.as_str().to_string(),
member: member.clone(),
role: None,
why: Some("explicit --assign".into()),
ts: Utc::now(),
})?;
}
Ok(())
}
AssignSpec::Auto => {
let mut load: std::collections::BTreeMap<String, u32> = std::collections::BTreeMap::new();
if let Some(member) = team.allocate(Role::for_parent(), &mut load) {
store.record(Event::CardAssigned {
node: idea_id.as_str().to_string(),
member,
role: Some(Role::for_parent().as_str().to_string()),
why: Some("auto-assign: decomposition parent -> Architect".into()),
ts: Utc::now(),
})?;
}
let kinds: std::collections::BTreeMap<NodeId, String> = store
.funnel
.plans
.get(plan_id)
.map(|p| p.nodes.iter().map(|(id, n)| (id.clone(), n.kind.clone())).collect())
.unwrap_or_default();
for node_id in node_ids {
let kind = kinds.get(node_id).cloned().unwrap_or_else(|| "code:write".to_string());
let role = Role::for_node_kind(&kind);
if let Some(member) = team.allocate(role, &mut load) {
store.record(Event::CardAssigned {
node: node_id.as_str().to_string(),
member,
role: Some(role.as_str().to_string()),
why: Some(format!("auto-assign: {kind} -> {}", role.as_str())),
ts: Utc::now(),
})?;
}
}
Ok(())
}
}
}
#[must_use]
pub fn prompt_result_to_json(result: &PromptResult, funnel: &super::Funnel) -> serde_json::Value {
let cards = super::cards_from_funnel(funnel);
let find = |id: &str| cards.iter().find(|c| c.id == id);
let card_json = |id: &str| {
find(id).map(|c| {
serde_json::json!({
"id": c.id,
"title": c.title,
"lane": c.lane.as_str(),
"assignee": c.assignee,
"card_type": c.card_type.as_str(),
})
})
};
serde_json::json!({
"mode": result.mode,
"idea_id": result.idea_id,
"plan_id": result.plan_id,
"mother": card_json(&result.idea_id),
"children": result.node_ids.iter().map(|id| card_json(id)).collect::<Vec<_>>(),
"decomposition": result.decomposition.to_json(),
})
}
#[must_use]
pub fn prompt_result_render(result: &PromptResult, funnel: &super::Funnel) -> String {
use std::fmt::Write as _;
let cards = super::cards_from_funnel(funnel);
let find = |id: &str| cards.iter().find(|c| c.id == id);
let mut out = String::new();
let _ = writeln!(out, "mother {} [mode={}]", result.idea_id, result.mode);
if let Some(c) = find(&result.idea_id) {
let _ = writeln!(
out,
" {} — lane={} assignee={}",
c.title,
c.lane.as_str(),
c.assignee.as_deref().unwrap_or("-")
);
}
let _ = writeln!(out, "plan {} — {} child task(s):", result.plan_id, result.node_ids.len());
for (id, sub) in result.node_ids.iter().zip(result.decomposition.subtasks.iter()) {
if let Some(c) = find(id) {
let _ = writeln!(
out,
" {} [{}] {} — lane={} assignee={} target={}",
id,
c.card_type.as_str(),
sub.title,
c.lane.as_str(),
c.assignee.as_deref().unwrap_or("-"),
if sub.target_file.is_empty() { "-" } else { &sub.target_file },
);
}
}
out
}
#[must_use]
pub fn current_lane(funnel: &super::Funnel, card_id: &str) -> Option<super::Lane> {
if let Some(node_str) = funnel.card_lanes.get(card_id) {
return super::Lane::parse(node_str);
}
history(funnel, None, None)
.into_iter()
.find(|it| it.id == card_id)
.map(|it| card_from_history(&it).lane)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::funnel::decompose::CodeIndexRetriever;
use crate::knowledge::query::KnowledgeView;
use crate::knowledge::symbols::SymbolRow;
fn view_with_symbols() -> KnowledgeView {
let symbols = vec![
SymbolRow {
crate_name: "r".into(),
module_path: "http".into(),
item_kind: "fn".into(),
item_name: "retry_fetch".into(),
visibility: "pub".into(),
file: "src/http.rs".into(),
line: 10,
doc_lines: 0,
signature: Some("fn retry_fetch()".into()),
},
SymbolRow {
crate_name: "r".into(),
module_path: "http".into(),
item_kind: "fn".into(),
item_name: "retry_fetch_test".into(),
visibility: "pub".into(),
file: "src/http.rs".into(),
line: 40,
doc_lines: 0,
signature: Some("fn retry_fetch_test()".into()),
},
];
KnowledgeView::new(symbols, vec![])
}
fn tmp_store() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path().join("wh")).unwrap();
(dir, store)
}
#[test]
fn prompt_creates_mother_and_anchored_children() {
let (_dir, mut store) = tmp_store();
let view = view_with_symbols();
let retriever = CodeIndexRetriever::new(&view);
let retrievers: Vec<&dyn Retriever> = vec![&retriever];
let team = Team::sample();
let result = run_prompt(
&mut store,
"retry the http fetch",
"human:rickard",
FunnelMode::Auto,
&retrievers,
5,
8,
&AssignSpec::None,
&team,
)
.unwrap();
assert_eq!(result.idea_id, "i-001");
assert!(!result.node_ids.is_empty(), "at least one anchored child node");
assert_eq!(result.node_ids.len(), result.decomposition.subtasks.len());
assert!(result.decomposition.subtasks.iter().any(|s| s.target_file == "src/http.rs"));
let idea = store.funnel.ideas.get(&IdeaId::new(&result.idea_id)).unwrap();
assert_eq!(idea.mode.as_deref(), Some("auto"));
let plan = store.funnel.plans.get(&PlanId::new(&result.plan_id)).unwrap();
assert_eq!(plan.idea_id, IdeaId::new(&result.idea_id), "the plan hangs under the mother");
assert_eq!(plan.nodes.len(), result.node_ids.len());
}
#[test]
fn auto_lands_planned_manual_lands_proposed() {
let view = view_with_symbols();
let retriever = CodeIndexRetriever::new(&view);
let retrievers: Vec<&dyn Retriever> = vec![&retriever];
let team = Team::sample();
let (_dir_a, mut store_a) = tmp_store();
let auto = run_prompt(&mut store_a, "retry the http fetch", "cli", FunnelMode::Auto, &retrievers, 5, 8, &AssignSpec::None, &team).unwrap();
let cards_a = super::super::cards_from_funnel(&store_a.funnel);
for id in &auto.node_ids {
let c = cards_a.iter().find(|c| &c.id == id).unwrap();
assert_eq!(c.lane, super::super::Lane::Planned, "auto -> Planned");
}
let (_dir_m, mut store_m) = tmp_store();
let manual = run_prompt(&mut store_m, "retry the http fetch", "cli", FunnelMode::Manual, &retrievers, 5, 8, &AssignSpec::None, &team).unwrap();
let cards_m = super::super::cards_from_funnel(&store_m.funnel);
for id in &manual.node_ids {
let c = cards_m.iter().find(|c| &c.id == id).unwrap();
assert_eq!(c.lane, super::super::Lane::Proposed, "manual -> Proposed");
}
}
#[test]
fn assign_auto_respects_role_and_is_deterministic() {
let view = view_with_symbols();
let retriever = CodeIndexRetriever::new(&view);
let retrievers: Vec<&dyn Retriever> = vec![&retriever];
let team = Team::sample();
let (_dir, mut store) = tmp_store();
let result = run_prompt(&mut store, "retry the http fetch", "cli", FunnelMode::Auto, &retrievers, 5, 8, &AssignSpec::Auto, &team).unwrap();
let mother_assignment = store.funnel.assignments.get(&result.idea_id).unwrap();
assert_eq!(mother_assignment.role.as_deref(), Some("architect"));
assert!(team.members.iter().any(|m| m.name == mother_assignment.member && m.can(Role::Architect)));
for node_id in &result.node_ids {
let a = store.funnel.assignments.get(node_id).unwrap();
assert_eq!(a.role.as_deref(), Some("coder"), "code:write nodes -> Coder");
}
}
}