mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 00:06:06 +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>
87 lines
3.1 KiB
Rust
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));
|
|
}
|
|
}
|