use std::fmt::{Display, Formatter};
use index_core::{IndexDocument, IndexNode, Link, Redactor};
use index_extract::{ExtractFormat, extract_document};
pub const PROMPT_TEMPLATE_VERSION: &str = "index-ai-prompt-v1";
const EXPLAIN_SYSTEM_PROMPT: &str = "Explain the Index document in concise terminal-native terms.";
const SUMMARIZE_SYSTEM_PROMPT: &str =
"Summarize the Index document without adding unsupported claims.";
const EXTRACT_SYSTEM_PROMPT: &str =
"Extract structured facts from the Index document as short bullet points.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiAction {
Explain,
Summarize,
Extract,
}
impl AiAction {
#[must_use]
pub fn parse(input: &str) -> Option<Self> {
match input.trim().to_ascii_lowercase().as_str() {
"explain" => Some(Self::Explain),
"summarize" | "summary" => Some(Self::Summarize),
"extract" => Some(Self::Extract),
_ => None,
}
}
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Explain => "explain",
Self::Summarize => "summarize",
Self::Extract => "extract",
}
}
}
impl Display for AiAction {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptTemplate {
pub version: &'static str,
pub action: AiAction,
pub system: &'static str,
}
#[must_use]
pub const fn prompt_template(action: AiAction) -> PromptTemplate {
let system = match action {
AiAction::Explain => EXPLAIN_SYSTEM_PROMPT,
AiAction::Summarize => SUMMARIZE_SYSTEM_PROMPT,
AiAction::Extract => EXTRACT_SYSTEM_PROMPT,
};
PromptTemplate {
version: PROMPT_TEMPLATE_VERSION,
action,
system,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrivacyMode {
Redacted,
AllowPageContent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiPrompt {
pub template_version: String,
pub action: AiAction,
pub system: String,
pub user: String,
pub privacy_mode: PrivacyMode,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiRequest {
pub prompt: AiPrompt,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiResponse {
pub text: String,
pub deterministic_fallback: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AiError {
Provider(String),
MissingMockResponse,
}
impl Display for AiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Provider(message) => write!(f, "AI provider failed: {message}"),
Self::MissingMockResponse => f.write_str("AI mock provider has no response"),
}
}
}
impl std::error::Error for AiError {}
pub trait AiProvider {
fn transform(&self, request: &AiRequest) -> Result<AiResponse, AiError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct OfflineProvider;
impl AiProvider for OfflineProvider {
fn transform(&self, request: &AiRequest) -> Result<AiResponse, AiError> {
Ok(AiResponse {
text: deterministic_fallback(&request.prompt),
deterministic_fallback: true,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MockProvider {
response: Option<AiResponse>,
}
impl MockProvider {
#[must_use]
pub fn with_response(text: impl Into<String>) -> Self {
Self {
response: Some(AiResponse {
text: text.into(),
deterministic_fallback: false,
}),
}
}
#[must_use]
pub const fn empty() -> Self {
Self { response: None }
}
}
impl AiProvider for MockProvider {
fn transform(&self, _request: &AiRequest) -> Result<AiResponse, AiError> {
self.response.clone().ok_or(AiError::MissingMockResponse)
}
}
#[must_use]
pub fn prepare_ai_request(
document: &IndexDocument,
action: AiAction,
privacy_mode: PrivacyMode,
redactor: &Redactor,
) -> AiRequest {
let template = prompt_template(action);
let document_text = document_for_prompt(document, action);
let user = match privacy_mode {
PrivacyMode::Redacted => redactor.redact(&document_text),
PrivacyMode::AllowPageContent => document_text,
};
AiRequest {
prompt: AiPrompt {
template_version: template.version.to_owned(),
action,
system: template.system.to_owned(),
user,
privacy_mode,
},
}
}
pub fn run_ai_action<P: AiProvider>(
provider: &P,
document: &IndexDocument,
action: AiAction,
privacy_mode: PrivacyMode,
redactor: &Redactor,
) -> Result<AiResponse, AiError> {
let request = prepare_ai_request(document, action, privacy_mode, redactor);
provider.transform(&request)
}
fn document_for_prompt(document: &IndexDocument, action: AiAction) -> String {
match action {
AiAction::Explain | AiAction::Summarize => {
extract_document(document, ExtractFormat::Markdown)
}
AiAction::Extract => extract_document(document, ExtractFormat::Json),
}
}
fn deterministic_fallback(prompt: &AiPrompt) -> String {
let title = prompt
.user
.lines()
.find_map(|line| line.strip_prefix("# "))
.unwrap_or("Untitled document");
let facts = prompt
.user
.lines()
.filter(|line| !line.trim().is_empty())
.take(4)
.collect::<Vec<_>>();
match prompt.action {
AiAction::Explain => format!(
"Offline explain: {title}. This document has {} visible prompt lines.",
facts.len()
),
AiAction::Summarize => {
let summary = facts.join(" ");
format!("Offline summary: {summary}")
}
AiAction::Extract => format!(
"Offline extract:\n- template: {}\n- prompt_lines: {}",
prompt.template_version,
facts.len()
),
}
}
#[must_use]
pub fn document_links(document: &IndexDocument) -> Vec<&Link> {
document
.nodes
.iter()
.filter_map(|node| match node {
IndexNode::Link(link) => Some(link),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use index_core::{IndexDocument, IndexNode, Link, Redactor};
use super::{
AiAction, AiError, MockProvider, OfflineProvider, PrivacyMode, document_links,
prepare_ai_request, prompt_template, run_ai_action,
};
fn document() -> IndexDocument {
let mut document = IndexDocument::titled("AI Fixture");
document.push(IndexNode::Paragraph(
"Visible body token=secret Authorization: Bearer abc123".to_owned(),
));
document.push(IndexNode::Link(Link::new(
"Docs",
"https://example.com/docs",
)));
document
}
#[test]
fn prompt_template_snapshot_is_versioned() {
let template = prompt_template(AiAction::Summarize);
assert_eq!(template.version, "index-ai-prompt-v1");
assert_eq!(
template.system,
"Summarize the Index document without adding unsupported claims."
);
}
#[test]
fn action_parser_accepts_supported_actions() {
assert_eq!(AiAction::parse("explain"), Some(AiAction::Explain));
assert_eq!(AiAction::parse("summary"), Some(AiAction::Summarize));
assert_eq!(AiAction::parse("extract"), Some(AiAction::Extract));
assert_eq!(AiAction::parse("chat"), None);
}
#[test]
fn redacted_prompt_hides_known_credential_fields() {
let mut redactor = Redactor::new();
redactor.add_secret("abc123");
let request = prepare_ai_request(
&document(),
AiAction::Summarize,
PrivacyMode::Redacted,
&redactor,
);
assert!(request.prompt.user.contains("[REDACTED]"));
assert!(!request.prompt.user.contains("abc123"));
assert!(!request.prompt.user.contains("token=secret"));
}
#[test]
fn explicit_page_content_mode_preserves_document_text() {
let redactor = Redactor::new();
let request = prepare_ai_request(
&document(),
AiAction::Summarize,
PrivacyMode::AllowPageContent,
&redactor,
);
assert!(request.prompt.user.contains("token=secret"));
}
#[test]
fn offline_provider_returns_deterministic_fallback() {
let redactor = Redactor::new();
let response = run_ai_action(
&OfflineProvider,
&document(),
AiAction::Explain,
PrivacyMode::Redacted,
&redactor,
);
assert!(
matches!(response, Ok(response) if response.deterministic_fallback && response.text.contains("Offline explain"))
);
}
#[test]
fn mock_provider_returns_configured_response() {
let provider = MockProvider::with_response("provider text");
let redactor = Redactor::new();
let response = run_ai_action(
&provider,
&document(),
AiAction::Extract,
PrivacyMode::Redacted,
&redactor,
);
assert!(matches!(response, Ok(response) if response.text == "provider text"));
}
#[test]
fn mock_provider_reports_missing_response() {
let redactor = Redactor::new();
let response = run_ai_action(
&MockProvider::empty(),
&document(),
AiAction::Extract,
PrivacyMode::Redacted,
&redactor,
);
assert_eq!(response, Err(AiError::MissingMockResponse));
}
#[test]
fn document_link_context_uses_document_model_links() {
let document = document();
let links = document_links(&document);
assert_eq!(links.len(), 1);
assert_eq!(links[0].href, "https://example.com/docs");
}
}