Skip to content

Commit 74a334e

Browse files
jeromechooclaude
andcommitted
Add snippetLength to webSearch and structured output to ask; bump to 0.3.0
Port the diffbot 3.1.0 / 0.2.3 additions from diffbot-python: - webSearch: snippetLength option (short, medium, long, full). full returns the whole stored page for url: lookups; the reranker always chunks, so full also sends rerank=none. - ask: responseFormat option, validated so a json_schema without the nested schema is rejected rather than silently ignored server-side. - askJson, jsonSchemaFormat, extractJson: constrained JSON answers with think-block and fence-tolerant parsing, a permissive-schema default instead of json_object, and a guard against the leaked tool call. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent e9c44aa commit 74a334e

9 files changed

Lines changed: 506 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ settings from the client you hand it.
5050
| `fetch` | global `fetch` | all |
5151
| `analyzeUrl` | `https://api.diffbot.com/v3` | `extract` |
5252
| `crawlerUrl` | `https://api.diffbot.com/v3/crawl` | `crawl`, `crawlListJobs`, `crawlGetJob`, `crawlDeleteJob` |
53-
| `llmUrl` | `https://llm.diffbot.com/rag/v1/chat/completions` | `ask` |
53+
| `llmUrl` | `https://llm.diffbot.com/rag/v1/chat/completions` | `ask`, `askJson` |
5454
| `webSearchUrl` | `https://llm.diffbot.com/api/v1/web_search` | `webSearch` |
5555
| `nlpUrl` | `https://nl.diffbot.com/v1/` | `entities` |
5656
| `dqlUrl` | `https://kg.diffbot.com/kg/v3/dql` | `dql`, `dqlParallel` |
@@ -101,6 +101,49 @@ for await (const chunk of ask(db, [{ role: "user", content: "What's the capital
101101
}
102102
```
103103

104+
### Structured output
105+
106+
Pass a JSON Schema to constrain the answer. The model is held to the schema by a grammar
107+
during decoding, so the result parses reliably even though the answer is retrieved live
108+
from the web.
109+
110+
```typescript
111+
import { askJson } from "@diffbot/typescript";
112+
113+
const schema = {
114+
type: "object",
115+
properties: { country: { type: "string" }, capital: { type: "string" } },
116+
required: ["country", "capital"],
117+
};
118+
const answer = await askJson<{ country: string; capital: string }>(
119+
db,
120+
[{ role: "user", content: "What's the capital of France?" }],
121+
schema,
122+
);
123+
console.log(answer.capital);
124+
```
125+
126+
Omit the schema to let the model choose the shape, or use `ask` with `responseFormat` to
127+
stream a constrained answer:
128+
129+
```typescript
130+
import { ask, askJson, jsonSchemaFormat } from "@diffbot/typescript";
131+
132+
const answer = await askJson(db, [{ role: "user", content: "What's the capital of France?" }]);
133+
134+
for await (const chunk of ask(db, [{ role: "user", content: "What's the capital of France?" }], {
135+
responseFormat: jsonSchemaFormat(schema),
136+
})) {
137+
process.stdout.write(chunk);
138+
}
139+
```
140+
141+
> **Avoid `responseFormat: { type: "json_object" }`.** The endpoint accepts it, but applies
142+
> no grammar to it: the RAG loop's internal tool call is itself a JSON object, so it can be
143+
> returned as the final answer. This is reproducible whenever the request includes a system
144+
> message. `askJson` therefore defaults to a permissive JSON Schema instead, and throws
145+
> `ValidationError` if it ever sees a tool call come back.
146+
104147
### Crawl a site
105148

106149
```typescript
@@ -128,6 +171,13 @@ const results = await webSearch(db, "diffbot knowledge graph");
128171
for (const r of results.search_results as Array<Record<string, unknown>>) {
129172
console.log(r.score, r.title, r.pageUrl);
130173
}
174+
175+
// snippetLength controls how much of each page comes back in "content":
176+
// "short" (~1-1.5 KB per result), "medium", "long" (default), or "full"
177+
const brief = await webSearch(db, "diffbot knowledge graph", { numResults: 5, snippetLength: "short" });
178+
179+
// "full" with a url: query returns the whole stored page
180+
const page = await webSearch(db, "url:https://en.wikipedia.org/wiki/Knowledge_graph", { snippetLength: "full" });
131181
```
132182

133183
### Caching the Knowledge Graph ontology
@@ -233,7 +283,10 @@ in CI against workerd with `compatibility_flags: []`.
233283
| `dql_parallel()` | `dqlParallel()` |
234284
| `dql_fetch_ontology()` | `dqlFetchOntology()` |
235285
| `dql_refresh_ontology()` | `new OntologyStore(db).load({ refresh: true })` (or `FileOntologyStore` from `@diffbot/typescript/node`) |
236-
| `web_search()` | `webSearch()` |
286+
| `web_search(snippet_length=...)` | `webSearch(db, text, { snippetLength })` |
287+
| `ask(response_format=...)` | `ask(db, messages, { responseFormat })` |
288+
| `ask_json()` | `askJson()` |
289+
| `json_schema_format()` | `jsonSchemaFormat()` |
237290

238291
## Development
239292

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@diffbot/typescript",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "TypeScript client library for Diffbot APIs",
55
"type": "module",
66
"packageManager": "pnpm@10.33.0",

‎src/ask.ts‎

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,146 @@
11
import type { DiffbotClient } from "./client.js";
2+
import { ValidationError } from "./errors.js";
23
import { readBodyText, raiseForStatus } from "./http.js";
4+
import type { JsonObject } from "./json.js";
35

46
export interface ChatMessage {
57
role: string;
68
content: string;
79
}
810

11+
const MODEL = "diffbot-small-xl";
12+
13+
/** `response_format` types accepted by the Diffbot LLM endpoint. */
14+
export const RESPONSE_FORMAT_TYPES = ["text", "json_object", "json_schema"] as const;
15+
export type ResponseFormatType = (typeof RESPONSE_FORMAT_TYPES)[number];
16+
17+
export interface ResponseFormat {
18+
type: ResponseFormatType;
19+
json_schema?: { name?: string; schema: JsonObject };
20+
}
21+
22+
export interface AskOptions {
23+
/**
24+
* Constrain the output, e.g. `{ type: "json_object" }` or `jsonSchemaFormat(schema)`.
25+
* Prefer a schema: `json_object` applies no server-side grammar.
26+
*/
27+
responseFormat?: ResponseFormat;
28+
}
29+
30+
/**
31+
* Build a `response_format` that constrains output to `schema`. The endpoint requires
32+
* the schema nested under `json_schema.schema`; anywhere else is ignored server-side
33+
* without an error, so prefer this helper over hand-building the object.
34+
*/
35+
export function jsonSchemaFormat(schema: JsonObject, name = "response"): ResponseFormat {
36+
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
37+
throw new ValidationError("schema must be a JSON Schema object");
38+
}
39+
return { type: "json_schema", json_schema: { name, schema } };
40+
}
41+
42+
/** Reject shapes the endpoint would accept but silently not enforce. */
43+
function validateResponseFormat(responseFormat: ResponseFormat | undefined): void {
44+
if (responseFormat === undefined) return;
45+
if (responseFormat === null || typeof responseFormat !== "object" || Array.isArray(responseFormat)) {
46+
throw new ValidationError("responseFormat must be an object");
47+
}
48+
const fmtType = responseFormat.type ?? "text";
49+
if (!(RESPONSE_FORMAT_TYPES as readonly string[]).includes(fmtType)) {
50+
throw new ValidationError(
51+
`responseFormat type must be one of ${RESPONSE_FORMAT_TYPES.join(", ")}; got ${JSON.stringify(fmtType)}`,
52+
);
53+
}
54+
if (fmtType === "json_schema") {
55+
const jsonSchema = responseFormat.json_schema;
56+
if (jsonSchema === null || typeof jsonSchema !== "object" || !("schema" in jsonSchema)) {
57+
throw new ValidationError(
58+
'responseFormat { type: "json_schema" } requires the schema nested as ' +
59+
"{ json_schema: { schema: {...} } }. Without it the server returns 200 and " +
60+
"ignores the constraint. Use jsonSchemaFormat(schema) to build it.",
61+
);
62+
}
63+
}
64+
}
65+
66+
// The RAG loop may prefix its final answer with a think block, and the model
67+
// occasionally wraps JSON in a markdown fence despite being told not to.
68+
const THINK_BLOCK = /<think>[\s\S]*?<\/think>/g;
69+
const JSON_FENCE = /^```(?:json)?\s*|\s*```$/gm;
70+
71+
/** Parse the model's final answer as JSON, tolerating think blocks and fences. */
72+
export function extractJson(text: string): unknown {
73+
let cleaned = text.replace(THINK_BLOCK, "").trim();
74+
cleaned = cleaned.replace(JSON_FENCE, "").trim();
75+
76+
try {
77+
return JSON.parse(cleaned);
78+
} catch {
79+
// fall through to span search
80+
}
81+
82+
// Fall back to the outermost object or array span in the response.
83+
const spans: Array<[number, string]> = [];
84+
for (const [opener, closer] of [
85+
["{", "}"],
86+
["[", "]"],
87+
] as const) {
88+
const start = cleaned.indexOf(opener);
89+
const end = cleaned.lastIndexOf(closer);
90+
if (start !== -1 && end > start) spans.push([start, cleaned.slice(start, end + 1)]);
91+
}
92+
spans.sort((a, b) => a[0] - b[0]);
93+
for (const [, span] of spans) {
94+
try {
95+
return JSON.parse(span);
96+
} catch {
97+
continue;
98+
}
99+
}
100+
101+
throw new ValidationError(
102+
`could not parse JSON from the model response: ${JSON.stringify(text.slice(0, 200))}`,
103+
);
104+
}
105+
106+
/**
107+
* Schema used when the caller wants JSON but has no shape in mind. This goes through
108+
* the json_schema path rather than `{ type: "json_object" }` on purpose: json_object
109+
* applies no server-side grammar, so the RAG loop's internal <functioncall> JSON
110+
* satisfies it and gets returned as the final answer. That is reproducible whenever
111+
* the request carries a system message.
112+
*/
113+
export const ANY_OBJECT_SCHEMA: JsonObject = { type: "object" };
114+
115+
function resolveFormat(
116+
schema: JsonObject | undefined,
117+
responseFormat: ResponseFormat | undefined,
118+
): ResponseFormat {
119+
if (schema !== undefined && responseFormat !== undefined) {
120+
throw new ValidationError("pass either schema or responseFormat, not both");
121+
}
122+
if (responseFormat !== undefined) return responseFormat;
123+
return jsonSchemaFormat(schema ?? ANY_OBJECT_SCHEMA);
124+
}
125+
126+
/** Catch the internal tool call surfacing as the answer (see ANY_OBJECT_SCHEMA). */
127+
function checkToolCallLeak<T>(parsed: T): T {
128+
if (
129+
parsed !== null &&
130+
typeof parsed === "object" &&
131+
!Array.isArray(parsed) &&
132+
(parsed as JsonObject).name === "functioncall" &&
133+
"arguments" in (parsed as JsonObject)
134+
) {
135+
throw new ValidationError(
136+
"the model returned its internal tool call instead of an answer; this happens with " +
137+
'responseFormat { type: "json_object" } because the server applies no grammar to it. ' +
138+
"Pass a schema instead.",
139+
);
140+
}
141+
return parsed;
142+
}
143+
9144
function parseChunk(line: string): string | undefined {
10145
try {
11146
const chunk = JSON.parse(line.replace(/^data: /, "")) as {
@@ -45,9 +180,12 @@ async function* linesFromStream(body: ReadableStream<Uint8Array>): AsyncGenerato
45180
export async function* ask(
46181
client: DiffbotClient,
47182
messages: ChatMessage[],
183+
options: AskOptions = {},
48184
): AsyncGenerator<string> {
185+
validateResponseFormat(options.responseFormat);
49186
const headers = { Authorization: `Bearer ${client.token}` };
50-
const payload = { model: "diffbot-small-xl", messages, stream: true };
187+
const payload: JsonObject = { model: MODEL, messages, stream: true };
188+
if (options.responseFormat !== undefined) payload.response_format = options.responseFormat;
51189

52190
const response = await client.http.post(client.llmUrl, { headers, json: payload });
53191
if (!response.ok) {
@@ -70,3 +208,20 @@ export async function* ask(
70208
if (content) yield content;
71209
}
72210
}
211+
212+
/**
213+
* Ask the Diffbot LLM and return the answer parsed as JSON. With `schema` (a JSON Schema
214+
* object) the model is constrained to it by grammar; without one it falls back to
215+
* unconstrained JSON output. Pass `responseFormat` instead of `schema` for full control.
216+
*/
217+
export async function askJson<T = unknown>(
218+
client: DiffbotClient,
219+
messages: ChatMessage[],
220+
schema?: JsonObject,
221+
options: AskOptions = {},
222+
): Promise<T> {
223+
const responseFormat = resolveFormat(schema, options.responseFormat);
224+
let text = "";
225+
for await (const chunk of ask(client, messages, { responseFormat })) text += chunk;
226+
return checkToolCallLeak(extractJson(text)) as T;
227+
}

‎src/constants.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const VERSION = "0.2.0";
1+
export const VERSION = "0.3.0";
22

33
export const DEFAULT_TIMEOUT = 30_000;
44

‎src/index.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
export { DiffbotClient, type DiffbotClientOptions } from "./client.js";
22
export { resolveTokenFromEnv, type TokenSource } from "./auth.js";
33
export { extract } from "./extract.js";
4-
export { ask, type ChatMessage } from "./ask.js";
4+
export {
5+
ANY_OBJECT_SCHEMA,
6+
RESPONSE_FORMAT_TYPES,
7+
ask,
8+
askJson,
9+
extractJson,
10+
jsonSchemaFormat,
11+
type AskOptions,
12+
type ChatMessage,
13+
type ResponseFormat,
14+
type ResponseFormatType,
15+
} from "./ask.js";
516
export {
617
crawl,
718
crawlDeleteJob,
@@ -27,7 +38,7 @@ export {
2738
type KVOntologyStoreOptions,
2839
type KVLike,
2940
} from "./ontology-store.js";
30-
export { webSearch } from "./web-search.js";
41+
export { webSearch, SNIPPET_LENGTHS, type SnippetLength, type WebSearchOptions } from "./web-search.js";
3142
export {
3243
DiffbotError,
3344
ValidationError,

‎src/web-search.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,43 @@
11
import type { DiffbotClient } from "./client.js";
2+
import { ValidationError } from "./errors.js";
23
import { readBodyText, raiseForStatus } from "./http.js";
34
import type { JsonObject } from "./json.js";
45

6+
/** How much of each page's matching content the web search returns in `content`. */
7+
export type SnippetLength = "short" | "medium" | "long" | "full";
8+
9+
export const SNIPPET_LENGTHS: readonly SnippetLength[] = ["short", "medium", "long", "full"];
10+
11+
export interface WebSearchOptions {
12+
numResults?: number;
13+
maxTokens?: number;
14+
/**
15+
* `short` keeps a result to ~1-1.5 KB; `long` is the API default; `full` returns the
16+
* whole stored page and is meant for `url:<URL>` queries. The reranker always chunks,
17+
* so `full` also sends `rerank=none` and query terms after the URL are ignored.
18+
*/
19+
snippetLength?: SnippetLength;
20+
}
21+
522
export async function webSearch(
623
client: DiffbotClient,
724
text: string,
8-
options: { numResults?: number; maxTokens?: number } = {},
25+
options: WebSearchOptions = {},
926
): Promise<JsonObject> {
1027
const headers = { Authorization: `Bearer ${client.token}` };
1128
const params: Record<string, string | number> = { text };
1229
if (options.numResults !== undefined) params.size = options.numResults;
1330
if (options.maxTokens !== undefined) params.maxTokens = options.maxTokens;
31+
if (options.snippetLength !== undefined) {
32+
const length = options.snippetLength.toLowerCase() as SnippetLength;
33+
if (!SNIPPET_LENGTHS.includes(length)) {
34+
throw new ValidationError(
35+
`snippetLength must be one of ${SNIPPET_LENGTHS.join(", ")}; got ${JSON.stringify(options.snippetLength)}`,
36+
);
37+
}
38+
params.snippetLength = length.toUpperCase();
39+
if (length === "full") params.rerank = "none";
40+
}
1441

1542
const response = await client.http.get(client.webSearchUrl, { headers, params });
1643
const body = await readBodyText(response);

0 commit comments

Comments
 (0)