use std::collections::BTreeMap;
use std::fmt::Write as _;
use super::super::*;
const MAX_PAGE_SYMBOL_ROWS: usize = 12;
#[derive(Clone, Copy)]
pub(crate) enum CuratedPageKind {
Concept,
Narrative,
}
pub(crate) struct CuratedBody {
pub(crate) body: Option<String>,
pub(crate) degraded: bool,
pub(crate) verify_notes: Vec<VerifyNote>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn curated_page_body(
kind: CuratedPageKind,
title: &str,
summary: &str,
member_modules: &[String],
member_files: &[String],
module_lookup: &BTreeMap<&str, &ModuleDoc>,
file_lookup: &BTreeMap<&str, &FileDoc>,
leading_chunks: &BTreeMap<String, LeadingChunk>,
spans: &[SourceSpan],
generate: &mut Option<&mut TextGenerator<'_>>,
verify: &mut Option<&mut TextVerifier<'_>>,
) -> CuratedBody {
let members = member_evidence_rows(member_modules, member_files, module_lookup, file_lookup);
let symbols = symbol_evidence_rows(member_files, file_lookup);
if members.is_empty() && symbols.is_empty() {
return CuratedBody {
body: None,
degraded: false,
verify_notes: Vec::new(),
};
}
let excerpt_take = match kind {
CuratedPageKind::Concept => prompts::CONCEPT_PAGE_SOURCE_EXCERPTS,
CuratedPageKind::Narrative => prompts::NARRATIVE_PAGE_SOURCE_EXCERPTS,
};
let member_file_docs = member_files
.iter()
.filter_map(|file| file_lookup.get(file.as_str()).copied());
let sources = ranked_source_excerpts(member_file_docs, leading_chunks, excerpt_take);
let prompt = match kind {
CuratedPageKind::Concept => {
prompts::concept_page_prompt(title, summary, &members, &symbols, &sources)
}
CuratedPageKind::Narrative => {
prompts::narrative_page_prompt(title, summary, &members, &symbols, &sources)
}
};
let system = match kind {
CuratedPageKind::Concept => prompts::CONCEPT_PAGE_SYSTEM,
CuratedPageKind::Narrative => prompts::NARRATIVE_PAGE_SYSTEM,
};
match maybe_generate(generate, &prompt, system, PromptTier::Aggregate) {
Generation::Generated(text) => {
let verification_evidence =
verifier_evidence_rows(members.iter().chain(symbols.iter()));
let (text, verify_notes) = match verify_with_notes(
verify,
&text,
&verification_evidence,
&sources,
&RelationshipFacts::default(),
) {
VerifyOutcome::Skipped => (text, Vec::new()),
VerifyOutcome::Verified { text, notes } => (text, notes),
};
let grounded = ground_text(&text, spans, None);
if grounded.trim().is_empty() {
CuratedBody {
body: Some(structural_body(kind, title, &members, &symbols)),
degraded: true,
verify_notes: Vec::new(),
}
} else {
CuratedBody {
body: Some(grounded),
degraded: false,
verify_notes,
}
}
}
Generation::Failed => CuratedBody {
body: Some(structural_body(kind, title, &members, &symbols)),
degraded: true,
verify_notes: Vec::new(),
},
Generation::Skipped => CuratedBody {
body: Some(structural_body(kind, title, &members, &symbols)),
degraded: false,
verify_notes: Vec::new(),
},
}
}
fn member_evidence_rows(
member_modules: &[String],
member_files: &[String],
module_lookup: &BTreeMap<&str, &ModuleDoc>,
file_lookup: &BTreeMap<&str, &FileDoc>,
) -> Vec<prompts::PageEvidenceRow> {
let mut rows = Vec::new();
for module in member_modules {
if let Some(doc) = module_lookup.get(module.as_str()) {
rows.push(prompts::PageEvidenceRow {
name: doc.module.clone(),
kind: "module".to_string(),
citation: span_citation(&doc.source_spans, &doc.module),
summary: doc.summary.clone(),
});
}
}
for file in member_files {
if let Some(doc) = file_lookup.get(file.as_str()) {
rows.push(prompts::PageEvidenceRow {
name: doc.path.clone(),
kind: "file".to_string(),
citation: span_citation(&doc.source_spans, &doc.path),
summary: doc.summary.clone(),
});
}
}
rows
}
fn symbol_evidence_rows(
member_files: &[String],
file_lookup: &BTreeMap<&str, &FileDoc>,
) -> Vec<prompts::PageEvidenceRow> {
let mut rows = Vec::new();
for file in member_files {
if let Some(doc) = file_lookup.get(file.as_str()) {
for symbol in &doc.symbols {
rows.push(prompts::PageEvidenceRow {
name: symbol.symbol.name.clone(),
kind: symbol.symbol.kind.clone(),
citation: symbol.source_span.citation(),
summary: symbol.purpose.clone(),
});
}
}
}
rows.sort_by(|a, b| a.name.cmp(&b.name));
rows.truncate(MAX_PAGE_SYMBOL_ROWS);
rows
}
fn verifier_evidence_rows<'a>(
rows: impl Iterator<Item = &'a prompts::PageEvidenceRow>,
) -> Vec<prompts::SymbolSummary> {
rows.enumerate()
.map(|(index, row)| {
let (line_start, line_end) = citation_line_range(&row.citation).unwrap_or((1, 1));
prompts::SymbolSummary {
name: row.name.clone(),
kind: row.kind.clone(),
component_id: format!("curated-evidence-{}", index + 1),
component_label: row.citation.clone(),
line_start,
line_end,
purpose: row.summary.clone(),
}
})
.collect()
}
fn citation_line_range(citation: &str) -> Option<(usize, usize)> {
let inner = citation.strip_prefix('[')?.strip_suffix(']')?;
let (_file, range) = inner.rsplit_once(':')?;
let (start, end) = match range.split_once('-') {
Some((start, end)) => (start.parse().ok()?, end.parse().ok()?),
None => {
let line = range.parse().ok()?;
(line, line)
}
};
Some((start, end))
}
fn span_citation(spans: &[SourceSpan], fallback: &str) -> String {
spans
.first()
.map(SourceSpan::citation)
.unwrap_or_else(|| fallback.to_string())
}
fn structural_body(
kind: CuratedPageKind,
title: &str,
members: &[prompts::PageEvidenceRow],
symbols: &[prompts::PageEvidenceRow],
) -> String {
let mut body = String::new();
match kind {
CuratedPageKind::Concept => {
write_section(
&mut body,
"Purpose",
&format!(
"{title} groups the related modules and files listed below; \
read the key components for the grounded detail."
),
);
}
CuratedPageKind::Narrative => {
write_section(
&mut body,
"Why this matters",
&format!(
"{title} walks through the modules and files listed below; \
follow the key components in order, then continue to the linked pages."
),
);
}
}
body.push_str("## Key components\n\n");
if symbols.is_empty() {
body.push_str("- No indexed symbols.\n\n");
} else {
write_markdown_table_header(&mut body, &["Symbol", "Kind", "Source", "Role"]);
for row in symbols {
write_markdown_table_row(
&mut body,
[
row.name.clone(),
row.kind.clone(),
row.citation.clone(),
row.summary.clone(),
],
);
}
body.push('\n');
}
if !members.is_empty() {
body.push_str("## Members\n\n");
for row in members {
let _ = writeln!(body, "- `{}` ({}) {}", row.name, row.kind, row.citation);
}
body.push('\n');
}
body
}
pub(crate) fn append_guided_tour(doc: &mut String, chapters: &[(&str, &str)]) {
doc.push_str("## Start here — guided tour\n\n");
if let Some((slug, title)) = chapters.first() {
let _ = writeln!(
doc,
"New to this codebase? Begin with [[code/narrative/{slug}|{title}]].\n"
);
}
for (index, (slug, title)) in chapters.iter().enumerate() {
let _ = writeln!(doc, "{}. [[code/narrative/{slug}|{title}]]", index + 1);
}
doc.push('\n');
append_ask_hint(doc);
}
pub(crate) fn append_ask_hint(doc: &mut String) {
doc.push_str(
"Ask questions across this vault with `gwiki ask \"...\"`, or find pages with `gwiki search \"...\"`.\n\n",
);
}
pub(crate) fn append_tour_nav(
doc: &mut String,
prev: Option<(&str, &str)>,
next: Option<(&str, &str)>,
) {
if prev.is_none() && next.is_none() {
return;
}
doc.push_str("## Continue the tour\n\n");
if let Some((slug, title)) = prev {
let _ = writeln!(doc, "- ← Previous: [[code/narrative/{slug}|{title}]]");
}
if let Some((slug, title)) = next {
let _ = writeln!(doc, "- Next →: [[code/narrative/{slug}|{title}]]");
}
doc.push('\n');
}
struct FlowComponent {
keys: Vec<String>,
label: String,
role: Option<String>,
}
const FLOW_HINT_EXCERPT_CHARS: usize = 600;
const FLOW_ROLE_WORDS: usize = 8;
pub(crate) fn curated_flow_diagram(
member_modules: &[String],
member_files: &[String],
module_lookup: &BTreeMap<&str, &ModuleDoc>,
file_lookup: &BTreeMap<&str, &FileDoc>,
leading_chunks: &BTreeMap<String, LeadingChunk>,
) -> Option<String> {
let mut components = flow_components(member_modules, member_files, module_lookup, file_lookup);
if components.len() < 2 {
return None;
}
let hint = flow_hint_text(
member_modules,
member_files,
module_lookup,
file_lookup,
leading_chunks,
);
let ordered_from_docs = order_components_by_hint(&mut components, &hint);
let degraded = components.iter().any(|component| component.role.is_none());
let steps = components
.iter()
.enumerate()
.map(
|(index, component)| architecture_diagrams::ConceptualFlowStep {
id: format!("s{index}"),
label: component.label.clone(),
role: component.role.clone(),
},
)
.collect::<Vec<_>>();
architecture_diagrams::render_conceptual_flow(&steps, ordered_from_docs, degraded)
}
fn flow_components(
member_modules: &[String],
member_files: &[String],
module_lookup: &BTreeMap<&str, &ModuleDoc>,
file_lookup: &BTreeMap<&str, &FileDoc>,
) -> Vec<FlowComponent> {
let mut components: Vec<FlowComponent> = Vec::new();
for module in member_modules {
if let Some(doc) = module_lookup.get(module.as_str()) {
components.push(component_from(&doc.module, &doc.summary));
}
}
if components.len() < 2 {
for file in member_files {
if let Some(doc) = file_lookup.get(file.as_str()) {
components.push(component_from(&doc.path, &doc.summary));
}
}
}
components
}
fn component_from(name: &str, summary: &str) -> FlowComponent {
let label = flow_label(name);
let mut keys = vec![normalize_key(name)];
let label_key = normalize_key(&label);
if !label_key.is_empty() && !keys.contains(&label_key) {
keys.push(label_key);
}
keys.retain(|key| !key.is_empty());
FlowComponent {
keys,
label,
role: role_phrase(summary),
}
}
fn flow_hint_text(
member_modules: &[String],
member_files: &[String],
module_lookup: &BTreeMap<&str, &ModuleDoc>,
file_lookup: &BTreeMap<&str, &FileDoc>,
leading_chunks: &BTreeMap<String, LeadingChunk>,
) -> String {
let mut text = String::new();
for module in member_modules {
if let Some(doc) = module_lookup.get(module.as_str()) {
text.push_str(&doc.summary);
text.push('\n');
}
}
for file in member_files {
if let Some(doc) = file_lookup.get(file.as_str()) {
text.push_str(&doc.summary);
text.push('\n');
}
if let Some(excerpt) = source_excerpt_for_file(file, leading_chunks) {
let head: String = excerpt
.excerpt
.chars()
.take(FLOW_HINT_EXCERPT_CHARS)
.collect();
text.push_str(&head);
text.push('\n');
}
}
text
}
fn order_components_by_hint(components: &mut Vec<FlowComponent>, hint: &str) -> bool {
let chain = parse_flow_chain(hint, components);
if chain.len() < 2 {
return false;
}
let mut slots: Vec<Option<FlowComponent>> = components.drain(..).map(Some).collect();
let mut ordered: Vec<FlowComponent> = Vec::with_capacity(slots.len());
for &index in &chain {
if let Some(component) = slots[index].take() {
ordered.push(component);
}
}
for slot in &mut slots {
if let Some(component) = slot.take() {
ordered.push(component);
}
}
*components = ordered;
true
}
fn parse_flow_chain(hint: &str, components: &[FlowComponent]) -> Vec<usize> {
let normalized = hint.replace("-->", "\u{2192}").replace("->", "\u{2192}");
for line in normalized.lines() {
if !line.contains('\u{2192}') {
continue;
}
let mut chain: Vec<usize> = Vec::new();
for segment in line.split('\u{2192}') {
if let Some(index) = first_component_in(segment, components) {
push_unique(&mut chain, index);
}
}
if chain.len() >= 2 {
return chain;
}
}
Vec::new()
}
fn first_component_in(segment: &str, components: &[FlowComponent]) -> Option<usize> {
segment
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.map(normalize_key)
.filter(|key| !key.is_empty())
.find_map(|key| {
components
.iter()
.position(|component| component.keys.contains(&key))
})
}
fn push_unique(chain: &mut Vec<usize>, index: usize) {
if !chain.contains(&index) {
chain.push(index);
}
}
fn flow_label(name: &str) -> String {
name.rsplit(['/', ':'])
.next()
.unwrap_or(name)
.trim_end_matches(".rs")
.trim()
.to_string()
}
fn normalize_key(text: &str) -> String {
let last = text.rsplit(['/', ':']).next().unwrap_or(text);
let stem = last.split('.').next().unwrap_or(last);
stem.chars()
.filter(|c| c.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
fn role_phrase(summary: &str) -> Option<String> {
let summary = summary.trim();
if summary.is_empty() {
return None;
}
let clause = summary
.split_terminator(['.', ';', ':'])
.next()
.unwrap_or(summary)
.trim();
let phrase = clause
.split_whitespace()
.take(FLOW_ROLE_WORDS)
.collect::<Vec<_>>()
.join(" ");
(!phrase.is_empty()).then_some(phrase)
}
#[cfg(test)]
mod flow_tests {
use super::*;
fn module_doc(name: &str, summary: &str) -> ModuleDoc {
ModuleDoc {
module: name.to_string(),
summary: summary.to_string(),
source_spans: Vec::new(),
direct_files: Vec::new(),
child_modules: Vec::new(),
degraded: false,
degraded_sources: Vec::new(),
verify_notes: Vec::new(),
reused_page: None,
}
}
fn file_doc(path: &str, summary: &str) -> FileDoc {
FileDoc {
path: path.to_string(),
module: String::new(),
summary: summary.to_string(),
body: String::new(),
source_spans: Vec::new(),
symbols: Vec::new(),
component_ids: Vec::new(),
degraded: false,
degraded_sources: Vec::new(),
verify_notes: Vec::new(),
reused_page: None,
}
}
fn module_lookup(docs: &[ModuleDoc]) -> BTreeMap<&str, &ModuleDoc> {
docs.iter().map(|doc| (doc.module.as_str(), doc)).collect()
}
fn file_lookup(docs: &[FileDoc]) -> BTreeMap<&str, &FileDoc> {
docs.iter().map(|doc| (doc.path.as_str(), doc)).collect()
}
#[test]
fn verifier_evidence_preserves_curated_members_and_symbols() {
let members = [prompts::PageEvidenceRow {
name: "walker".to_string(),
kind: "module".to_string(),
citation: "[src/walker.rs:10-12]".to_string(),
summary: "Discovers candidate files.".to_string(),
}];
let symbols = [prompts::PageEvidenceRow {
name: "parse_plan".to_string(),
kind: "function".to_string(),
citation: "[src/plan.rs:42]".to_string(),
summary: "Parses the navigation JSON.".to_string(),
}];
let rows = verifier_evidence_rows(members.iter().chain(symbols.iter()));
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].name, "walker");
assert_eq!(rows[0].kind, "module");
assert_eq!(rows[0].component_label, "[src/walker.rs:10-12]");
assert_eq!((rows[0].line_start, rows[0].line_end), (10, 12));
assert_eq!(rows[0].purpose, "Discovers candidate files.");
assert_eq!(rows[1].name, "parse_plan");
assert_eq!(rows[1].component_label, "[src/plan.rs:42]");
assert_eq!((rows[1].line_start, rows[1].line_end), (42, 42));
}
#[test]
fn chains_member_modules_grounded_in_summaries() {
let modules = [
module_doc("walker", "Discovers candidate files. Walks the tree."),
module_doc("parser", "Extracts the AST via tree-sitter."),
];
let member_modules = vec!["walker".to_string(), "parser".to_string()];
let flow = curated_flow_diagram(
&member_modules,
&[],
&module_lookup(&modules),
&file_lookup(&[]),
&BTreeMap::new(),
);
let section = flow.expect("flow drawn for two members");
assert!(section.contains("## Conceptual flow"), "{section}");
assert!(section.contains("flowchart LR"), "{section}");
assert!(
section.contains("walker — Discovers candidate files"),
"{section}"
);
assert!(
section.contains("parser — Extracts the AST via tree-sitter"),
"{section}"
);
assert!(
section.contains("in the order these subsystems are grouped"),
"{section}"
);
}
#[test]
fn orders_by_documented_data_flow_when_present() {
let modules = [
module_doc(
"indexer",
"Writes hub rows. Pipeline: walker -> parser -> chunker -> indexer.",
),
module_doc("walker", "Discovers files."),
module_doc("parser", "Extracts the AST."),
module_doc("chunker", "Splits content for search."),
];
let member_modules = vec![
"indexer".to_string(),
"walker".to_string(),
"parser".to_string(),
"chunker".to_string(),
];
let flow = curated_flow_diagram(
&member_modules,
&[],
&module_lookup(&modules),
&file_lookup(&[]),
&BTreeMap::new(),
);
let section = flow.expect("flow drawn");
assert!(
section.contains("ordered by the data flow documented"),
"{section}"
);
assert!(
section.contains("s0[\"walker — Discovers files\"]"),
"{section}"
);
assert!(section.contains("s3[\"indexer"), "indexer last: {section}");
}
#[test]
fn marks_degraded_when_a_member_summary_is_missing() {
let modules = [
module_doc("walker", "Discovers files."),
module_doc("parser", ""),
];
let member_modules = vec!["walker".to_string(), "parser".to_string()];
let flow = curated_flow_diagram(
&member_modules,
&[],
&module_lookup(&modules),
&file_lookup(&[]),
&BTreeMap::new(),
);
let section = flow.expect("flow drawn");
assert!(section.contains("_Degraded:_"), "{section}");
assert!(
section.contains("s1[\"parser\"]"),
"name-only node: {section}"
);
}
#[test]
fn omitted_for_a_single_member() {
let modules = [module_doc("walker", "Discovers files.")];
let flow = curated_flow_diagram(
&["walker".to_string()],
&[],
&module_lookup(&modules),
&file_lookup(&[]),
&BTreeMap::new(),
);
assert!(flow.is_none());
}
#[test]
fn falls_back_to_files_without_enough_modules() {
let files = [
file_doc("src/bm25.rs", "Runs BM25 keyword search."),
file_doc("src/rrf.rs", "Fuses ranked results."),
];
let member_files = vec!["src/bm25.rs".to_string(), "src/rrf.rs".to_string()];
let flow = curated_flow_diagram(
&[],
&member_files,
&module_lookup(&[]),
&file_lookup(&files),
&BTreeMap::new(),
);
let section = flow.expect("flow drawn from files");
assert!(
section.contains("bm25 — Runs BM25 keyword search"),
"{section}"
);
assert!(section.contains("rrf — Fuses ranked results"), "{section}");
}
}