Files
windmill/backend/windmill-api/src/docs/mod.rs
T
centdix 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

125 lines
4.6 KiB
Rust

//! Self-hosted documentation search.
//!
//! The backend embeds a vendored docs snapshot (see [`corpus`]) and exposes two
//! read-only endpoints over it, so docs search works with no runtime egress:
//! - `GET /api/docs/search?query=...` — full-text + index search
//! - `GET /api/docs/page?url=...&section=...` — read one page (or a section)
//!
//! These back the AI chat `search_docs`/`read_docs_page` tools, the MCP
//! `searchDocs`/`readDocsPage` tools, and the `wmill docs` CLI. The routes are
//! nested behind the global authed service in `lib.rs`, so a valid token is
//! required but no workspace.
mod corpus;
mod search;
use axum::{extract::Query, routing::get, Json, Router};
use serde::{Deserialize, Serialize};
use windmill_common::error::JsonResult;
use search::DocsSearchResult;
pub fn global_service() -> Router {
Router::new()
.route("/search", get(search_docs))
.route("/page", get(read_docs_page))
}
#[derive(Deserialize)]
struct SearchQuery {
query: String,
}
#[derive(Serialize)]
struct SearchResponse {
/// Model-ready rendering of the results (the exact string the AI/MCP tool
/// returns). Built once here so every consumer is identical.
text: String,
/// Structured results for non-AI consumers (e.g. the CLI's pretty/`--json`).
results: Vec<DocsSearchResult>,
}
#[derive(Deserialize)]
struct PageQuery {
/// A page's `Source` URL (as returned by the docs search tool); a bare `/docs/...`
/// path is also accepted and canonicalized before lookup.
url: String,
section: Option<String>,
}
#[derive(Serialize)]
struct PageResponse {
text: String,
source_url: String,
}
async fn search_docs(Query(q): Query<SearchQuery>) -> JsonResult<SearchResponse> {
let query = q.query.trim().to_string();
if query.is_empty() {
return Ok(Json(SearchResponse {
text: "No search query was provided. Provide a `query` of one or more keywords."
.to_string(),
results: Vec::new(),
}));
}
// Lazy corpus init (gzip decompress + parse) and the per-query full-corpus scan
// are CPU-bound; keep them off the async runtime.
let (text, results) = tokio::task::spawn_blocking(move || {
let corpus = corpus::corpus();
// Body grep first (concrete content hits), then index titles/descriptions to
// surface named features body grep misses; merge dedupes by canonical URL.
let body = search::search_docs_pages(&corpus.pages, &query, 5);
let index = search::search_docs_index(&corpus.index, &query, 4);
let results = search::merge_docs_search_results(body, index, search::SEARCH_MAX_PAGES);
let text = search::format_docs_search_results(&query, &results);
(text, results)
})
.await
.map_err(|e| windmill_common::error::Error::InternalErr(format!("docs search task: {e}")))?;
Ok(Json(SearchResponse { text, results }))
}
async fn read_docs_page(Query(q): Query<PageQuery>) -> JsonResult<PageResponse> {
let url = q.url.trim();
if url.is_empty() {
return Ok(Json(PageResponse {
text: "No documentation page URL was provided. Provide a `url` — e.g. a `Source` URL returned by the docs search tool.".to_string(),
source_url: String::new(),
}));
}
let url = url.to_string();
let section = q.section.filter(|s| !s.trim().is_empty());
// Corpus init + page sanitize/render are CPU-bound; keep them off the runtime.
let resp = tokio::task::spawn_blocking(move || {
let corpus = corpus::corpus();
match corpus.find_page(&url) {
Some(page) => {
// Rewrite docusaurus source-file links to canonical published URLs
// so the model never echoes a broken `.mdx` path.
let sanitized = search::sanitize_docs_markdown_links(&page.body, &page.url);
let rendered = search::render_docs_page_result(&sanitized, section.as_deref());
let text = format!(
"Source page — cite this URL when referencing this page: {}\n\n{}",
page.url, rendered
);
PageResponse { text, source_url: page.url.clone() }
}
None => PageResponse {
text: format!(
"No documentation page found for \"{}\". Use the docs search tool to find the correct Source URL first.",
url
),
source_url: search::canonical_docs_page_url(&url),
},
}
})
.await
.map_err(|e| windmill_common::error::Error::InternalErr(format!("docs page task: {e}")))?;
Ok(Json(resp))
}