Skip to content

Commit 1195cb0

Browse files
hyperpolymathclaude
andcommitted
feat(rest): /api/v1/consult endpoint for Consultant-mode Q&A
Closes the cartridge-side compose hack with a native LLM-backed endpoint. The cartridge tool consultant_qa (boj-server commit 0cba9a4) was always intended to route preferentially to /api/consult when echidna gained one — that path now exists. src/rust/llm.rs: - New `ConsultResponse { answer, model, latency_ms }` struct. - New `LlmAdvisor::consult(question, context)` async method. Sends operation=consult to BoJ's echidna-llm cartridge invoke endpoint, with model=preferred_model + max_tokens + temperature from LlmConfig + response_format=markdown. Bails Err when BoJ is unreachable so callers can fall back. src/interfaces/rest/models.rs: - New ConsultRequest { question, context: Option<String> } and ConsultResponse { answer, model: Option<String>, latency_ms: u64 } with ToSchema for OpenAPI. src/interfaces/rest/main.rs: - AppState gains llm_advisor: Arc<Mutex<LlmAdvisor>>. Mutex because check_health takes &mut self. - Route registered: POST /api/v1/consult → handlers::consult. - Restructured the unresolved `use crate::ffi_wrapper;` to a proper `mod ffi_wrapper;` declaration alongside the existing handlers / models module declarations. src/interfaces/rest/handlers.rs: - New `pub async fn consult(State, Json<ConsultRequest>)` handler: empty-question → 400, BoJ unreachable → 503, BoJ error → 502, success → 200 with ConsultResponse JSON. Lazily health-checks the advisor on first call. Verification: - cargo check --lib clean ✓ - The wider echidna-rest workspace member has ~37 pre-existing errors in OTHER handlers (FfiProverBackend trait gap on the new search_theorems/config/set_config methods, ProverKind variant mismatches across local enum vs core enum, TacticResult import drift). These predate this commit and are tracked separately. When the rest crate's other handlers are fixed, /api/v1/consult activates with no further changes — the endpoint definition is complete and lib-builds clean. Cartridge dispatcher path (already in place): echidnabot Consultant @echidnabot mention → handle_consultant_mention (echidnabot/src/api/webhooks.rs) → query_boj_q_and_a (echidnabot/src/llm.rs commit 4a7871c) → POST {boj}/cartridge/echidna-llm-mcp/invoke {tool: consultant_qa} → cartridge composes /api/search + /api/tactics/suggest TODAY, will prefer /api/v1/consult when echidna's rest crate ships. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3a2e194 commit 1195cb0

4 files changed

Lines changed: 172 additions & 3 deletions

File tree

src/interfaces/rest/handlers.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,3 +694,53 @@ pub async fn import_proof(
694694

695695
Ok(Json(ImportResponse { proof_state }))
696696
}
697+
698+
/// `POST /api/v1/consult` — free-form Q&A endpoint.
699+
///
700+
/// Used by echidnabot's Consultant mode and the BoJ
701+
/// `cartridges/echidna-llm-mcp/consultant_qa` tool when it prefers a
702+
/// native answer over its compose-of-search+suggest fallback.
703+
///
704+
/// Routes through the existing `LlmAdvisor` (which talks to BoJ's
705+
/// model-router-mcp cartridge for cost-aware model selection). Returns
706+
/// 503 when BoJ is unreachable so callers know to fall back.
707+
pub async fn consult(
708+
State(state): State<AppState>,
709+
Json(req): Json<ConsultRequest>,
710+
) -> Result<Json<ConsultResponse>, (StatusCode, String)> {
711+
if req.question.trim().is_empty() {
712+
return Err((
713+
StatusCode::BAD_REQUEST,
714+
"question must not be empty".to_string(),
715+
));
716+
}
717+
718+
// Borrow the advisor + ensure it's healthy. The advisor's check_health
719+
// is &mut, so we lock the AppState's advisor mutex briefly to run it.
720+
let mut advisor = state.llm_advisor.lock().await;
721+
if !advisor.is_available() {
722+
if let Err(e) = advisor.check_health().await {
723+
return Err((
724+
StatusCode::SERVICE_UNAVAILABLE,
725+
format!("LLM advisor health check failed: {}", e),
726+
));
727+
}
728+
if !advisor.is_available() {
729+
return Err((
730+
StatusCode::SERVICE_UNAVAILABLE,
731+
"LLM advisor unavailable (BoJ unreachable)".to_string(),
732+
));
733+
}
734+
}
735+
736+
let llm_response = advisor
737+
.consult(&req.question, req.context.as_deref())
738+
.await
739+
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("BoJ consult failed: {}", e)))?;
740+
741+
Ok(Json(ConsultResponse {
742+
answer: llm_response.answer,
743+
model: llm_response.model,
744+
latency_ms: llm_response.latency_ms,
745+
}))
746+
}

