use std::sync::Arc;
use axum::Json;
use axum::extract::State;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use serde_json::json;
use trusty_common::inference::{ChatMessage, ChatRequest};
use super::inference::{MANAGER_MAX_TOKENS, MANAGER_TEMPERATURE};
use crate::daemon::error::DaemonError;
use crate::daemon::state::DaemonState;
use crate::project::resolver::{ProjectMatch, ProjectResolution, ResolverError, resolve_project};
#[derive(Debug, Deserialize)]
pub struct RouteTaskRequest {
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ResolvedBy {
Resolver,
Disambiguation,
NoMatch,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RouteTaskResponse {
pub project: Option<String>,
pub confidence: f32,
pub rationale: String,
pub resolved_by: ResolvedBy,
}
pub fn build_disambiguation_messages(text: &str, candidates: &[ProjectMatch]) -> Vec<ChatMessage> {
let mut listing = String::new();
for (i, m) in candidates.iter().enumerate() {
listing.push_str(&format!(
"{}. {} (confidence {:.2}, matched by {})\n",
i + 1,
m.project.name,
m.confidence,
m.reason.label(),
));
}
let system = "You route a developer's free-text task to exactly ONE of a short list of \
candidate projects that a deterministic matcher already scored as plausible but \
could not decide between. Choose the single best-fitting project for the task. \
Reply with ONLY that project's exact name from the list — no punctuation, no \
explanation, no extra words. Never invent a project that is not listed.";
let user =
format!("Task: {text}\n\nCandidate projects:\n{listing}\nBest-fitting project name:");
vec![ChatMessage::system(system), ChatMessage::user(user)]
}
pub fn pick_from_reply<'a>(
reply: &str,
candidates: &'a [ProjectMatch],
) -> Option<&'a ProjectMatch> {
let reply_lower = reply.trim().to_lowercase();
if reply_lower.is_empty() {
return None;
}
if let Some(exact) = candidates
.iter()
.find(|m| m.project.name.to_lowercase() == reply_lower)
{
return Some(exact);
}
let mut by_len: Vec<&ProjectMatch> = candidates.iter().collect();
by_len.sort_by_key(|m| std::cmp::Reverse(m.project.name.len()));
by_len
.into_iter()
.find(|m| reply_lower.contains(&m.project.name.to_lowercase()))
}
fn resolver_response(resolution: &ProjectResolution, note: Option<&str>) -> RouteTaskResponse {
match &resolution.primary {
Some(primary) => {
let mut rationale = format!(
"resolved to '{}' by {} (confidence {:.2})",
primary.project.name,
primary.reason.label(),
primary.confidence,
);
if let Some(note) = note {
rationale.push_str("; ");
rationale.push_str(note);
}
RouteTaskResponse {
project: Some(primary.project.name.clone()),
confidence: primary.confidence,
rationale,
resolved_by: ResolvedBy::Resolver,
}
}
None => no_match_response("resolver returned no candidate"),
}
}
fn no_match_response(reason: &str) -> RouteTaskResponse {
RouteTaskResponse {
project: None,
confidence: 0.0,
rationale: reason.to_string(),
resolved_by: ResolvedBy::NoMatch,
}
}
pub async fn manager_route_task_route(
State(state): State<Arc<DaemonState>>,
Json(body): Json<RouteTaskRequest>,
) -> impl IntoResponse {
match route_task_op(&state, body).await {
Ok(reply) => Json(reply).into_response(),
Err(e) => render_route_task_error(e),
}
}
fn render_route_task_error(e: DaemonError) -> axum::response::Response {
let code = match &e {
DaemonError::InvalidRequest(_) => "invalid_request",
_ => "registry_read_failed",
};
(
e.status(),
Json(json!({ "error": code, "message": e.detail() })),
)
.into_response()
}
pub async fn route_task_op(
state: &Arc<DaemonState>,
body: RouteTaskRequest,
) -> Result<RouteTaskResponse, DaemonError> {
let text = body.text.trim().to_string();
if text.is_empty() {
return Err(DaemonError::InvalidRequest(
"text must not be empty".to_string(),
));
}
let registry = state.project_registry().await;
let projects = registry.list().await.map_err(|e| {
tracing::warn!(error = %e, "route-task: project registry read failed");
DaemonError::Internal("project registry read failed".to_string())
})?;
let resolution = match resolve_project(&text, &projects) {
Ok(resolution) => resolution,
Err(ResolverError::EmptyRegistry) => {
return Ok(no_match_response(
"no projects are registered; register one with `tm projects register`",
));
}
Err(ResolverError::NoMatch { .. }) => {
return Ok(no_match_response("no registered project matched the task"));
}
};
if !resolution.needs_disambiguation() {
return Ok(resolver_response(&resolution, None));
}
let (model, adapter) = match state.manager_state().inference().resolve() {
Ok(pair) => pair,
Err(_) => {
return Ok(resolver_response(
&resolution,
Some(
"multiple candidates tied and no inference provider was available to \
disambiguate, so the highest-confidence candidate was chosen",
),
));
}
};
let candidates = &resolution.matches;
let mut request = ChatRequest::new(
model.clone(),
build_disambiguation_messages(&text, candidates),
);
request.max_tokens = Some(MANAGER_MAX_TOKENS);
request.temperature = Some(MANAGER_TEMPERATURE);
let picked = match adapter.chat(&request).await {
Ok(response) => response
.first_text()
.and_then(|reply| pick_from_reply(&reply, candidates).cloned()),
Err(e) => {
tracing::warn!("route-task disambiguation inference call failed: {e}");
None
}
};
match picked {
Some(m) => Ok(RouteTaskResponse {
project: Some(m.project.name.clone()),
confidence: m.confidence,
rationale: format!(
"disambiguated to '{}' by inference among {} tied candidates (resolver \
confidence {:.2}, matched by {})",
m.project.name,
candidates.len(),
m.confidence,
m.reason.label(),
),
resolved_by: ResolvedBy::Disambiguation,
}),
None => Ok(resolver_response(
&resolution,
Some("disambiguation was inconclusive, so the highest-confidence candidate was chosen"),
)),
}
}
#[cfg(test)]
#[path = "route_task_tests.rs"]
mod tests;