Feature chore bug 005 #19

Merged
iamdoubz merged 106 commits from feature_chore_bug_005 into main 2026-07-07 22:02:41 -05:00
Showing only changes of commit a7fda95843 - Show all commits
+95 -2
View File
@@ -46,6 +46,12 @@ pub trait LlmProvider: Send + Sync {
/// Non-streaming — the reply is short enough that a single round trip is
/// simpler than wiring up another token-stream event for it.
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError>;
/// One-shot, non-streaming completion (T10.6, ADR-0011): a caller-supplied
/// system prompt + user message, one full reply back — no output-format
/// contract of its own (unlike `summarize`'s three fixed sections), since
/// callers like `briefs::FeatureBriefBuilder` define their own. Mirrors
/// `suggest_tags`'s single-round-trip shape.
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError>;
/// True if the endpoint resolves to loopback/local (FR-LLM-6, FR-SEC-1).
fn is_local(&self) -> bool;
}
@@ -154,8 +160,10 @@ fn parse_summary(text: &str) -> Summary {
}
/// `- text` / `* text` -> `Some("text")`; skips empty bullets and the
/// placeholder "- None" the prompt asks for when a section is empty.
fn bullet_text(line: &str) -> Option<String> {
/// placeholder "- None" the prompt asks for when a section is empty. Shared
/// with `briefs::parse_brief` — its "Acceptance Criteria" section is the same
/// bullet-list shape as `summarize`'s Decisions/Action Items.
pub(crate) fn bullet_text(line: &str) -> Option<String> {
let trimmed = line.trim();
let stripped = trimmed
.strip_prefix("- ")
@@ -438,6 +446,34 @@ impl LlmProvider for OllamaProvider {
))
}
/// One-shot completion (T10.6) — same non-streaming `/api/chat` shape as
/// `suggest_tags`, just with a caller-supplied system/user pair instead of
/// the fixed tag-list prompt.
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError> {
let body = serde_json::json!({
"model": self.model,
"messages": [
{ "role": "system", "content": system },
{ "role": "user", "content": user },
],
"stream": false,
});
let resp = reqwest::Client::new()
.post(format!("{}/api/chat", self.base()))
.json(&body)
.send()
.await
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
if !resp.status().is_success() {
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
}
let chunk: OllamaChatChunk = resp
.json()
.await
.map_err(|e| LlmError::Request(e.to_string()))?;
Ok(chunk.message.map(|m| m.content).unwrap_or_default())
}
fn is_local(&self) -> bool {
is_local_endpoint(&self.endpoint)
}
@@ -627,6 +663,55 @@ impl LlmProvider for OpenAiCompatProvider {
Ok(parse_tags(&full_text))
}
/// One-shot completion (T10.6) — reuses `suggest_tags`'s SSE-accumulate
/// path (this API has no simpler non-streaming reply shape) with a
/// caller-supplied system/user pair instead of the fixed tag prompt.
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError> {
let body = serde_json::json!({
"model": self.model,
"messages": [
{ "role": "system", "content": system },
{ "role": "user", "content": user },
],
"stream": true,
});
let req = self.auth(
reqwest::Client::new()
.post(format!("{}/v1/chat/completions", self.base()))
.json(&body),
);
let resp = req
.send()
.await
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
if !resp.status().is_success() {
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
}
let mut full_text = String::new();
stream_lines(resp, |line| {
let Some(payload) = line.strip_prefix("data:") else {
return true;
};
let payload = payload.trim();
if payload == "[DONE]" {
return false;
}
let Ok(chunk) = serde_json::from_str::<OpenAiChatChunk>(payload) else {
return true;
};
for choice in chunk.choices {
if let Some(content) = choice.delta.content {
full_text.push_str(&content);
}
}
true
})
.await?;
Ok(full_text)
}
fn is_local(&self) -> bool {
self.credential_ref.is_none() && is_local_endpoint(&self.endpoint)
}
@@ -755,6 +840,14 @@ impl LlmProvider for AnthropicProvider {
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
))
}
async fn complete(&self, _system: &str, _user: &str) -> Result<String, LlmError> {
// Not built yet (Phase 10a); `briefs::LlmFeatureBriefBuilder` (M1) only
// ever gets an Ollama/OpenAI-compatible provider from
// `llm_provider_from_settings` today, so this path isn't reachable yet.
Err(LlmError::Request(
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
))
}
fn is_local(&self) -> bool {
false
}