mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-29 16:15:51 +00:00
* feat: self-host docs search for chat, mcp and cli; remove inkeep
Embed a vendored docs snapshot (llms.txt/llms-full.txt) in the backend and
serve ranking + page rendering from GET /api/docs/{search,page}. The AI chat,
the MCP searchDocs/readDocsPage tools, and 'wmill docs' all consume it, so docs
search works with no runtime egress and is no longer EE-gated. Removes the
inkeep proxy. EE companion deletes inkeep_ee.rs (ee-repo-ref bumped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: name read_docs_page param `url` instead of `path`
search_docs returns each hit's `Source` URL, so the read tool now takes a
`url` argument to match — the AI/MCP loop reads "search gives a Source URL,
read takes that url" rather than copying a `Source:` URL into a `path` slot.
A bare `/docs/...` path is still accepted and canonicalized before lookup.
Regenerated openapi-deref, the MCP endpoint tools, and the frontend client.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ci: add scheduled workflow to refresh the vendored docs snapshot
The backend embeds docs_snapshot/*.gz at build time, so the in-product docs
corpus is otherwise only as fresh as the last manual fetch.sh run. This adds a
weekly (and manually dispatchable) job that re-runs fetch.sh, sanity-checks the
result against truncation/garbage, and opens a PR via the internal app when the
snapshot changed — so a human reviews the docs diff before it rides into the
next release build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor: make docs tool-result strings caller-neutral
The search/page endpoints back three differently-named consumers (the AI chat
`read_docs_page` tool, the MCP `readDocsPage` tool, and the `wmill docs` CLI),
so the shared rendered text shouldn't name one of them. Refer to "the docs
page-reading tool" and its `url` argument instead, and add tests pinning the
caller-neutral follow-up guidance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: point ee-repo-ref at inkeep-removal companion rebased on EE main
The companion branch now carries only the inkeep_ee.rs deletion on top of EE
main (was based on the native-job-retry EE line, which polluted the EE PR diff).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(docs): expose docs:read in token catalog; precompute lowercased corpus
Addresses two review nits on the self-hosted docs PR:
- docs:read was enforced (ScopeDomain::Docs) but missing from the token scope
catalog (token.rs ALL_SCOPES), so it couldn't be selected when creating a
standard scoped token in the UI — leaving scope-restricted CLI/MCP docs use
effectively ungrantable. Add a read-only "Documentation" group (no write
surface) and a test asserting it is exposed.
- search ran page.body.to_lowercase() on the whole corpus per query. Lowercase
body/title/description once at parse time (into the OnceLock corpus) and scan
the precomputed copies instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: update ee-repo-ref to 27a4f41b8e5603d6e444efcfc420bd1c44a07eed
This commit updates the EE repository reference after PR #630 was merged in windmill-ee-private.
Previous ee-repo-ref: c7ec3a0c2fa38d4cb5e50bf0265eef4710de4860
New ee-repo-ref: 27a4f41b8e5603d6e444efcfc420bd1c44a07eed
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
268 lines
11 KiB
Rust
268 lines
11 KiB
Rust
//! Parity tests ported from the frontend `copilot/chat/docs/core.test.ts`.
|
|
|
|
use super::*;
|
|
|
|
const SAMPLE: &str = "# Jobs\n\nIntro text about jobs.\n\n## Job kinds\n\nSome kinds.\n\n## Result\n\n### Result of jobs that failed\n\n```\n{ \"error\": \"boom\" }\n```\n\n### Result streaming\n\n#### Returning a stream directly\n\n```python\n# Returning a stream directly is a comment heading that must be ignored\ndef main():\n pass\n```\n\n## Retention policy\n\nFinal section.\n";
|
|
|
|
// Mirrors the llms-full.txt layout: a corpus preamble, then per-page blocks each
|
|
// introduced by a `---` + `## <Category>` lead-in followed by a `Source:` line.
|
|
const SAMPLE_FULL: &str = "# Windmill\n\n> Preamble blurb that precedes the first Source line and must be ignored.\n\n## Browser automation\n\nSource: https://www.windmill.dev/docs/advanced/browser_automation\n\n# Browser automation\n\nBy default, a worker group named `reports` handles jobs with the `chromium` tag.\nThe chromium binary will be available on these workers at /usr/bin/chromium.\nYou can disable the sandbox by passing the --no-sandbox flag.\n\n---\n\n## Worker groups\n\nSource: https://www.windmill.dev/docs/core_concepts/worker_groups\n\n# Worker groups\n\nWorker groups let you assign tags to workers.\nSet the chromium tag on a worker so it can run browser jobs.\n\n---\n\n## Scheduling\n\nSource: https://www.windmill.dev/docs/core_concepts/scheduling\n\n# Scheduling\n\nUse cron expressions to schedule scripts and flows.\n";
|
|
|
|
#[test]
|
|
fn parses_headings_and_ignores_fenced_blocks() {
|
|
let titles: Vec<String> = parse_docs_headings(SAMPLE)
|
|
.iter()
|
|
.map(|h| format!("{}:{}", h.level, h.title))
|
|
.collect();
|
|
assert_eq!(
|
|
titles,
|
|
vec![
|
|
"1:Jobs",
|
|
"2:Job kinds",
|
|
"2:Result",
|
|
"3:Result of jobs that failed",
|
|
"3:Result streaming",
|
|
"4:Returning a stream directly",
|
|
"2:Retention policy",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn heading_start_index_points_at_the_heading_line() {
|
|
for h in parse_docs_headings(SAMPLE) {
|
|
let at = &SAMPLE[h.start_index..];
|
|
assert!(at.starts_with(&"#".repeat(h.level)));
|
|
assert!(at[h.level..].trim_start().starts_with(&h.title));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn handles_tilde_fences() {
|
|
let content = "# Title\n\n~~~\n# not a heading\n~~~\n\n## Real\n";
|
|
let titles: Vec<String> = parse_docs_headings(content).iter().map(|h| h.title.clone()).collect();
|
|
assert_eq!(titles, vec!["Title", "Real"]);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_section_up_to_next_same_or_higher_heading() {
|
|
let section = extract_docs_section(SAMPLE, "Result").unwrap();
|
|
assert!(section.contains("## Result"));
|
|
assert!(section.contains("### Result of jobs that failed"));
|
|
assert!(section.contains("### Result streaming"));
|
|
assert!(!section.contains("## Retention policy"));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_section_is_case_and_punctuation_tolerant() {
|
|
let section = extract_docs_section(SAMPLE, "retention-policy!").unwrap();
|
|
assert!(section.contains("## Retention policy"));
|
|
assert!(section.contains("Final section."));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_section_returns_none_when_missing() {
|
|
assert!(extract_docs_section(SAMPLE, "Nonexistent section").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn build_outline_lists_headings_with_indent() {
|
|
let outline = build_docs_outline(SAMPLE);
|
|
assert!(outline.contains("- Jobs (~"));
|
|
assert!(outline.contains(" - Job kinds (~"));
|
|
assert!(outline.contains(" - Result of jobs that failed (~"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_outline_handles_no_headings() {
|
|
assert_eq!(
|
|
build_docs_outline("just some text\nwith no headings"),
|
|
"(no markdown headings found on this page)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_returns_whole_small_page() {
|
|
assert_eq!(render_docs_page_result(SAMPLE, None), SAMPLE);
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_returns_outline_for_large_page() {
|
|
let large = format!("# Big\n\n{}\n\n## Tail\n\nmore", "x".repeat(25_000));
|
|
let result = render_docs_page_result(&large, None);
|
|
assert!(result.contains("This documentation page is large"));
|
|
assert!(result.contains("same `url` argument"));
|
|
assert!(!result.contains("same path"));
|
|
assert!(!result.contains("read_docs_page"));
|
|
assert!(result.contains("- Big (~"));
|
|
assert!(result.contains("- Tail (~"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_returns_requested_section() {
|
|
let result = render_docs_page_result(SAMPLE, Some("Job kinds"));
|
|
assert!(result.contains("## Job kinds"));
|
|
assert!(result.contains("Some kinds."));
|
|
}
|
|
|
|
#[test]
|
|
fn render_page_missing_section_returns_outline_note() {
|
|
let result = render_docs_page_result(SAMPLE, Some("Does not exist"));
|
|
assert!(result.contains("No section matching \"Does not exist\" was found"));
|
|
assert!(result.contains("- Jobs (~"));
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_docs_url_cases() {
|
|
assert_eq!(normalize_docs_url("/docs/core_concepts/jobs"), "https://www.windmill.dev/docs/core_concepts/jobs.md");
|
|
assert_eq!(normalize_docs_url("docs/core_concepts/jobs"), "https://www.windmill.dev/docs/core_concepts/jobs.md");
|
|
assert_eq!(
|
|
normalize_docs_url("https://www.windmill.dev/docs/core_concepts/jobs#result?foo=bar"),
|
|
"https://www.windmill.dev/docs/core_concepts/jobs.md"
|
|
);
|
|
assert_eq!(normalize_docs_url("/docs/core_concepts/jobs.md"), "https://www.windmill.dev/docs/core_concepts/jobs.md");
|
|
assert_eq!(normalize_docs_url("/docs/core_concepts/jobs/"), "https://www.windmill.dev/docs/core_concepts/jobs.md");
|
|
assert_eq!(normalize_docs_url("/docs/flows/13_flow_branches"), "https://www.windmill.dev/docs/flows/flow_branches.md");
|
|
assert_eq!(normalize_docs_url("/docs/flows/13_flow_branches.mdx"), "https://www.windmill.dev/docs/flows/flow_branches.md");
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_docs_page_url_cases() {
|
|
assert_eq!(canonical_docs_page_url("/docs/flows/flow_editor"), "https://www.windmill.dev/docs/flows/flow_editor");
|
|
assert_eq!(canonical_docs_page_url("/docs/flows/14_retries.md"), "https://www.windmill.dev/docs/flows/retries");
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_markdown_links_cases() {
|
|
let page = "https://www.windmill.dev/docs/flows/flow_editor.md";
|
|
assert_eq!(
|
|
sanitize_docs_markdown_links("See [retries](./14_retries.mdx) for more.", page),
|
|
"See [retries](https://www.windmill.dev/docs/flows/retries) for more."
|
|
);
|
|
assert_eq!(
|
|
sanitize_docs_markdown_links("[handling](./8_error_handling.mdx)", page),
|
|
"[handling](https://www.windmill.dev/docs/flows/error_handling)"
|
|
);
|
|
assert_eq!(
|
|
sanitize_docs_markdown_links("[branch all](./13_flow_branches.mdx#branch-all)", page),
|
|
"[branch all](https://www.windmill.dev/docs/flows/flow_branches#branch-all)"
|
|
);
|
|
// images & external links untouched
|
|
let external = " and [site](https://example.com/page.md)";
|
|
assert_eq!(sanitize_docs_markdown_links(external, page), external);
|
|
// bare anchor untouched
|
|
assert_eq!(sanitize_docs_markdown_links("[top](#introduction)", page), "[top](#introduction)");
|
|
// ../ cross-directory links untouched
|
|
let parent = "[handling](../core_concepts/8_error_handling.mdx)";
|
|
assert_eq!(sanitize_docs_markdown_links(parent, page), parent);
|
|
let parent2 = "[retries](../../flows/14_retries.md)";
|
|
assert_eq!(sanitize_docs_markdown_links(parent2, page), parent2);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_full_text_splits_pages_and_drops_preamble() {
|
|
let pages = parse_docs_full_text(SAMPLE_FULL);
|
|
assert_eq!(
|
|
pages.iter().map(|p| p.url.clone()).collect::<Vec<_>>(),
|
|
vec![
|
|
"https://www.windmill.dev/docs/advanced/browser_automation",
|
|
"https://www.windmill.dev/docs/core_concepts/worker_groups",
|
|
"https://www.windmill.dev/docs/core_concepts/scheduling",
|
|
]
|
|
);
|
|
assert_eq!(
|
|
pages.iter().map(|p| p.title.clone()).collect::<Vec<_>>(),
|
|
vec!["Browser automation", "Worker groups", "Scheduling"]
|
|
);
|
|
// The next page's "## Worker groups" lead-in must not leak into this body.
|
|
let browser = pages.iter().find(|p| p.url.ends_with("/browser_automation")).unwrap();
|
|
assert!(!browser.body.contains("Worker groups"));
|
|
assert!(!browser.body.contains("---"));
|
|
}
|
|
|
|
#[test]
|
|
fn search_pages_ranks_more_occurrences_first() {
|
|
let pages = parse_docs_full_text(SAMPLE_FULL);
|
|
let results = search_docs_pages(&pages, "chromium", 5);
|
|
assert_eq!(
|
|
results.iter().map(|r| r.url.clone()).collect::<Vec<_>>(),
|
|
vec![
|
|
"https://www.windmill.dev/docs/advanced/browser_automation",
|
|
"https://www.windmill.dev/docs/core_concepts/worker_groups",
|
|
]
|
|
);
|
|
assert!(!results[0].snippets.is_empty());
|
|
assert!(results[0].snippets.join("\n").contains("chromium"));
|
|
}
|
|
|
|
#[test]
|
|
fn search_prefers_pages_covering_all_terms() {
|
|
let pages = parse_docs_full_text(SAMPLE_FULL);
|
|
// Only browser_automation contains "sandbox"; worker_groups has "chromium" but
|
|
// not "sandbox". A full-coverage page exists, so partial matches are dropped.
|
|
let results = search_docs_pages(&pages, "chromium sandbox", 5);
|
|
assert_eq!(
|
|
results.iter().map(|r| r.url.clone()).collect::<Vec<_>>(),
|
|
vec!["https://www.windmill.dev/docs/advanced/browser_automation"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_dedupes_index_against_body_by_canonical_url() {
|
|
let body = vec![DocsSearchResult {
|
|
url: "https://www.windmill.dev/docs/a".to_string(),
|
|
title: "A".to_string(),
|
|
score: 10,
|
|
snippets: vec![],
|
|
}];
|
|
let index = vec![
|
|
DocsSearchResult {
|
|
url: "https://www.windmill.dev/docs/a.md".to_string(),
|
|
title: "A".to_string(),
|
|
score: 5,
|
|
snippets: vec![],
|
|
},
|
|
DocsSearchResult {
|
|
url: "https://www.windmill.dev/docs/b.md".to_string(),
|
|
title: "B".to_string(),
|
|
score: 5,
|
|
snippets: vec![],
|
|
},
|
|
];
|
|
let merged = merge_docs_search_results(body, index, SEARCH_MAX_PAGES);
|
|
assert_eq!(
|
|
merged.iter().map(|r| r.url.clone()).collect::<Vec<_>>(),
|
|
vec!["https://www.windmill.dev/docs/a", "https://www.windmill.dev/docs/b.md"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_query_returns_no_results() {
|
|
let pages = parse_docs_full_text(SAMPLE_FULL);
|
|
assert!(search_docs_pages(&pages, " ", 5).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn format_search_results_no_matches() {
|
|
let out = format_docs_search_results("zzz", &[]);
|
|
assert!(out.contains("No documentation pages matched \"zzz\""));
|
|
}
|
|
|
|
#[test]
|
|
fn format_search_results_uses_caller_neutral_followup_guidance() {
|
|
let out = format_docs_search_results(
|
|
"jobs",
|
|
&[DocsSearchResult {
|
|
url: "https://www.windmill.dev/docs/core_concepts/jobs".to_string(),
|
|
title: "Jobs".to_string(),
|
|
score: 1,
|
|
snippets: vec!["Jobs run scripts and flows.".to_string()],
|
|
}],
|
|
);
|
|
|
|
assert!(out.contains("Source: https://www.windmill.dev/docs/core_concepts/jobs"));
|
|
assert!(out.contains("Source URL as its `url` argument"));
|
|
assert!(!out.contains("read_docs_page"));
|
|
assert!(!out.contains("readDocsPage"));
|
|
}
|