gobby-wiki 0.7.0

Gobby wiki CLI shell
use crate::commands::ask::citation::CITATION_CHECK_SUPPORTED;
use crate::commands::scoped_outcome;
use crate::output::AskOutput;
use crate::{CommandOutcome, ScopeIdentity, WikiError};

pub(super) fn render(output: AskOutput) -> Result<CommandOutcome, WikiError> {
    let scope = output.scope.clone();
    let text = render_text(&output.query, &scope, &output);
    let payload = serde_json::to_value(&output).map_err(|error| WikiError::Json {
        action: "serialize ask output",
        path: None,
        source: error,
    })?;

    Ok(scoped_outcome("ask", &scope, payload, text))
}

fn render_text(query: &str, scope: &ScopeIdentity, output: &AskOutput) -> String {
    if let Some(synthesis) = &output.synthesis {
        let mut text = format!(
            "Answer for \"{query}\"\nScope: {scope}\n\n{}",
            synthesis.answer
        );
        if synthesis.citation_check.status != CITATION_CHECK_SUPPORTED {
            text.push_str(&format!(
                "\n\n[unverified] {} claim(s) lack citation support in the retrieved evidence.",
                synthesis.citation_check.unsupported_claims.len()
            ));
        }
        return text;
    }
    let mut text = format!("Wiki hits for \"{query}\"\nScope: {scope}\n");
    if output.degraded {
        text.push_str(&format!(
            "Degraded sources: {}\n",
            output.degraded_sources.join(", ")
        ));
    }
    if output.hits.is_empty() {
        text.push_str("No results");
    } else {
        for hit in &output.hits {
            text.push_str("- ");
            if let Some(title) = &hit.title {
                text.push_str(title);
                text.push_str("");
            }
            text.push_str(&hit.wiki_page.display().to_string());
            text.push('\n');
        }
    }
    if !output.code_citations.is_empty() {
        text.push_str("Code citations\n");
        for citation in &output.code_citations {
            text.push_str("- ");
            text.push_str(&citation.file);
            if let Some(line) = citation.line {
                text.push_str(&format!(":{line}"));
            }
            if let Some(symbol) = &citation.symbol {
                text.push(' ');
                text.push_str(symbol);
            }
            text.push('\n');
        }
    }
    text
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::ask::assembly::ask_output_from_retrieval;
    use crate::commands::ask::evidence::plan_evidence;
    use crate::commands::ask::synthesis::record_synthesis;
    use crate::commands::ask::test_support::retrieval_with_hooks_hit;

    #[test]
    fn unverified_synthesis_is_flagged_in_text_render() {
        let retrieval = retrieval_with_hooks_hit();
        let plan = plan_evidence(&retrieval);
        let mut output = ask_output_from_retrieval(retrieval.output, &plan);
        record_synthesis(
            &mut output,
            &plan.excerpts,
            "direct",
            "Kubernetes pods restart the scheduler cluster nightly.".to_string(),
            None,
        );

        let text = render_text(
            &output.query.clone(),
            &ScopeIdentity::topic("docs"),
            &output,
        );
        assert!(text.contains("[unverified] 1 claim(s) lack citation support"));

        let retrieval = retrieval_with_hooks_hit();
        let plan = plan_evidence(&retrieval);
        let mut grounded = ask_output_from_retrieval(retrieval.output, &plan);
        record_synthesis(
            &mut grounded,
            &plan.excerpts,
            "direct",
            "Hooks run at turn boundaries.".to_string(),
            None,
        );
        let text = render_text(
            &grounded.query.clone(),
            &ScopeIdentity::topic("docs"),
            &grounded,
        );
        assert!(!text.contains("[unverified]"));
    }
}