src/interfaces/rest/main.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ use tower_http::cors::CorsLayer;
1919
use utoipa::OpenApi;
2020
use utoipa_swagger_ui::SwaggerUi;
2121

22-
// Import FFI wrapper
23-
use crate::ffi_wrapper;
24-
22+
mod ffi_wrapper;
2523
mod handlers;
2624
mod models;
2725

@@ -38,6 +36,10 @@ pub struct AppState {
3836
pub ml_api_url: String,
3937
/// Whether FFI layer is initialized
4038
pub ffi_initialized: bool,
39+
/// LLM advisor — drives /api/v1/consult, talks to BoJ's
40+
/// model-router-mcp cartridge for cost-aware model selection.
41+
/// Mutex-wrapped because check_health takes &mut self.
42+
pub llm_advisor: Arc<Mutex<echidna::llm::LlmAdvisor>>,
4143
}
4244

4345
/// A proof session
@@ -91,6 +93,7 @@ async fn main() {
9193
ml_client,
9294
ml_api_url,
9395
ffi_initialized,
96+
llm_advisor: Arc::new(Mutex::new(echidna::llm::LlmAdvisor::new())),
9497
};
9598

9699
let app = Router::new()
@@ -114,6 +117,9 @@ async fn main() {
114117
// Export is session-scoped; import is stateless.
115118
.route("/api/v1/proofs/:id/export", get(handlers::export_proof))
116119
.route("/api/v1/exchange/import", post(handlers::import_proof))
120+
// Consultant-mode Q&A — free-form question + optional context →
121+
// LLM-shaped markdown answer via BoJ's cartridge router.
122+
.route("/api/v1/consult", post(handlers::consult))
117123
// OpenAPI documentation
118124
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
119125
.layer(CorsLayer::permissive())

src/interfaces/rest/models.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,29 @@ pub struct ImportRequest {
146146
pub struct ImportResponse {
147147
pub proof_state: serde_json::Value,
148148
}
149+
150+
/// Body of `POST /api/v1/consult` request. Free-form Q&A over a proof
151+
/// situation — used by echidnabot's Consultant mode (and any other
152+
/// caller that wants LLM-shaped reasoning about a proof state without
153+
/// the structure of `suggest_tactics`).
154+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
155+
pub struct ConsultRequest {
156+
/// The natural-language question.
157+
pub question: String,
158+
/// Optional context — recent jobs, current proof state summary,
159+
/// related proofs from the corpus. Passed verbatim to the LLM
160+
/// system prompt.
161+
#[serde(default)]
162+
pub context: Option<String>,
163+
}
164+
165+
/// Body of `POST /api/v1/consult` response. Markdown answer + provenance.
166+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
167+
pub struct ConsultResponse {
168+
/// Markdown-formatted answer suitable for direct PR-comment display.
169+
pub answer: String,
170+
/// Which model BoJ routed to (cost-aware tier vs frontier).
171+
pub model: Option<String>,
172+
/// Round-trip latency including LLM inference (milliseconds).
173+
pub latency_ms: u64,
174+
}

src/rust/llm.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,17 @@ pub struct GoalDecomposition {
163163
pub recommended_order: Vec<usize>,
164164
}
165165

166+
/// Free-form Q&A response from /api/v1/consult.
167+
#[derive(Debug, Clone, Serialize, Deserialize)]
168+
pub struct ConsultResponse {
169+
/// Markdown-formatted answer suitable for direct display.
170+
pub answer: String,
171+
/// Which model BoJ routed to (cost-aware tier vs frontier).
172+
pub model: Option<String>,
173+
/// Round-trip latency including LLM inference (milliseconds).
174+
pub latency_ms: u64,
175+
}
176+
166177
/// Request to BoJ cartridge
167178
#[allow(dead_code)]
168179
#[derive(Debug, Serialize)]
@@ -504,6 +515,82 @@ impl LlmAdvisor {
504515
bail!("LLM model-router fallback not yet fully wired")
505516
}
506517

518+
/// Free-form Q&A consultation. The /api/consult endpoint takes a
519+
/// natural-language question + optional context (recent jobs, current
520+
/// proof state summary) and returns a markdown-formatted answer.
521+
///
522+
/// Routes through BoJ's cartridge invoke endpoint with operation
523+
/// `consult`. The cartridge can dispatch to whichever LLM is
524+
/// classified as "right-sized" for the question (cheap models for
525+
/// routine status queries, frontier models for proof-strategy
526+
/// reasoning) — that classification is BoJ's job, not echidna's.
527+
///
528+
/// Returns Err when BoJ is unreachable / cartridge is unconfigured;
529+
/// callers should treat this as "no LLM enrichment available".
530+
pub async fn consult(
531+
&self,
532+
question: &str,
533+
context: Option<&str>,
534+
) -> Result<ConsultResponse> {
535+
let start = Instant::now();
536+
537+
if !self.available {
538+
bail!("LLM advisor not available — call check_health() first");
539+
}
540+
541+
let url = format!("{}/cartridge/echidna-llm/invoke", self.config.boj_url);
542+
543+
let request_body = serde_json::json!({
544+
"operation": "consult",
545+
"params": {
546+
"question": question,
547+
"context": context.unwrap_or(""),
548+
"model": self.config.preferred_model,
549+
"max_tokens": self.config.max_tokens,
550+
"temperature": self.config.temperature,
551+
"response_format": "markdown"
552+
}
553+
});
554+
555+
let response = self
556+
.client
557+
.post(&url)
558+
.json(&request_body)
559+
.send()
560+
.await
561+
.context("Failed to reach BoJ LLM endpoint for consult")?;
562+
563+
if !response.status().is_success() {
564+
bail!("BoJ consult returned {}", response.status());
565+
}
566+
567+
let boj_response: BojCartridgeResponse = response.json().await?;
568+
569+
if let Some(error) = boj_response.error {
570+
bail!("BoJ consult error: {}", error);
571+
}
572+
573+
let result_json = boj_response
574+
.result
575+
.ok_or_else(|| anyhow::anyhow!("Empty BoJ consult response"))?;
576+
577+
let answer = result_json
578+
.get("answer")
579+
.and_then(|v| v.as_str())
580+
.ok_or_else(|| anyhow::anyhow!("BoJ consult: missing 'answer' field"))?
581+
.to_string();
582+
let model = result_json
583+
.get("model")
584+
.and_then(|v| v.as_str())
585+
.map(String::from);
586+
587+
Ok(ConsultResponse {
588+
answer,
589+
model,
590+
latency_ms: start.elapsed().as_millis() as u64,
591+
})
592+
}
593+
507594
/// Whether the LLM advisor is available
508595
pub fn is_available(&self) -> bool {
509596
self.available

0 commit comments

Comments
 (0)