use std::path::Path;
use gobby_core::ai::{daemon, effective_route, text};
use gobby_core::ai_context::{AiConfigSource, AiContext, AiContextOptions};
use gobby_core::config::{AiCapability, AiRouting};
use super::AcceptedNoteDraft;
use super::notes::write_accepted_note;
use super::outcome::{dedup_strings, estimate_tokens, observation_from_outcome};
use crate::commands::{ask, index, read, search};
use crate::research_loop::{
ModelDecision, ModelRequest, NoteWriteOutcome, ResearchModel, ResearchModelError,
ResearchNoteWriter, ResearchObservation, SourceIngestor, WikiAsk, WikiRead, WikiSearch,
model_system_prompt, parse_model_action, render_model_prompt,
};
use crate::{IngestFileOptions, ReadTarget, ScopeSelection, WikiError};
pub(crate) struct GcoreResearchModel {
pub(crate) requested_route: AiRouting,
pub(crate) require_ai: bool,
}
impl GcoreResearchModel {
fn ai_unavailable<T>(&self, message: String) -> Result<T, ResearchModelError> {
if self.require_ai {
return Err(WikiError::Config { detail: message }.into());
}
Err(ResearchModelError::AiUnavailable(message))
}
}
impl ResearchModel for GcoreResearchModel {
fn next_action(
&mut self,
request: ModelRequest<'_>,
) -> Result<ModelDecision, ResearchModelError> {
let mut source = research_ai_config_source()?;
let context = AiContext::resolve_with_options(
None,
&mut source,
AiContextOptions {
no_ai: false,
forced_routing: Some(self.requested_route),
},
);
let route = effective_route(&context, AiCapability::TextGenerate);
let prompt = render_model_prompt(&request);
let max_tokens = request.max_tokens.saturating_sub(request.tokens_used);
if max_tokens == 0 {
return Err(ResearchModelError::BudgetExceeded);
}
let result = match route {
AiRouting::Direct => text::generate_text_with_max_tokens(
&context,
&prompt,
Some(model_system_prompt()),
Some(max_tokens),
),
AiRouting::Daemon => daemon::generate_via_daemon_with_max_tokens(
&context,
&prompt,
Some(model_system_prompt()),
Some(max_tokens),
),
AiRouting::Off => {
return self
.ai_unavailable(format!("text generation route '{route:?}' is unavailable"));
}
AiRouting::Auto => {
return self
.ai_unavailable(format!("text generation route '{route:?}' is unavailable"));
}
};
let result = match result {
Ok(result) => result,
Err(error) => return self.ai_unavailable(error.to_string()),
};
let action =
parse_model_action(&result.text).map_err(ResearchModelError::InvalidResponse)?;
let tokens_used = result
.usage
.as_ref()
.and_then(|usage| usage.token_count())
.unwrap_or_else(|| {
estimate_tokens(&prompt).saturating_add(estimate_tokens(&result.text))
});
Ok(ModelDecision {
action,
tokens_used,
})
}
}
pub(crate) fn research_ai_config_source() -> Result<AiConfigSource, WikiError> {
let gobby_home = gobby_core::gobby_home().map_err(|error| WikiError::Config {
detail: format!("failed to resolve Gobby home for gwiki research config: {error}"),
})?;
AiConfigSource::from_gobby_home(&gobby_home).map_err(|error| WikiError::Config {
detail: format!("failed to resolve AI config for gwiki research: {error}"),
})
}
pub(crate) struct CommandAsk {
pub(crate) selection: ScopeSelection,
}
impl WikiAsk for CommandAsk {
fn ask(&mut self, query: &str) -> Result<ResearchObservation, WikiError> {
let outcome = ask::execute(
query.to_string(),
self.selection.clone(),
false,
AiRouting::Off,
false,
)?;
Ok(observation_from_outcome("ask", &outcome))
}
}
pub(crate) struct CommandSearch {
pub(crate) selection: ScopeSelection,
}
impl WikiSearch for CommandSearch {
fn search(&mut self, query: &str, limit: usize) -> Result<ResearchObservation, WikiError> {
let output = search::retrieve(query.to_string(), self.selection.clone(), limit, true)?;
let mut sources = Vec::new();
for result in &output.results {
sources.push(result.source_path.display().to_string());
sources.extend(result.sources.iter().cloned());
}
Ok(ResearchObservation::new(
"search",
format!("{} search hit(s) for {query}", output.results.len()),
)
.with_sources(dedup_strings(sources)))
}
}
pub(crate) struct CommandRead {
pub(crate) selection: ScopeSelection,
}
impl WikiRead for CommandRead {
fn read(&mut self, path: &Path) -> Result<ResearchObservation, WikiError> {
let outcome = read::execute(ReadTarget::Path(path.to_path_buf()), self.selection.clone())?;
let mut observation = observation_from_outcome("read", &outcome);
observation.sources.push(path.display().to_string());
observation.sources = dedup_strings(observation.sources);
Ok(observation)
}
}
pub(crate) struct CommandIngestor {
pub(crate) selection: ScopeSelection,
}
impl SourceIngestor for CommandIngestor {
fn ingest_url(&mut self, url: &str) -> Result<ResearchObservation, WikiError> {
let outcome = index::execute_ingest_url(vec![url.to_string()], self.selection.clone())?;
let mut observation = observation_from_outcome("ingest_url", &outcome);
if outcome.exit_code == 0 && !observation.sources.iter().any(|source| source == url) {
observation.sources.push(url.to_string());
}
observation.sources = dedup_strings(observation.sources);
Ok(observation)
}
fn ingest_file(&mut self, path: &Path) -> Result<ResearchObservation, WikiError> {
let outcome = index::execute_ingest_file(
path.to_path_buf(),
self.selection.clone(),
IngestFileOptions::default(),
)?;
let mut observation = observation_from_outcome("ingest_file", &outcome);
let path_string = path.display().to_string();
if outcome.exit_code == 0
&& !observation
.sources
.iter()
.any(|source| source == &path_string)
{
observation.sources.push(path_string);
}
observation.sources = dedup_strings(observation.sources);
Ok(observation)
}
}
pub(crate) struct AcceptedNoteWriter<'a> {
pub(crate) root: &'a Path,
pub(crate) session_id: &'a str,
}
impl ResearchNoteWriter for AcceptedNoteWriter<'_> {
fn write_note(&mut self, note: &AcceptedNoteDraft) -> Result<NoteWriteOutcome, WikiError> {
let accepted = write_accepted_note(self.root, self.session_id, note)?;
Ok(NoteWriteOutcome {
note: accepted.note,
created: accepted.created,
write_conflict: accepted.write_conflict,
})
}
}