use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use serde::Serialize;
use crate::WikiError;
use crate::citations::render_source_citations;
use crate::session::{CompileState, ResearchSession};
use crate::synthesis::{
ArticleKind, PageWriteOutcome, SynthesisInput, SynthesisPrompt, SynthesisSource, WritePolicy,
build_synthesis_prompt, synthesize_article, synthesize_source_pages, write_synthesized_page,
};
mod collect;
mod index;
mod render;
use collect::*;
use index::*;
use render::*;
const INDEX_LOCK_TIMEOUT_ENV: &str = "GWIKI_INDEX_LOCK_TIMEOUT_MS";
const DEFAULT_INDEX_LOCK_TIMEOUT_MS: u64 = 5_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompileRequest {
pub topic: String,
pub outline: Vec<String>,
pub target_page: Option<PathBuf>,
pub write_intent: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompileOutcome {
pub bundle: CompileBundle,
pub state: CompileState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WikiCompileOptions {
pub target_kind: ArticleKind,
pub daemon_synthesis_available: bool,
}
impl Default for WikiCompileOptions {
fn default() -> Self {
Self {
target_kind: ArticleKind::Topic,
daemon_synthesis_available: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WikiCompileOutcome {
pub handoff_id: String,
pub article_path: PathBuf,
pub source_paths: Vec<PathBuf>,
pub index_path: PathBuf,
pub page_writes: Vec<PageWriteOutcome>,
pub prompt: SynthesisPrompt,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompileBundle {
pub handoff_id: String,
pub topic: String,
pub outline: Vec<String>,
pub accepted_sources: Vec<AcceptedCompileSource>,
pub citations: Vec<String>,
pub conflicting_claims: Vec<String>,
pub missing_evidence: Vec<String>,
pub target_page: Option<PathBuf>,
pub write_intent: bool,
pub path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcceptedCompileSource {
pub title: String,
pub path: PathBuf,
pub chunks: Vec<String>,
pub chunk_offsets: Vec<AcceptedCompileChunkOffset>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcceptedCompileChunkOffset {
pub byte_start: usize,
pub byte_end: usize,
}
pub fn compile_to_wiki(
session: &mut ResearchSession,
request: CompileRequest,
) -> Result<WikiCompileOutcome, WikiError> {
compile_to_wiki_with_options(session, request, WikiCompileOptions::default())
}
pub fn compile_to_wiki_with_options(
session: &mut ResearchSession,
request: CompileRequest,
options: WikiCompileOptions,
) -> Result<WikiCompileOutcome, WikiError> {
let target_page = normalize_target_page(session.scope.root(), request.target_page.as_deref())?;
let write_intent = request.write_intent;
let handoff_request = CompileRequest {
topic: request.topic,
outline: request.outline,
target_page: None,
write_intent: false,
};
let mut handoff = prepare_handoff(session, handoff_request)?;
handoff.bundle.target_page = target_page.clone();
handoff.bundle.write_intent = write_intent;
handoff.state.write_intent = write_intent;
session.record_compile_state(handoff.state.clone())?;
if handoff.bundle.write_intent
&& let Some(target_page) = handoff.bundle.target_page.as_ref()
{
let rendered = render_bundle(&handoff.bundle);
write_target_page(session.scope.root(), target_page, &rendered)?;
}
let vault_root = session.scope.root();
let source_paths: Vec<PathBuf> = handoff
.bundle
.accepted_sources
.iter()
.map(|source| source.path.clone())
.collect();
let mut citations = handoff.bundle.citations.clone();
extend_unique(
&mut citations,
render_source_citations(vault_root, &source_paths)?,
);
let synthesis_sources = handoff
.bundle
.accepted_sources
.iter()
.map(|source| SynthesisSource {
title: source.title.clone(),
path: source.path.clone(),
chunks: source.chunks.clone(),
})
.collect();
let input = SynthesisInput {
handoff_id: handoff.bundle.handoff_id.clone(),
topic: handoff.bundle.topic.clone(),
outline: handoff.bundle.outline.clone(),
target_kind: options.target_kind,
accepted_sources: synthesis_sources,
citations,
conflicting_claims: handoff.bundle.conflicting_claims.clone(),
missing_evidence: handoff.bundle.missing_evidence.clone(),
daemon_synthesis_available: options.daemon_synthesis_available,
};
let prompt = build_synthesis_prompt(&input);
let article = synthesize_article(vault_root, &input, target_page)?;
let mut pages = vec![article.clone()];
pages.extend(synthesize_source_pages(vault_root, &input, &article.path)?);
let policy = if write_intent {
WritePolicy::AllowOverwriteAfterMerge
} else {
WritePolicy::RequireMergeIntent
};
let mut page_writes = Vec::with_capacity(pages.len());
for page in &pages {
page_writes.push(write_synthesized_page(vault_root, page, policy)?);
}
update_wiki_index(vault_root, &article)?;
write_provenance(vault_root, &article, &handoff.bundle.accepted_sources)?;
mark_sources_compiled(vault_root, &source_paths)?;
Ok(WikiCompileOutcome {
handoff_id: handoff.bundle.handoff_id,
article_path: article.path,
source_paths: pages.iter().skip(1).map(|page| page.path.clone()).collect(),
index_path: vault_root.join("_index.md"),
page_writes,
prompt,
})
}
pub fn prepare_handoff(
session: &mut ResearchSession,
mut request: CompileRequest,
) -> Result<CompileOutcome, WikiError> {
if request.topic.trim().is_empty() {
return Err(WikiError::InvalidInput {
field: "topic",
message: "compile handoff requires a topic".to_string(),
});
}
request.target_page =
normalize_target_page(session.scope.root(), request.target_page.as_deref())?;
let handoff_id = format!(
"compile-{}-{}",
slugify(&request.topic),
unix_timestamp_ms()?
);
let bundle_path = session
.scope
.root()
.join(".gwiki")
.join("compile")
.join(format!("{handoff_id}.md"));
let collected_sources = collect_accepted_sources(session)?;
let bundle = CompileBundle {
handoff_id: handoff_id.clone(),
topic: request.topic,
outline: request.outline,
accepted_sources: collected_sources.accepted_sources,
citations: collected_sources.citations,
conflicting_claims: collected_sources.conflicting_claims,
missing_evidence: collected_sources.missing_evidence,
target_page: request.target_page,
write_intent: request.write_intent,
path: bundle_path,
};
let rendered = render_bundle(&bundle);
if let Some(parent) = bundle.path.parent() {
fs::create_dir_all(parent).map_err(|error| WikiError::Io {
action: "create compile handoff directory",
path: Some(parent.to_path_buf()),
source: error,
})?;
}
fs::write(&bundle.path, &rendered).map_err(|error| WikiError::Io {
action: "write compile handoff bundle",
path: Some(bundle.path.clone()),
source: error,
})?;
let state = CompileState {
handoff_id,
topic: bundle.topic.clone(),
bundle_path: bundle.path.clone(),
selected_note_paths: bundle
.accepted_sources
.iter()
.map(|source| source.path.clone())
.collect(),
selected_source_titles: bundle
.accepted_sources
.iter()
.map(|source| source.title.clone())
.collect(),
citations: bundle.citations.clone(),
conflicting_claims: bundle.conflicting_claims.clone(),
missing_evidence: bundle.missing_evidence.clone(),
write_intent: bundle.write_intent,
};
session.record_compile_state(state.clone())?;
Ok(CompileOutcome { bundle, state })
}
#[derive(Debug, Default)]
pub(crate) struct CollectedSources {
accepted_sources: Vec<AcceptedCompileSource>,
citations: Vec<String>,
conflicting_claims: Vec<String>,
missing_evidence: Vec<String>,
}
pub(crate) fn index_lock_timeout() -> Duration {
match std::env::var(INDEX_LOCK_TIMEOUT_ENV) {
Ok(raw) => raw
.parse::<u64>()
.ok()
.filter(|value| *value > 0)
.map(Duration::from_millis)
.unwrap_or_else(|| {
eprintln!("warning: ignoring invalid {INDEX_LOCK_TIMEOUT_ENV}={raw}");
Duration::from_millis(DEFAULT_INDEX_LOCK_TIMEOUT_MS)
}),
Err(_) => Duration::from_millis(DEFAULT_INDEX_LOCK_TIMEOUT_MS),
}
}
#[cfg(test)]
mod tests;