Skip to content

Commit cba6c55

Browse files
Merge pull request #466 from datadog-labs/feat/docs-ai-command
feat(docs): add pup docs ask command for Datadog Docs AI
2 parents 2e7f7a0 + 89cc481 commit cba6c55

4 files changed

Lines changed: 370 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ Thumbs.db
1919
# Config files with potential secrets
2020
.env
2121

22+
# HTTP Archive files (captured browser traffic — may contain session cookies)
23+
*.har
24+
2225
# Test harness outputs (generated artifacts — written to /tmp/pup-dev/)
2326
scripts/__pycache__
2427
.env.local

src/commands/docs.rs

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
use anyhow::Result;
2+
use std::io::Write;
3+
4+
const DOCS_AI_URL: &str = "https://app.datadoghq.com/api/unstable/docs-ai/chat";
5+
// Public key embedded in the Datadog docs site JS — not a user credential.
6+
const DOCS_AI_API_KEY: &str = "ddpub_docsai_nkbIDfPWw4pKuRlLef8aDs2onVqdimFI";
7+
8+
// Allows test overrides without changing the function signature (mirrors PUP_MOCK_SERVER).
9+
fn endpoint_url() -> String {
10+
std::env::var("PUP_DOCS_AI_URL").unwrap_or_else(|_| DOCS_AI_URL.to_string())
11+
}
12+
13+
/// Ask the Datadog Docs AI a question and stream the response to `out`.
14+
///
15+
/// `out` is injected so callers can redirect output (pass `&mut std::io::stdout()`
16+
/// for normal use) and tests can capture or control write failures.
17+
#[cfg(not(target_arch = "wasm32"))]
18+
pub async fn ask(question: &str, out: &mut impl Write) -> Result<()> {
19+
use futures::StreamExt;
20+
21+
if question.trim().is_empty() {
22+
anyhow::bail!("question cannot be empty");
23+
}
24+
25+
let conversation_id = format!("dd_docsai_{}", uuid::Uuid::new_v4());
26+
27+
let body = serde_json::json!({
28+
"data": {
29+
"attributes": {
30+
"query": question,
31+
"conversation_id": conversation_id,
32+
"anchor_url": "https://docs.datadoghq.com/",
33+
"rewrite_query": true
34+
}
35+
}
36+
});
37+
38+
let client = reqwest::Client::builder()
39+
.timeout(std::time::Duration::from_secs(120))
40+
.connect_timeout(std::time::Duration::from_secs(10))
41+
.build()
42+
.map_err(|e| anyhow::anyhow!("Failed to build HTTP client: {e}"))?;
43+
44+
let resp = client
45+
.post(endpoint_url())
46+
.header("Content-Type", "application/json")
47+
.header("Accept", "text/event-stream")
48+
.header("x-docs-ai-api-key", DOCS_AI_API_KEY)
49+
.header("User-Agent", crate::useragent::get())
50+
.json(&body)
51+
.send()
52+
.await
53+
.map_err(|e| anyhow::anyhow!("Docs AI request failed: {e}"))?;
54+
55+
if !resp.status().is_success() {
56+
let status = resp.status();
57+
let err_body = match resp.text().await {
58+
Ok(b) => b,
59+
Err(e) => format!("<failed to read error body: {e}>"),
60+
};
61+
anyhow::bail!("Docs AI error (HTTP {status}): {err_body}");
62+
}
63+
64+
let mut buffer = String::new();
65+
let mut bytes_stream = resp.bytes_stream();
66+
let mut saw_done = false;
67+
68+
// SSE framing follows the same pattern as bits.rs: accumulate chunks,
69+
// split on \n\n event boundaries, strip "data: " prefix per line.
70+
'outer: while let Some(chunk_result) = bytes_stream.next().await {
71+
let chunk = chunk_result.map_err(|e| anyhow::anyhow!("Stream read error: {e}"))?;
72+
buffer.push_str(&String::from_utf8_lossy(&chunk));
73+
74+
while let Some(end) = buffer.find("\n\n") {
75+
let event_block = buffer[..end].to_string();
76+
buffer = buffer[end + 2..].to_string();
77+
78+
for line in event_block.lines() {
79+
let Some(data_str) = line.strip_prefix("data: ") else {
80+
continue;
81+
};
82+
if data_str.trim() == "[DONE]" {
83+
saw_done = true;
84+
break 'outer;
85+
}
86+
if let Some(text) = extract_sse_content(data_str) {
87+
if !emit(out, &text)? {
88+
return Ok(());
89+
}
90+
}
91+
}
92+
}
93+
}
94+
95+
// Drain any remaining buffer when the stream ends without [DONE].
96+
for line in buffer.lines() {
97+
let Some(data_str) = line.strip_prefix("data: ") else {
98+
continue;
99+
};
100+
if data_str.trim() == "[DONE]" {
101+
saw_done = true;
102+
} else if let Some(text) = extract_sse_content(data_str) {
103+
if !emit(out, &text)? {
104+
return Ok(());
105+
}
106+
}
107+
}
108+
109+
if !saw_done {
110+
eprintln!(
111+
"Warning: Docs AI stream ended without a completion signal — the response may be truncated."
112+
);
113+
}
114+
writeln!(out).map_err(|e| anyhow::anyhow!("Failed to write Docs AI response: {e}"))?;
115+
Ok(())
116+
}
117+
118+
/// Write `text` to `out` and flush. Returns `Ok(false)` on `BrokenPipe` (caller
119+
/// should stop and return `Ok(())`), `Ok(true)` on success, `Err` on other I/O errors.
120+
fn emit(out: &mut impl Write, text: &str) -> Result<bool> {
121+
if let Err(e) = writeln!(out, "{text}") {
122+
if e.kind() == std::io::ErrorKind::BrokenPipe {
123+
return Ok(false);
124+
}
125+
return Err(anyhow::anyhow!("Failed to write Docs AI response: {e}"));
126+
}
127+
if let Err(e) = out.flush() {
128+
if e.kind() == std::io::ErrorKind::BrokenPipe {
129+
return Ok(false);
130+
}
131+
return Err(anyhow::anyhow!("Failed to write Docs AI response: {e}"));
132+
}
133+
Ok(true)
134+
}
135+
136+
/// Extract text content from a single SSE data line.
137+
///
138+
/// Tries multiple envelope shapes in priority order. Returns None for non-JSON
139+
/// frames, metadata events, or shapes that carry no printable content.
140+
pub(crate) fn extract_sse_content(data_str: &str) -> Option<String> {
141+
// Non-JSON lines are intentionally skipped — SSE streams include comment lines,
142+
// heartbeat pings, and control frames (e.g. "event: ping") that are not JSON.
143+
let val = serde_json::from_str::<serde_json::Value>(data_str).ok()?;
144+
let text = val
145+
.pointer("/data/attributes/content")
146+
.or_else(|| val.pointer("/attributes/content"))
147+
.or_else(|| val.get("content"))
148+
.or_else(|| val.get("text"))
149+
.and_then(|v| v.as_str())?;
150+
Some(text.to_string())
151+
}
152+
153+
// ---------------------------------------------------------------------------
154+
// WASM stub
155+
// ---------------------------------------------------------------------------
156+
157+
#[cfg(target_arch = "wasm32")]
158+
pub async fn ask(_question: &str, _out: &mut impl Write) -> Result<()> {
159+
anyhow::bail!("docs ask is not supported in WASM builds")
160+
}
161+
162+
#[cfg(test)]
163+
mod tests {
164+
use super::*;
165+
166+
// --- extract_sse_content unit tests ---
167+
168+
#[test]
169+
fn extract_nested_data_attributes_content() {
170+
let json = r#"{"data":{"attributes":{"content":"hello world"}}}"#;
171+
assert_eq!(extract_sse_content(json).as_deref(), Some("hello world"));
172+
}
173+
174+
#[test]
175+
fn extract_attributes_content() {
176+
let json = r#"{"attributes":{"content":"from attributes"}}"#;
177+
assert_eq!(
178+
extract_sse_content(json).as_deref(),
179+
Some("from attributes")
180+
);
181+
}
182+
183+
#[test]
184+
fn extract_flat_content_field() {
185+
let json = r#"{"content":"flat content"}"#;
186+
assert_eq!(extract_sse_content(json).as_deref(), Some("flat content"));
187+
}
188+
189+
#[test]
190+
fn extract_flat_text_field() {
191+
let json = r#"{"text":"flat text"}"#;
192+
assert_eq!(extract_sse_content(json).as_deref(), Some("flat text"));
193+
}
194+
195+
#[test]
196+
fn extract_returns_none_for_non_json() {
197+
assert_eq!(extract_sse_content("not json"), None);
198+
assert_eq!(extract_sse_content(""), None);
199+
}
200+
201+
#[test]
202+
fn extract_returns_none_when_no_content_field() {
203+
assert_eq!(extract_sse_content(r#"{"data":{"attributes":{}}}"#), None);
204+
assert_eq!(extract_sse_content(r#"{"other":"value"}"#), None);
205+
}
206+
207+
// --- ask() integration tests via mockito ---
208+
209+
#[cfg(not(target_arch = "wasm32"))]
210+
#[tokio::test]
211+
async fn ask_streams_sse_content_to_out() {
212+
let _guard = crate::test_utils::ENV_LOCK.lock().await;
213+
214+
let mut server = mockito::Server::new_async().await;
215+
let sse_body = concat!(
216+
"data: {\"content\":\"Hello\"}\n\n",
217+
"data: {\"content\":\", world\"}\n\n",
218+
"data: [DONE]\n\n",
219+
);
220+
let _mock = server
221+
.mock("POST", "/api/unstable/docs-ai/chat")
222+
.with_status(200)
223+
.with_header("content-type", "text/event-stream")
224+
.with_body(sse_body)
225+
.create_async()
226+
.await;
227+
228+
std::env::set_var(
229+
"PUP_DOCS_AI_URL",
230+
format!("{}/api/unstable/docs-ai/chat", server.url()),
231+
);
232+
let mut buf = Vec::new();
233+
let result = ask("what is a monitor?", &mut buf).await;
234+
std::env::remove_var("PUP_DOCS_AI_URL");
235+
236+
result.expect("ask() should succeed");
237+
assert!(
238+
buf.starts_with(b"Hello\n, world\n"),
239+
"output should contain streamed content, one event per line"
240+
);
241+
}
242+
243+
#[cfg(not(target_arch = "wasm32"))]
244+
#[tokio::test]
245+
async fn ask_returns_error_on_non_200() {
246+
let _guard = crate::test_utils::ENV_LOCK.lock().await;
247+
248+
let mut server = mockito::Server::new_async().await;
249+
let _mock = server
250+
.mock("POST", "/api/unstable/docs-ai/chat")
251+
.with_status(429)
252+
.with_header("content-type", "application/json")
253+
.with_body(r#"{"error":"rate limited"}"#)
254+
.create_async()
255+
.await;
256+
257+
std::env::set_var(
258+
"PUP_DOCS_AI_URL",
259+
format!("{}/api/unstable/docs-ai/chat", server.url()),
260+
);
261+
let result = ask("what is a monitor?", &mut std::io::sink()).await;
262+
std::env::remove_var("PUP_DOCS_AI_URL");
263+
264+
let err = result.expect_err("ask() should fail on HTTP 429");
265+
let msg = err.to_string();
266+
assert!(msg.contains("429"), "error should mention HTTP status");
267+
assert!(
268+
msg.contains("rate limited"),
269+
"error should include the response body"
270+
);
271+
}
272+
273+
#[cfg(not(target_arch = "wasm32"))]
274+
#[tokio::test]
275+
async fn ask_rejects_empty_question() {
276+
let result = ask("", &mut std::io::sink()).await;
277+
assert!(result.is_err());
278+
assert!(result
279+
.unwrap_err()
280+
.to_string()
281+
.contains("question cannot be empty"));
282+
}
283+
284+
#[cfg(not(target_arch = "wasm32"))]
285+
#[tokio::test]
286+
async fn ask_rejects_whitespace_only_question() {
287+
let result = ask(" ", &mut std::io::sink()).await;
288+
assert!(result.is_err());
289+
assert!(result
290+
.unwrap_err()
291+
.to_string()
292+
.contains("question cannot be empty"));
293+
}
294+
295+
#[cfg(not(target_arch = "wasm32"))]
296+
#[tokio::test]
297+
async fn ask_handles_broken_pipe_gracefully() {
298+
let _guard = crate::test_utils::ENV_LOCK.lock().await;
299+
300+
let mut server = mockito::Server::new_async().await;
301+
let _mock = server
302+
.mock("POST", "/api/unstable/docs-ai/chat")
303+
.with_status(200)
304+
.with_header("content-type", "text/event-stream")
305+
.with_body("data: {\"content\":\"Hello\"}\n\ndata: [DONE]\n\n")
306+
.create_async()
307+
.await;
308+
309+
std::env::set_var(
310+
"PUP_DOCS_AI_URL",
311+
format!("{}/api/unstable/docs-ai/chat", server.url()),
312+
);
313+
let result = ask("what is a monitor?", &mut BrokenPipeWriter).await;
314+
std::env::remove_var("PUP_DOCS_AI_URL");
315+
316+
assert!(result.is_ok(), "broken pipe should be silently handled");
317+
}
318+
319+
/// A writer whose flush always returns BrokenPipe, simulating a closed pipe.
320+
struct BrokenPipeWriter;
321+
322+
impl Write for BrokenPipeWriter {
323+
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
324+
Ok(buf.len())
325+
}
326+
fn flush(&mut self) -> std::io::Result<()> {
327+
Err(std::io::Error::new(
328+
std::io::ErrorKind::BrokenPipe,
329+
"broken pipe",
330+
))
331+
}
332+
}
333+
}

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub mod dbm;
3131
pub mod ddsql;
3232
pub mod debugger;
3333
pub mod deployment_gates;
34+
pub mod docs;
3435
pub mod downtime;
3536
pub mod error_tracking;
3637
pub mod events;

0 commit comments

Comments
 (0)