use axum::extract::State;
use axum::Json;
use super::{bad as fail, Cur, Reply, Shared};
use crate::api::{self, PromptAction};
use crate::prompts::{self, Prompts, Task};
pub(super) async fn get(State(app): State<Shared>) -> Reply<api::Prompts> {
let run = async {
let root = super::team::root(&app).await?;
let stored = prompts::load(&root).await?;
let lib = stored.doc;
anyhow::Ok(api::Prompts {
tasks: Task::ALL
.into_iter()
.map(|t| api::PromptTask {
task: t,
builtin: t.builtin().into(),
format: crate::prompts::fixed(t),
active: lib.active.get(&t).copied(),
builtin_changed: lib.builtin_changed(t),
sections: match t {
Task::Guide => crate::styleguide::SECTIONS
.iter()
.map(|(title, g)| api::GuideSectionText {
title: title.to_string(),
guidance: g.to_string(),
})
.collect(),
_ => Vec::new(),
},
versions: lib.of(t).cloned().collect(),
})
.collect(),
version: stored.version,
changed_by: stored.changed_by,
changed_at: stored.changed_at,
})
};
run.await.map(Json).map_err(fail)
}
pub(super) async fn change(
who: super::Member,
State(app): State<Shared>,
Json(b): Json<api::PromptChange>,
) -> Reply<api::Done> {
let run = async {
let root = super::team::root(&app).await?;
let by = match who.as_deref() {
Some(w) => w.email.clone(),
None => {
let me = crate::config::local_identity();
me.name.unwrap_or(me.actor)
}
};
let mut lib = prompts::load(&root).await?.doc;
let msg_detail = match &b.action {
PromptAction::Save { task, .. } => format!("{} saved", task.name()),
PromptAction::Activate { task, n } => format!("{} v{n} activated", task.name()),
PromptAction::Deactivate { task } => format!("{} back to built-in", task.name()),
PromptAction::Delete { task, n } => format!("{} v{n} deleted", task.name()),
};
let msg = match b.action {
PromptAction::Save {
task,
text,
sections,
note,
} => {
let n = lib.add(task, &text, §ions, note.as_deref(), &by)?;
format!("saved as version {n}; not used until you activate it")
}
PromptAction::Activate { task, n } => {
lib.activate(task, n)?;
format!("version {n} is in use for what the next index writes")
}
PromptAction::Deactivate { task } => {
lib.deactivate(task);
"back to the built-in prompt for what the next index writes".to_string()
}
PromptAction::Delete { task, n } => {
lib.remove(task, n)?;
format!("version {n} deleted")
}
};
prompts::save_as(&root, &lib, b.version, &by).await?;
if let Some(server) = app.server.as_deref() {
server.audit.record(&by, "prompt", &msg_detail);
}
anyhow::Ok(msg)
};
run.await.map(|m| Json(api::Done::new(m))).map_err(fail)
}
pub(super) async fn try_draft(
State(app): State<Shared>,
Cur(s): Cur,
Json(b): Json<api::PromptTry>,
) -> Reply<api::PromptTried> {
let run = async {
anyhow::ensure!(!b.text.trim().is_empty(), "the prompt is empty");
let team = app.team().name;
let llm =
crate::config::llm_client(&app.settings(), team.as_deref(), app.server.as_deref())
.ok_or_else(|| anyhow::anyhow!("no LLM key; set one under Settings"))?;
let draft = Prompts::draft(b.task, &b.text, b.sections.clone());
let g = &s.data.graph;
let extras = s.extras().await;
let pick = |id: Option<&str>| id.filter(|i| !i.is_empty()).and_then(|i| g.lookup(i));
let shown = |(text, q): &(String, Vec<String>)| answer(text, q);
match b.task {
Task::Summary => {
let i = match pick(b.sample.as_deref()) {
Some(i) => i,
None => most_used(&s.data)
.and_then(|id| g.lookup(&id))
.ok_or_else(|| anyhow::anyhow!("nothing indexed to try it on"))?,
};
let node = g.node(i);
let sym = symbol(&s, &node.id)?;
let mut sources = sources_for(&s, sym).await;
let n = crate::brief::Neighbours::new(&s.data.symbols, &s.data.edges);
let e = llm
.enrich_symbol(&draft, &crate::brief::symbol(sym, &n, &mut sources))
.await?;
Ok(api::PromptTried {
sample: format!("{} ({})", node.name, node.repo),
output: answer(&e.summary, &e.queries),
current: extras.enrichment.get(&node.id).map(shown),
})
}
Task::Flow => {
let i = match pick(b.sample.as_deref()) {
Some(i) => i,
None => g
.entry_points(1)
.first()
.map(|(i, _)| *i)
.ok_or_else(|| anyhow::anyhow!("no flows indexed to try it on"))?,
};
let node = g.node(i);
let sig = node.signature.clone().unwrap_or_default();
let summaries = extras
.enrichment
.iter()
.map(|(id, (text, _))| (id.clone(), text.clone()))
.collect();
let sym = symbol(&s, &node.id)?;
let mut sources = sources_for(&s, sym).await;
let trace = crate::brief::trace(&g.flow(i, 6), &summaries);
let (text, q) = llm
.narrate_flow(
&draft,
&node.name,
&sig,
&crate::brief::flow(trace, sym, &mut sources),
)
.await?;
Ok(api::PromptTried {
sample: format!("{} ({})", node.name, node.repo),
output: answer(&text, &q),
current: extras.flows.get(&node.id).map(shown),
})
}
Task::Guide => {
let (repo, title, body) = guide_sample(&app, &s, &b, &draft, &llm).await?;
let uri = super::graph_uri(&s).ok_or_else(|| anyhow::anyhow!("no index"))?;
let current = crate::index::read_keys(&uri, "guide_sections")
.await
.ok()
.and_then(|m| m.get(&format!("{repo}\0{title}")).cloned());
Ok(api::PromptTried {
sample: format!("{title} ({repo})"),
output: body.unwrap_or_else(|| "No consistent convention observed.".into()),
current,
})
}
}
};
run.await.map(Json).map_err(fail)
}
fn symbol<'s>(s: &'s super::Session, id: &str) -> anyhow::Result<&'s crate::model::Symbol> {
s.data
.symbols
.iter()
.find(|x| x.id.render() == id)
.ok_or_else(|| anyhow::anyhow!("no such symbol in the index"))
}
async fn sources_for(s: &super::Session, sym: &crate::model::Symbol) -> crate::brief::Sources {
let mut sources = crate::brief::Sources::new(&[]);
if let Some(uri) = super::graph_uri(s) {
let text = crate::index::read_source(&uri, &sym.repo.0, &sym.file_path)
.await
.ok()
.flatten();
sources.insert(&sym.repo.0, &sym.file_path, text);
}
sources
}
fn answer(text: &str, queries: &[String]) -> String {
let mut out = text.trim().to_string();
if !queries.is_empty() {
out.push_str("\n\nQuestions it answers:");
for q in queries {
out.push_str(&format!("\n- {q}"));
}
}
out
}
fn most_used(data: &crate::view::Data) -> Option<String> {
use crate::model::SymbolKind as K;
data.symbols
.iter()
.filter(|s| {
matches!(
s.kind,
K::Function | K::Method | K::Class | K::Struct | K::Interface | K::Enum
)
})
.map(|s| s.id.render())
.max_by_key(|id| data.graph.dependents_count(id))
}
async fn guide_sample(
app: &Shared,
s: &super::Session,
b: &api::PromptTry,
draft: &Prompts,
llm: &crate::llm::LlmClient,
) -> anyhow::Result<(String, String, Option<String>)> {
let entries = app.repos();
let named = |e: &crate::config::RepoEntry| crate::worktree::name(std::path::Path::new(&e.path));
let wanted = b.sample.as_deref().map(str::trim).filter(|x| !x.is_empty());
let entry = match wanted {
Some(w) => entries.iter().find(|e| named(e) == w),
None => entries.first(),
}
.ok_or_else(|| anyhow::anyhow!("no repository {} in the list", wanted.unwrap_or("")))?;
let repo = named(entry);
let root = crate::worktree::checkout_dir(
&crate::config::cache_dir(),
std::path::Path::new(&entry.path),
)
.ok_or_else(|| anyhow::anyhow!("{repo} has no checkout here yet; index it once"))?;
let symbols: Vec<crate::model::Symbol> = s
.data
.symbols
.iter()
.filter(|x| x.id.package == repo)
.cloned()
.collect();
let edges: Vec<crate::model::Edge> = s
.data
.edges
.iter()
.filter(|e| e.src.package == repo)
.cloned()
.collect();
let conventions: Vec<_> = s
.data
.conventions
.iter()
.filter(|c| c.repo == repo)
.cloned()
.collect();
let title = b
.section
.clone()
.unwrap_or_else(|| crate::styleguide::SECTIONS[0].0.to_string());
let t = title.clone();
let mut sec = tokio::task::spawn_blocking(move || {
let refs: Vec<&crate::model::Symbol> = symbols.iter().collect();
crate::styleguide::build_sections(&root, &refs, &edges, &conventions)
.into_iter()
.find(|x| x.title == t)
})
.await?
.ok_or_else(|| anyhow::anyhow!("{repo} has no evidence for {title}"))?;
if let Some(g) = draft.guidance(&sec.title) {
sec.guidance = g.to_string();
}
let body = llm
.guide_section(draft, &repo, &sec.title, &sec.guidance, &sec.evidence)
.await?;
Ok((repo, title, body))
}