use std::time::Duration;
use gobby_core::ai::{
daemon::{generate_via_daemon_with_candidates, generate_via_daemon_with_max_tokens},
effective_route,
text::{generate_text, generate_text_with_max_tokens},
};
use gobby_core::ai_context::{AiConfigSource, AiContext, AiContextOptions, PostgresAiConfigSource};
use gobby_core::ai_types::AiError;
use gobby_core::config::{AiCapability, AiRouting, FeatureCandidate};
use crate::commands::codewiki::{
CodewikiAiOptions, DEFAULT_VERIFY_PROFILE, PromptTier, TextGenerator, TextVerifier, prompts,
};
use crate::config::{self, Context};
use crate::{db, secrets};
pub(super) const GENERATION_RETRY_BACKOFF: [Duration; 2] =
[Duration::from_millis(200), Duration::from_millis(500)];
fn writer_candidate_chain() -> Vec<FeatureCandidate> {
vec![
FeatureCandidate {
candidate: "claude/opus".to_string(),
reasoning_effort: Some("high".to_string()),
},
FeatureCandidate {
candidate: "codex/gpt-5.5".to_string(),
reasoning_effort: Some("xhigh".to_string()),
},
]
}
const MODULE_WRITER_PROFILE: &str = "feature_mid";
pub(crate) fn resolve_text_generator(
ctx: &Context,
ai: &CodewikiAiOptions,
) -> Option<Box<TextGenerator<'static>>> {
let ai_context = resolve_ai_context(ctx, ai.routing).ok()?;
let route = effective_route(&ai_context, AiCapability::TextGenerate);
if matches!(route, AiRouting::Off | AiRouting::Auto) {
return None;
}
let aggregate_profile = ai.aggregate_profile.clone();
let writer_candidates = writer_candidate_chain();
let max_tokens = ai.prose_depth.max_tokens();
let register = ai.register;
let mut warned = false;
let quiet = ctx.quiet;
Some(Box::new(move |prompt, system, tier| {
let use_opus_writer = matches!(tier, PromptTier::Aggregate) && aggregate_profile.is_none();
let profile = match tier {
PromptTier::Aggregate => aggregate_profile.as_deref(),
PromptTier::Module => Some(MODULE_WRITER_PROFILE),
PromptTier::Standard => None,
};
let system = prompts::with_register(system, register);
let result = generate_with_bounded_retry(|| match route {
AiRouting::Daemon => {
if use_opus_writer {
generate_via_daemon_with_candidates(
&ai_context,
prompt,
Some(system.as_ref()),
max_tokens,
&writer_candidates,
)
} else {
generate_via_daemon_with_max_tokens(
&ai_context,
prompt,
Some(system.as_ref()),
max_tokens,
profile,
)
}
}
AiRouting::Direct => generate_text_with_max_tokens(
&ai_context,
prompt,
Some(system.as_ref()),
max_tokens,
),
AiRouting::Off | AiRouting::Auto => {
unreachable!("non-generating routes returned above")
}
});
match result {
Ok(result) => clean_generated(result.text),
Err(error) => {
if !quiet && !warned {
eprintln!(
"text generation failed; affected codewiki docs fall back to AST-only \
content and record degraded: true: {error}"
);
warned = true;
}
None
}
}
}))
}
pub(crate) fn resolve_text_verifier(
ctx: &Context,
ai: &CodewikiAiOptions,
) -> Option<Box<TextVerifier<'static>>> {
let mut ai_context = resolve_ai_context(ctx, ai.routing).ok()?;
let route = effective_route(&ai_context, AiCapability::TextGenerate);
if matches!(route, AiRouting::Off | AiRouting::Auto) {
return None;
}
let binding = ai_context.binding(AiCapability::TextGenerate);
let verify_profile = ai
.verify_profile
.clone()
.or_else(|| binding.verify_profile.clone())
.unwrap_or_else(|| DEFAULT_VERIFY_PROFILE.to_string());
let verify_model = ai
.verify_model
.clone()
.or_else(|| binding.verify_model.clone());
let verify_api_key = ai
.verify_api_key
.clone()
.or_else(|| binding.verify_api_key.clone());
if matches!(route, AiRouting::Direct) {
let text_generate = &mut ai_context.bindings.text_generate;
if let Some(model) = verify_model {
text_generate.model = Some(model);
}
if let Some(api_key) = verify_api_key {
text_generate.api_key = Some(api_key);
}
}
let quiet = ctx.quiet;
let mut warned = false;
Some(Box::new(move |prompt: &str, system: &str| {
let result = generate_with_bounded_retry(|| match route {
AiRouting::Daemon => generate_via_daemon_with_max_tokens(
&ai_context,
prompt,
Some(system),
None,
Some(verify_profile.as_str()),
),
AiRouting::Direct => generate_text(&ai_context, prompt, Some(system)),
AiRouting::Off | AiRouting::Auto => {
unreachable!("non-generating routes returned above")
}
});
match result {
Ok(result) => clean_generated(result.text),
Err(error) => {
if !quiet && !warned {
eprintln!(
"codewiki verification unavailable; generated narratives ship \
unverified (degraded: false): {error}"
);
warned = true;
}
None
}
}
}))
}
pub(crate) fn generate_with_bounded_retry<T>(
mut call: impl FnMut() -> Result<T, AiError>,
) -> Result<T, AiError> {
let mut result = call();
for backoff in GENERATION_RETRY_BACKOFF {
match &result {
Err(error) if retryable_generation_error(error) => {
std::thread::sleep(backoff);
result = call();
}
_ => break,
}
}
result
}
fn retryable_generation_error(error: &AiError) -> bool {
match error {
AiError::TransportFailure { .. } | AiError::RateLimited { .. } => true,
AiError::HttpStatus { status, .. } => *status >= 500,
AiError::CapabilityUnavailable { .. }
| AiError::NotConfigured { .. }
| AiError::ParseFailure { .. } => false,
}
}
fn resolve_ai_context(ctx: &Context, ai: Option<AiRouting>) -> anyhow::Result<AiContext> {
let mut conn = db::connect_readonly(&ctx.database_url)?;
let standalone = config::read_standalone_config_optional();
let primary = PostgresAiConfigSource::new(&mut conn, secrets::resolve_config_value);
let mut source = AiConfigSource::with_primary(primary, standalone);
Ok(AiContext::resolve_with_options(
Some(ctx.project_id.clone()),
&mut source,
AiContextOptions {
no_ai: false,
forced_routing: ai,
},
))
}
#[derive(Debug)]
pub(crate) enum Generation {
Generated(String),
Failed,
Skipped,
}
impl Generation {
pub(crate) fn failed(&self) -> bool {
matches!(self, Generation::Failed)
}
pub(crate) fn unwrap_or_record(self, fallback: String, degraded: &mut bool) -> String {
match self {
Generation::Generated(text) => text,
Generation::Failed => {
*degraded = true;
fallback
}
Generation::Skipped => fallback,
}
}
}
pub(crate) fn maybe_generate(
generate: &mut Option<&mut TextGenerator<'_>>,
prompt: &str,
system: &str,
tier: PromptTier,
) -> Generation {
match generate.as_deref_mut() {
None => Generation::Skipped,
Some(generate) => match generate(prompt, system, tier) {
Some(text) if is_prompt_echo(&text, prompt) || is_model_refusal(&text) => {
Generation::Failed
}
Some(text) => Generation::Generated(text),
None => Generation::Failed,
},
}
}
const PROMPT_ECHO_PREFIX_CHARS: usize = 80;
pub(super) fn is_prompt_echo(text: &str, prompt: &str) -> bool {
let prefix = prompt
.trim_start()
.chars()
.take(PROMPT_ECHO_PREFIX_CHARS)
.collect::<String>();
if prefix.chars().count() < PROMPT_ECHO_PREFIX_CHARS {
return false;
}
text.trim_start().starts_with(&prefix)
}
const REFUSAL_SCAN_CHARS: usize = 600;
pub(super) fn is_model_refusal(text: &str) -> bool {
let head_end = text
.char_indices()
.nth(REFUSAL_SCAN_CHARS)
.map_or(text.len(), |(idx, _)| idx);
let head = text[..head_end].to_ascii_lowercase();
const REFUSAL_MARKERS: [&str; 8] = [
"i cannot write",
"i can't write",
"i cannot create",
"i can't create",
"i cannot generate",
"i can't generate",
"i am unable to",
"i'm unable to",
];
REFUSAL_MARKERS.iter().any(|marker| head.contains(marker))
}
fn clean_generated(text: String) -> Option<String> {
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}