Files
windmill/backend/windmill-api/src/docs/corpus.rs
T
9d61e4e59e feat: self-host docs search for chat, mcp, cli; drop inkeep (#9772)
* 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>
2026-06-25 16:32:07 +02:00

87 lines
3.1 KiB
Rust

//! The vendored documentation snapshot, embedded into the binary and parsed once.
//!
//! `docs_snapshot/*.gz` are refreshed by `docs_snapshot/fetch.sh`. Embedding them
//! lets docs search work with no runtime egress (including air-gapped instances).
use std::io::Read;
use std::sync::OnceLock;
use flate2::read::GzDecoder;
use super::search::{
canonical_docs_page_url, canonical_search_url, parse_docs_full_text, parse_docs_index,
DocsFullPage, DocsIndexEntry,
};
const LLMS_FULL_GZ: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs_snapshot/llms-full.txt.gz"));
const LLMS_INDEX_GZ: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs_snapshot/llms.txt.gz"));
pub struct DocsCorpus {
/// Every docs page, keyed by its `Source:` URL (from llms-full.txt).
pub pages: Vec<DocsFullPage>,
/// The curated page index with one-line descriptions (from llms.txt).
pub index: Vec<DocsIndexEntry>,
}
impl DocsCorpus {
/// Finds the page whose `Source:` URL matches a model/CLI-supplied path or URL
/// after canonicalization (origin re-anchored, `.md` and ordering prefixes
/// stripped).
pub fn find_page(&self, path: &str) -> Option<&DocsFullPage> {
let key = canonical_search_url(&canonical_docs_page_url(path));
self.pages.iter().find(|p| canonical_search_url(&p.url) == key)
}
}
static CORPUS: OnceLock<DocsCorpus> = OnceLock::new();
fn decompress(bytes: &[u8]) -> String {
let mut out = String::new();
if let Err(e) = GzDecoder::new(bytes).read_to_string(&mut out) {
// The embedded snapshot is valid gzip text; a decode failure is a
// build-time packaging error, so failing closed to an empty corpus
// (docs search returns "no matches") is acceptable.
tracing::error!("failed to decompress embedded docs snapshot: {e}");
return String::new();
}
out
}
/// Returns the parsed docs corpus, decompressing and parsing the embedded
/// snapshot once on first access.
pub fn corpus() -> &'static DocsCorpus {
CORPUS.get_or_init(|| {
let pages = parse_docs_full_text(&decompress(LLMS_FULL_GZ));
let index = parse_docs_index(&decompress(LLMS_INDEX_GZ));
DocsCorpus { pages, index }
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_corpus_parses_to_non_empty() {
let corpus = corpus();
assert!(corpus.pages.len() > 50, "expected many pages, got {}", corpus.pages.len());
assert!(corpus.index.len() > 50, "expected many index entries, got {}", corpus.index.len());
assert!(corpus
.pages
.iter()
.all(|p| p.url.starts_with("https://www.windmill.dev/docs/") && !p.body.is_empty()));
}
#[test]
fn find_page_matches_by_canonical_url() {
let corpus = corpus();
// A page that is expected to exist in the published docs.
let by_path = corpus.find_page("/docs/core_concepts/worker_groups");
let by_url = corpus.find_page("https://www.windmill.dev/docs/core_concepts/worker_groups.md");
assert!(by_path.is_some());
assert_eq!(by_path.map(|p| &p.url), by_url.map(|p| &p.url));
}
}