From 33d05928743a2bfed4c6f9bc518a7b5fc650a2a0 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 31 Aug 2026 17:53:10 -0700 Subject: [PATCH 01/29] chore(cli): prepare first public npm release The package metadata still named an unavailable scope and lacked source and release guidance, so the verified tarball could not be published or reproduced cleanly. Set the owned @wcallahan scope and 0.0.1 version, add repository metadata and development and release instructions, and keep lock metadata aligned. - Document source, tarball dogfood, and the manual npm publication boundary --- README.md | 15 +++++++++ cli/README.md | 75 ++++++++++++++++++++++++++++++++++++++++++- cli/package-lock.json | 8 ++--- cli/package.json | 12 +++++-- 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a5206115..c1e4201b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,21 @@ make dev # bootstraps missing schemas only on loopback Qdrant Open `http://localhost:8085/`. +### CLI development + +The CLI is its own npm package under `cli/`. Install it and run the source entrypoint from that +directory: + +```bash +cd cli +npm install +npm run dev -- --help +npm run dev -- --host http://localhost:8085 "How do Java records work?" +``` + +See the **[CLI README](cli/README.md)** for authentication, production dogfood, packaged-artifact +checks, and the npm release procedure. + ## Index documentation (RAG) ```bash diff --git a/cli/README.md b/cli/README.md index 9f6649ee..48bdf122 100644 --- a/cli/README.md +++ b/cli/README.md @@ -5,7 +5,7 @@ Ask JavaChat from a terminal with the same retrieval and citations as the web ap ## Install ```bash -npm install --global @williamcallahan/javachat-cli +npm install --global @wcallahan/javachat-cli javachat login javachat "How do Java records work?" ``` @@ -28,3 +28,76 @@ JavaChat deployment. total chunk counts). Run `javachat --help` for options. + +## Develop locally + +The CLI has no runtime dependencies. Node.js 24.18 or newer and npm are sufficient: + +```bash +cd cli +npm install +npm run dev -- --version +npm run dev -- --help +``` + +Everything after `--` is passed to the CLI. It targets `https://javachat.ai` by default. Use +`--host` for another deployment: + +```bash +npm run dev -- --host http://localhost:8085 --help +npm run dev -- --host https://dev.javachat.ai --help +npm run dev -- --host https://javachat.ai --help +``` + +Credentials are stored separately for each host. Sign in to the host you intend to test: + +```bash +npm run dev -- --host https://javachat.ai login +npm run dev -- --host https://javachat.ai whoami +``` + +For headless environments, set `JAVACHAT_API_KEY` instead of running `login`. Do not put API keys +in command arguments, package scripts, or committed files. + +## Dogfood the CLI + +Exercise the inventory and a fresh, citation-backed answer against the target deployment: + +```bash +npm run dev -- --host https://javachat.ai knowledge +npm run dev -- --host https://javachat.ai --new --verbose \ + "Compare HikariCP 7.0.5 with Spring AI 1.1.5 and name every indexed version used as evidence." +``` + +Then verify the package that npm would install, not only the source entrypoint: + +```bash +npm test +npm run pack:check +npm pack +npm install --global ./wcallahan-javachat-cli-0.0.1.tgz +javachat --version +javachat --host https://javachat.ai whoami +javachat --host https://javachat.ai knowledge +``` + +The tarball-installed `javachat` command uses the same per-host credential file as the source +entrypoint. + +## Publish the first npm release + +Package versions on npm are immutable. Confirm the prepared version and packed contents before +publishing from `cli/`: + +```bash +npm test +npm run pack:check +npm whoami +npm publish --access public +npm view @wcallahan/javachat-cli@0.0.1 version +``` + +Publishing a scoped public package requires control of the `wcallahan` npm scope and npm's current +two-factor authentication or granular access requirements. If `npm whoami` is not `wcallahan`, sign +in with `npm login` before publishing. Do not publish `0.0.1` until every source and tarball dogfood +check above passes; any correction after publication must use a new version. diff --git a/cli/package-lock.json b/cli/package-lock.json index 3315ba7e..ec1788ce 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@williamcallahan/javachat-cli", - "version": "0.1.0", + "name": "@wcallahan/javachat-cli", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@williamcallahan/javachat-cli", - "version": "0.1.0", + "name": "@wcallahan/javachat-cli", + "version": "0.0.1", "license": "SEE LICENSE IN LICENSE.md", "os": [ "darwin", diff --git a/cli/package.json b/cli/package.json index 4ff86c3b..2f729e48 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { - "name": "@williamcallahan/javachat-cli", - "version": "0.1.0", + "name": "@wcallahan/javachat-cli", + "version": "0.0.1", "description": "Ask Java Chat from any terminal, with the same retrieval and citations as javachat.ai.", "license": "SEE LICENSE IN LICENSE.md", "bin": { @@ -18,7 +18,15 @@ "publishConfig": { "access": "public" }, + "repository": { + "type": "git", + "url": "git+https://github.com/WilliamAGH/java-chat.git", + "directory": "cli" + }, + "homepage": "https://github.com/WilliamAGH/java-chat/tree/dev/cli#readme", + "bugs": "https://github.com/WilliamAGH/java-chat/issues", "scripts": { + "dev": "node bin/javachat.js", "test": "node --test", "pack:check": "npm pack --dry-run" }, From 97d00cc160b19b80f35ae80d8c7b20b736848438 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 31 Aug 2026 23:18:23 -0700 Subject: [PATCH 02/29] feat(cli): explicit ask and auth command hierarchy Make the public CLI grammar predictable before the first npm release. Route authentication through one nested command surface and document it everywhere users encounter the CLI. - require javachat ask for questions - add auth login, logout, and status - cover nested parsing and credential behavior --- README.md | 2 +- cli/README.md | 24 ++-- cli/bin/javachat.js | 62 +++++--- cli/test/javachat.test.js | 135 +++++++++++++++--- .../components/CliAuthorizationPage.svelte | 2 +- .../components/CliAuthorizationPage.test.ts | 2 +- 6 files changed, 167 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index c1e4201b..63708315 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ directory: cd cli npm install npm run dev -- --help -npm run dev -- --host http://localhost:8085 "How do Java records work?" +npm run dev -- --host http://localhost:8085 ask "How do Java records work?" ``` See the **[CLI README](cli/README.md)** for authentication, production dogfood, packaged-artifact diff --git a/cli/README.md b/cli/README.md index 48bdf122..03fe6def 100644 --- a/cli/README.md +++ b/cli/README.md @@ -6,11 +6,11 @@ Ask JavaChat from a terminal with the same retrieval and citations as the web ap ```bash npm install --global @wcallahan/javachat-cli -javachat login -javachat "How do Java records work?" +javachat auth login +javachat ask "How do Java records work?" ``` -`javachat login` opens JavaChat in a browser. After sign-in and approval, the CLI stores the +`javachat auth login` opens JavaChat in a browser. After sign-in and approval, the CLI stores the personal Clerk API key in `$XDG_CONFIG_HOME/javachat/credentials.json` (or `~/.config/javachat/credentials.json`) with POSIX owner-only permissions. The package supports macOS and Linux. @@ -20,9 +20,9 @@ JavaChat deployment. ## Commands -- `javachat "question"` — ask a question with the same retrieval and citations as the website. -- `javachat login` / `javachat logout` — authorize or remove this machine's credential. -- `javachat whoami` — show who the stored key belongs to. +- `javachat ask "question"` — ask a question with the same retrieval and citations as the website. +- `javachat auth login` / `javachat auth logout` — authorize or remove this machine's credential. +- `javachat auth status` — show who the stored key belongs to. - `javachat knowledge` — list the document groups ingested in the knowledge base (documentation sets, books, articles, PDFs, and indexed GitHub repositories, with per-group and total chunk counts). @@ -52,12 +52,12 @@ npm run dev -- --host https://javachat.ai --help Credentials are stored separately for each host. Sign in to the host you intend to test: ```bash -npm run dev -- --host https://javachat.ai login -npm run dev -- --host https://javachat.ai whoami +npm run dev -- --host https://javachat.ai auth login +npm run dev -- --host https://javachat.ai auth status ``` -For headless environments, set `JAVACHAT_API_KEY` instead of running `login`. Do not put API keys -in command arguments, package scripts, or committed files. +For headless environments, set `JAVACHAT_API_KEY` instead of running `auth login`. Do not put API +keys in command arguments, package scripts, or committed files. ## Dogfood the CLI @@ -65,7 +65,7 @@ Exercise the inventory and a fresh, citation-backed answer against the target de ```bash npm run dev -- --host https://javachat.ai knowledge -npm run dev -- --host https://javachat.ai --new --verbose \ +npm run dev -- --host https://javachat.ai --new --verbose ask \ "Compare HikariCP 7.0.5 with Spring AI 1.1.5 and name every indexed version used as evidence." ``` @@ -77,7 +77,7 @@ npm run pack:check npm pack npm install --global ./wcallahan-javachat-cli-0.0.1.tgz javachat --version -javachat --host https://javachat.ai whoami +javachat --host https://javachat.ai auth status javachat --host https://javachat.ai knowledge ``` diff --git a/cli/bin/javachat.js b/cli/bin/javachat.js index afe583e3..0edfd239 100755 --- a/cli/bin/javachat.js +++ b/cli/bin/javachat.js @@ -254,7 +254,9 @@ async function commandLogin(host, options) { } const storedHosts = await readCredentials(); if (storedHosts[host]?.apiKey) { - stderr.write(`Already signed in to ${host}. Run "javachat logout" before replacing the key.\n`); + stderr.write( + `Already signed in to ${host}. Run "javachat auth logout" before replacing the key.\n`, + ); return 0; } const apiKey = await authorizeThroughBrowser(host, !options.noBrowser); @@ -271,7 +273,7 @@ async function commandLogin(host, options) { async function commandLogout(host) { if (env.JAVACHAT_API_KEY?.trim()) { stderr.write( - `Authentication for ${host} comes from JAVACHAT_API_KEY. Unset it, then run "javachat logout" again to remove any stored fallback credential.\n`, + `Authentication for ${host} comes from JAVACHAT_API_KEY. Unset it, then run "javachat auth logout" again to remove any stored fallback credential.\n`, ); return 0; } @@ -320,10 +322,10 @@ async function fetchIdentity(host, apiKey) { return identity.userId; } -async function commandWhoami(host) { +async function commandStatus(host) { const apiKey = await storedKeyFor(host); if (!apiKey) { - stderr.write(`Not signed in to ${host}. Run "javachat login".\n`); + stderr.write(`Not signed in to ${host}. Run "javachat auth login".\n`); return 1; } stdout.write(`${await fetchIdentity(host, apiKey)} at ${host}\n`); @@ -340,7 +342,7 @@ async function commandWhoami(host) { async function commandKnowledge(host) { const apiKey = await storedKeyFor(host); if (!apiKey) { - stderr.write(`Not signed in to ${host}. Run "javachat login".\n`); + stderr.write(`Not signed in to ${host}. Run "javachat auth login".\n`); return 1; } const groupsResponse = await fetch(new URL("/api/knowledge/groups", host), { @@ -499,7 +501,7 @@ async function commandAsk(host, question, options) { const allHosts = environmentApiKey ? {} : await readCredentials(); const apiKey = environmentApiKey || allHosts[host]?.apiKey; if (!apiKey) { - throw new Error(`Not signed in to ${host}. Run "javachat login" first.`); + throw new Error(`Not signed in to ${host}. Run "javachat auth login" first.`); } const storedSessionId = allHosts[host]?.sessionId; const sessionId = @@ -618,10 +620,10 @@ function decodeFrame(frame) { const USAGE = `javachat — ask Java Chat from your terminal Usage - javachat "How do Java records work?" Ask a question - javachat login Authorize this machine in your browser - javachat logout Remove the local credential - javachat whoami Show who the stored key belongs to + javachat ask "How do records work?" Ask a question + javachat auth login Authorize this machine in your browser + javachat auth logout Remove the local credential + javachat auth status Show who the stored key belongs to javachat knowledge List the document groups ingested in the knowledge base Options @@ -637,11 +639,10 @@ Environment JAVACHAT_HOST Default host when --host is absent `; -const SUBCOMMANDS = new Map([ +const AUTH_COMMANDS = new Map([ ["login", commandLogin], ["logout", commandLogout], - ["whoami", commandWhoami], - ["knowledge", commandKnowledge], + ["status", commandStatus], ]); function parseArguments(rawArguments) { @@ -676,9 +677,7 @@ function parseArguments(rawArguments) { throw new Error(`Unknown option: ${argument}. Run "javachat --help" for usage.`); } else { positional.push(argument); - const questionStarted = - (!SUBCOMMANDS.has(positional[0]) && positional[0] !== "ask" && positional[0] !== "help") || - (positional[0] === "ask" && positional.length > 1); + const questionStarted = positional[0] === "ask" && positional.length > 1; if (questionStarted) { positional.push(...rawArguments.slice(index + 1)); break; @@ -704,17 +703,34 @@ async function main() { stdout.write(USAGE); return options.help || first ? 0 : 1; } - const subcommand = SUBCOMMANDS.get(first); - if (subcommand) { + if (first === "auth") { + const [authCommandName, ...authArguments] = rest; + if (!authCommandName) { + throw new Error("javachat auth requires a command: login, logout, or status."); + } + const authCommand = AUTH_COMMANDS.get(authCommandName); + if (!authCommand) { + throw new Error( + `Unknown auth command: ${authCommandName}. Run "javachat --help" for usage.`, + ); + } + if (authArguments.length > 0) { + throw new Error(`javachat auth ${authCommandName} takes no arguments.`); + } + return await authCommand(normalizedHost, options); + } + if (first === "knowledge") { if (rest.length > 0) { - throw new Error(`javachat ${first} takes no arguments.`); + throw new Error("javachat knowledge takes no arguments."); } - return await subcommand(normalizedHost, options); + return await commandKnowledge(normalizedHost); } - - const question = (first === "ask" ? rest : positional).join(" ").trim(); + if (first !== "ask") { + throw new Error(`Unknown command: ${first}. Run "javachat --help" for usage.`); + } + const question = rest.join(" ").trim(); if (!question) { - stderr.write('Nothing to ask. Try: javachat "How do Java records work?"\n'); + stderr.write('Nothing to ask. Try: javachat ask "How do Java records work?"\n'); return 1; } return await commandAsk(normalizedHost, question, options); diff --git a/cli/test/javachat.test.js b/cli/test/javachat.test.js index 2ea8d326..1e1c20bc 100644 --- a/cli/test/javachat.test.js +++ b/cli/test/javachat.test.js @@ -34,7 +34,7 @@ function runCli(argumentsList, environmentVariables = {}) { async function startBrowserLogin(host, configurationHome) { const child = spawn( process.execPath, - [CLI_ENTRYPOINT, "login", "--host", host, "--no-browser"], + [CLI_ENTRYPOINT, "auth", "login", "--host", host, "--no-browser"], { env: { ...process.env, XDG_CONFIG_HOME: configurationHome }, stdio: ["ignore", "pipe", "pipe"], @@ -71,11 +71,25 @@ async function completeBrowserLogin(approvalUrl, apiKey = TEST_API_KEY) { }); } +async function writeStoredCredential(configurationHome, host, apiKey) { + const credentialDirectory = join(configurationHome, "javachat"); + const credentialFile = join(credentialDirectory, "credentials.json"); + await mkdir(credentialDirectory, { recursive: true }); + await writeFile( + credentialFile, + JSON.stringify({ [host]: { apiKey, sessionId: "chat-stored" } }), + ); + return credentialFile; +} + test("prints usage without loading credentials", async () => { const cliExecution = await runCli(["--help"]); assert.equal(cliExecution.exitCode, 0); - assert.match(cliExecution.standardOutput, /javachat login/); + assert.match(cliExecution.standardOutput, /javachat ask/); + assert.match(cliExecution.standardOutput, /javachat auth login/); + assert.match(cliExecution.standardOutput, /javachat auth logout/); + assert.match(cliExecution.standardOutput, /javachat auth status/); assert.equal(cliExecution.standardError, ""); }); @@ -95,7 +109,7 @@ test("treats --version after -- as question text", async (testContext) => { const configurationHome = await mkdtemp(join(tmpdir(), "javachat-cli-test-")); testContext.after(() => rm(configurationHome, { recursive: true, force: true })); - const cliExecution = await runCli(["--", "--version"], { + const cliExecution = await runCli(["ask", "--", "--version"], { JAVACHAT_API_KEY: "", XDG_CONFIG_HOME: configurationHome, }); @@ -158,7 +172,7 @@ test("streams text and citations through the public API", async (testContext) => assert.equal(typeof address, "object"); const cliExecution = await runCli( - ["--host", `http://127.0.0.1:${address.port}`, "How do records work?"], + ["--host", `http://127.0.0.1:${address.port}`, "ask", "How do records work?"], { JAVACHAT_API_KEY: TEST_API_KEY }, ); @@ -193,7 +207,7 @@ test("renders enrichment markers split across stream chunks", async (testContext const address = apiServer.address(); const cliExecution = await runCli( - ["--host", `http://127.0.0.1:${address.port}`, "What is a sealed interface?"], + ["--host", `http://127.0.0.1:${address.port}`, "ask", "What is a sealed interface?"], { JAVACHAT_API_KEY: TEST_API_KEY }, ); @@ -223,7 +237,7 @@ test("releases an unterminated enrichment marker instead of swallowing it", asyn const address = apiServer.address(); const cliExecution = await runCli( - ["--host", `http://127.0.0.1:${address.port}`, "Anything"], + ["--host", `http://127.0.0.1:${address.port}`, "ask", "Anything"], { JAVACHAT_API_KEY: TEST_API_KEY }, ); @@ -250,7 +264,7 @@ test("rejects a successful non-SSE response", async (testContext) => { assert.equal(typeof address, "object"); const cliExecution = await runCli( - ["--host", `http://127.0.0.1:${address.port}`, "How do records work?"], + ["--host", `http://127.0.0.1:${address.port}`, "ask", "How do records work?"], { JAVACHAT_API_KEY: TEST_API_KEY }, ); @@ -344,6 +358,35 @@ test("rejects an unknown option instead of treating it as a question", async () assert.match(cliExecution.standardError, /Unknown option: --hots/); }); +test("requires the ask command before question text", async () => { + const cliExecution = await runCli(["How do records work?"]); + + assert.equal(cliExecution.exitCode, 1); + assert.match(cliExecution.standardError, /Unknown command: How do records work\?/); +}); + +test("rejects removed flat authentication commands", async () => { + for (const flatCommand of ["login", "logout", "whoami"]) { + const cliExecution = await runCli([flatCommand]); + assert.equal(cliExecution.exitCode, 1); + assert.match(cliExecution.standardError, new RegExp(`Unknown command: ${flatCommand}`)); + } +}); + +test("requires an authentication command", async () => { + const cliExecution = await runCli(["auth"]); + + assert.equal(cliExecution.exitCode, 1); + assert.match(cliExecution.standardError, /auth requires a command: login, logout, or status/); +}); + +test("rejects unknown authentication commands", async () => { + const cliExecution = await runCli(["auth", "whoami"]); + + assert.equal(cliExecution.exitCode, 1); + assert.match(cliExecution.standardError, /Unknown auth command: whoami/); +}); + test("allows Java option tokens after question text begins", async (testContext) => { let receivedQuestion = ""; const apiServer = createServer((request, response) => { @@ -377,7 +420,7 @@ test("allows Java option tokens after question text begins", async (testContext) }); test("rejects --host without a value", async () => { - const cliExecution = await runCli(["whoami", "--host"]); + const cliExecution = await runCli(["auth", "status", "--host"]); assert.equal(cliExecution.exitCode, 1); assert.match(cliExecution.standardError, /--host requires a value/); @@ -391,17 +434,40 @@ test("describes the offending host value when it is not a URL", async () => { }); test("rejects stray arguments on subcommands", async () => { - const cliExecution = await runCli(["whoami", "extra"]); + const cliExecution = await runCli(["auth", "status", "extra"]); assert.equal(cliExecution.exitCode, 1); - assert.match(cliExecution.standardError, /javachat whoami takes no arguments/); + assert.match(cliExecution.standardError, /javachat auth status takes no arguments/); +}); + +test("auth status shows the authenticated identity", async (testContext) => { + const apiServer = createServer((request, response) => { + assert.equal(request.url, "/api/me"); + assert.equal(request.headers.authorization, `Bearer ${TEST_API_KEY}`); + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"userId":"user_cli"}'); + }); + apiServer.listen(0, "127.0.0.1"); + await once(apiServer, "listening"); + testContext.after(() => apiServer.close()); + const apiAddress = apiServer.address(); + assert.equal(typeof apiAddress, "object"); + const host = `http://127.0.0.1:${apiAddress.port}`; + + const cliExecution = await runCli(["auth", "--host", host, "status"], { + JAVACHAT_API_KEY: TEST_API_KEY, + }); + + assert.equal(cliExecution.exitCode, 0); + assert.equal(cliExecution.standardOutput, `user_cli at ${host}\n`); + assert.equal(cliExecution.standardError, ""); }); test("login points at JAVACHAT_API_KEY when it provides the authentication", async (testContext) => { const configurationHome = await mkdtemp(join(tmpdir(), "javachat-cli-test-")); testContext.after(() => rm(configurationHome, { recursive: true, force: true })); - const cliExecution = await runCli(["login", "--host", "https://javachat.ai"], { + const cliExecution = await runCli(["auth", "login", "--host", "https://javachat.ai"], { JAVACHAT_API_KEY: TEST_API_KEY, XDG_CONFIG_HOME: configurationHome, }); @@ -411,31 +477,58 @@ test("login points at JAVACHAT_API_KEY when it provides the authentication", asy assert.doesNotMatch(cliExecution.standardError, /logout/); }); +test("auth logout revokes and removes the stored credential", async (testContext) => { + const configurationHome = await mkdtemp(join(tmpdir(), "javachat-cli-test-")); + testContext.after(() => rm(configurationHome, { recursive: true, force: true })); + const apiServer = createServer((request, response) => { + assert.equal(request.method, "DELETE"); + assert.equal(request.url, "/api/me/api-key"); + assert.equal(request.headers.authorization, `Bearer ${TEST_API_KEY}`); + response.writeHead(204).end(); + }); + apiServer.listen(0, "127.0.0.1"); + await once(apiServer, "listening"); + testContext.after(() => apiServer.close()); + const apiAddress = apiServer.address(); + assert.equal(typeof apiAddress, "object"); + const host = `http://127.0.0.1:${apiAddress.port}`; + const credentialFile = await writeStoredCredential(configurationHome, host, TEST_API_KEY); + + const cliExecution = await runCli(["auth", "logout", "--host", host], { + JAVACHAT_API_KEY: "", + XDG_CONFIG_HOME: configurationHome, + }); + + assert.equal(cliExecution.exitCode, 0); + assert.match(cliExecution.standardOutput, /Revoked and removed the local credential/); + assert.equal(cliExecution.standardError, ""); + await assert.rejects(readFile(credentialFile, "utf8"), { code: "ENOENT" }); +}); + test("logout explains JAVACHAT_API_KEY when no credential is stored", async (testContext) => { const configurationHome = await mkdtemp(join(tmpdir(), "javachat-cli-test-")); testContext.after(() => rm(configurationHome, { recursive: true, force: true })); - const cliExecution = await runCli(["logout", "--host", "https://javachat.ai"], { + const cliExecution = await runCli(["auth", "logout", "--host", "https://javachat.ai"], { JAVACHAT_API_KEY: TEST_API_KEY, XDG_CONFIG_HOME: configurationHome, }); assert.equal(cliExecution.exitCode, 0); assert.match(cliExecution.standardError, /authentication.*JAVACHAT_API_KEY/i); - assert.match(cliExecution.standardError, /run "javachat logout" again/i); + assert.match(cliExecution.standardError, /run "javachat auth logout" again/i); }); test("environment authentication takes precedence over a stored logout credential", async (testContext) => { const configurationHome = await mkdtemp(join(tmpdir(), "javachat-cli-test-")); testContext.after(() => rm(configurationHome, { recursive: true, force: true })); - const credentialDirectory = join(configurationHome, "javachat"); - await mkdir(credentialDirectory, { recursive: true }); - await writeFile( - join(credentialDirectory, "credentials.json"), - JSON.stringify({ "https://javachat.ai": { apiKey: "ak_secret_stored", sessionId: "chat-stored" } }), + const credentialFile = await writeStoredCredential( + configurationHome, + "https://javachat.ai", + "ak_secret_stored", ); - const cliExecution = await runCli(["logout", "--host", "https://javachat.ai"], { + const cliExecution = await runCli(["auth", "logout", "--host", "https://javachat.ai"], { JAVACHAT_API_KEY: TEST_API_KEY, XDG_CONFIG_HOME: configurationHome, }); @@ -444,9 +537,7 @@ test("environment authentication takes precedence over a stored logout credentia assert.match(cliExecution.standardError, /authentication.*JAVACHAT_API_KEY/i); assert.match(cliExecution.standardError, /stored fallback credential/i); assert.doesNotMatch(cliExecution.standardOutput, /Revoked/); - const storedCredentials = JSON.parse( - await readFile(join(credentialDirectory, "credentials.json"), "utf8"), - ); + const storedCredentials = JSON.parse(await readFile(credentialFile, "utf8")); assert.equal(storedCredentials["https://javachat.ai"].apiKey, "ak_secret_stored"); }); diff --git a/frontend/src/lib/components/CliAuthorizationPage.svelte b/frontend/src/lib/components/CliAuthorizationPage.svelte index c5963fe2..15adb545 100644 --- a/frontend/src/lib/components/CliAuthorizationPage.svelte +++ b/frontend/src/lib/components/CliAuthorizationPage.svelte @@ -54,7 +54,7 @@ {#if !authorizationQueryValidation.success}

This authorization request is invalid. Return to your terminal and run - javachat login again. + javachat auth login again.

{:else if clerkAuthentication.phase === 'disabled' || clerkAuthentication.phase === 'failed'}

diff --git a/frontend/src/lib/components/CliAuthorizationPage.test.ts b/frontend/src/lib/components/CliAuthorizationPage.test.ts index 02a40f93..14cca9f2 100644 --- a/frontend/src/lib/components/CliAuthorizationPage.test.ts +++ b/frontend/src/lib/components/CliAuthorizationPage.test.ts @@ -33,7 +33,7 @@ describe("CLI authorization request", () => { const authorizationPage = render(CliAuthorizationPage); expect(authorizationPage.getByRole("alert")).toHaveTextContent( - "This authorization request is invalid", + "This authorization request is invalid. Return to your terminal and run javachat auth login again.", ); expect( authorizationPage.queryByRole("button", { name: "Authorize terminal" }), From 655f3d09e802f1b12163b2f1913a82c03f7bd410 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 00:44:43 -0700 Subject: [PATCH 03/29] feat(cli): expose source-aware knowledge inventory The CLI could only print group labels and counts, so users could not verify which canonical sources and exact versions or revisions were actually indexed. Enrich the authenticated inventory from source-filtered Qdrant facets, expose it through list all and list knowledge, and add reusable JavaChat skill guidance and complete help. - Return canonical URLs and exact ingested metadata for documentation and repositories --- README.md | 5 +- cli/README.md | 12 +- cli/bin/javachat.js | 56 +++++-- cli/test/javachat.test.js | 146 +++++++++++++----- docs/api.md | 8 +- skills/javachat/SKILL.md | 103 ++++++++++++ .../KnowledgeBaseInventoryUseCase.java | 74 ++++++++- .../javachat/config/DocsSourceRegistry.java | 18 ++- .../domain/knowledge/KnowledgeGroup.java | 23 ++- .../javachat/service/HybridVectorService.java | 22 ++- .../KnowledgeBaseControllerTest.java | 17 +- .../KnowledgeBaseInventoryUseCaseTest.java | 81 +++++++++- .../config/DocsSourceRegistryTest.java | 8 + .../service/HybridVectorServiceTest.java | 20 +++ ...edUserEndpointSecurityIntegrationTest.java | 13 +- 15 files changed, 521 insertions(+), 85 deletions(-) create mode 100644 skills/javachat/SKILL.md diff --git a/README.md b/README.md index 63708315..0be3f113 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,9 @@ npm run dev -- --help npm run dev -- --host http://localhost:8085 ask "How do Java records work?" ``` -See the **[CLI README](cli/README.md)** for authentication, production dogfood, packaged-artifact -checks, and the npm release procedure. +See the **[CLI README](cli/README.md)** for authentication, inventory, production dogfood, +packaged-artifact checks, and the npm release procedure. Agents can reuse the repository's +**[JavaChat skill](skills/javachat/SKILL.md)** for source-grounded documentation questions. ## Index documentation (RAG) diff --git a/cli/README.md b/cli/README.md index 03fe6def..149549fc 100644 --- a/cli/README.md +++ b/cli/README.md @@ -23,11 +23,11 @@ JavaChat deployment. - `javachat ask "question"` — ask a question with the same retrieval and citations as the website. - `javachat auth login` / `javachat auth logout` — authorize or remove this machine's credential. - `javachat auth status` — show who the stored key belongs to. -- `javachat knowledge` — list the document groups ingested in the knowledge base - (documentation sets, books, articles, PDFs, and indexed GitHub repositories, with per-group and - total chunk counts). +- `javachat list all` — list every ingested source with canonical URLs, versions or revisions, + and chunk counts. +- `javachat list knowledge` — alias for `javachat list all`. -Run `javachat --help` for options. +Run `javachat`, `javachat --help`, or `javachat -h` for the complete command list. ## Develop locally @@ -64,7 +64,7 @@ keys in command arguments, package scripts, or committed files. Exercise the inventory and a fresh, citation-backed answer against the target deployment: ```bash -npm run dev -- --host https://javachat.ai knowledge +npm run dev -- --host https://javachat.ai list all npm run dev -- --host https://javachat.ai --new --verbose ask \ "Compare HikariCP 7.0.5 with Spring AI 1.1.5 and name every indexed version used as evidence." ``` @@ -78,7 +78,7 @@ npm pack npm install --global ./wcallahan-javachat-cli-0.0.1.tgz javachat --version javachat --host https://javachat.ai auth status -javachat --host https://javachat.ai knowledge +javachat --host https://javachat.ai list knowledge ``` The tarball-installed `javachat` command uses the same per-host credential file as the source diff --git a/cli/bin/javachat.js b/cli/bin/javachat.js index 0edfd239..0e9bb024 100755 --- a/cli/bin/javachat.js +++ b/cli/bin/javachat.js @@ -339,7 +339,7 @@ async function commandStatus(host) { * collections, repository URLs for GitHub collections); the terminal's job is * only to validate and print that inventory. */ -async function commandKnowledge(host) { +async function commandList(host) { const apiKey = await storedKeyFor(host); if (!apiKey) { stderr.write(`Not signed in to ${host}. Run "javachat auth login".\n`); @@ -373,6 +373,12 @@ async function commandKnowledge(host) { stdout.write( ` ${stripVTControlCharacters(knowledgeGroup.name)} (${knowledgeGroup.chunks} ${chunkNoun})\n`, ); + for (const canonicalUrl of knowledgeGroup.canonicalUrls) { + stdout.write(` URL: ${stripVTControlCharacters(canonicalUrl)}\n`); + } + stdout.write( + ` Versions/revisions: ${knowledgeGroup.ingestedVersions.length > 0 ? knowledgeGroup.ingestedVersions.map(stripVTControlCharacters).join(", ") : "unversioned"}\n`, + ); } const collectionCount = new Set(knowledgeGroups.map((knowledgeGroup) => knowledgeGroup.collection)) .size; @@ -406,16 +412,34 @@ function parseKnowledgeInventory(rawKnowledgeInventory, host) { !rawKnowledgeGroup.kind || typeof rawKnowledgeGroup.name !== "string" || !rawKnowledgeGroup.name || + !Array.isArray(rawKnowledgeGroup.canonicalUrls) || + rawKnowledgeGroup.canonicalUrls.length === 0 || + !Array.isArray(rawKnowledgeGroup.ingestedVersions) || + rawKnowledgeGroup.ingestedVersions.some( + (ingestedVersion) => typeof ingestedVersion !== "string" || !ingestedVersion, + ) || typeof rawKnowledgeGroup.chunks !== "number" || !Number.isSafeInteger(rawKnowledgeGroup.chunks) || rawKnowledgeGroup.chunks < 0 ) { throw new Error(`JavaChat returned a malformed knowledge group from ${host}.`); } + const canonicalUrls = rawKnowledgeGroup.canonicalUrls.map((canonicalUrl) => { + if (typeof canonicalUrl !== "string" || !canonicalUrl) { + throw new Error(`JavaChat returned a malformed knowledge group from ${host}.`); + } + const parsedUrl = URL.parse(canonicalUrl, host); + if (!parsedUrl || (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:")) { + throw new Error(`JavaChat returned a malformed knowledge group from ${host}.`); + } + return parsedUrl.href; + }); return { collection: rawKnowledgeGroup.collection, kind: rawKnowledgeGroup.kind, name: rawKnowledgeGroup.name, + canonicalUrls, + ingestedVersions: rawKnowledgeGroup.ingestedVersions, chunks: rawKnowledgeGroup.chunks, }; }); @@ -624,13 +648,15 @@ Usage javachat auth login Authorize this machine in your browser javachat auth logout Remove the local credential javachat auth status Show who the stored key belongs to - javachat knowledge List the document groups ingested in the knowledge base + javachat list all List every ingested source, URL, and version + javachat list knowledge Alias for list all Options --host Target a different deployment (default ${DEFAULT_HOST}) --new Start a fresh conversation instead of continuing the last one --verbose Show retrieval progress on stderr --no-browser Print the approval URL instead of opening a browser + --help, -h Show this complete command reference --version Show the installed CLI version -- Treat everything after it as question text (for questions starting with -) @@ -654,6 +680,7 @@ function parseArguments(rawArguments) { version: false, }; let host = env.JAVACHAT_HOST?.trim() || DEFAULT_HOST; + let hostOptionProvided = false; const positional = []; for (let index = 0; index < rawArguments.length; index += 1) { const argument = rawArguments[index]; @@ -667,6 +694,7 @@ function parseArguments(rawArguments) { throw new Error("--host requires a value, e.g. javachat --host https://javachat.ai"); } host = hostValue; + hostOptionProvided = true; index += 1; } else if (argument === "--verbose" || argument === "-v") options.verbose = true; else if (argument === "--new") options.newSession = true; @@ -684,12 +712,12 @@ function parseArguments(rawArguments) { } } } - return { host, options, positional }; + return { host, hostOptionProvided, options, positional }; } async function main() { const rawArguments = argv.slice(2); - const { host, options, positional } = parseArguments(rawArguments); + const { host, hostOptionProvided, options, positional } = parseArguments(rawArguments); const [first, ...rest] = positional; if (options.version) { @@ -698,11 +726,12 @@ async function main() { return 0; } - const normalizedHost = normalizeHost(host); if (options.help || first === "help" || !first) { + if (hostOptionProvided) normalizeHost(host); stdout.write(USAGE); - return options.help || first ? 0 : 1; + return 0; } + const normalizedHost = normalizeHost(host); if (first === "auth") { const [authCommandName, ...authArguments] = rest; if (!authCommandName) { @@ -719,11 +748,18 @@ async function main() { } return await authCommand(normalizedHost, options); } - if (first === "knowledge") { - if (rest.length > 0) { - throw new Error("javachat knowledge takes no arguments."); + if (first === "list") { + const [listTarget, ...listArguments] = rest; + if (!listTarget) { + throw new Error("javachat list requires a target: all or knowledge."); + } + if (listTarget !== "all" && listTarget !== "knowledge") { + throw new Error(`Unknown list target: ${listTarget}. Run "javachat --help" for usage.`); + } + if (listArguments.length > 0) { + throw new Error(`javachat list ${listTarget} takes no arguments.`); } - return await commandKnowledge(normalizedHost); + return await commandList(normalizedHost); } if (first !== "ask") { throw new Error(`Unknown command: ${first}. Run "javachat --help" for usage.`); diff --git a/cli/test/javachat.test.js b/cli/test/javachat.test.js index 1e1c20bc..2fdc77df 100644 --- a/cli/test/javachat.test.js +++ b/cli/test/javachat.test.js @@ -83,14 +83,28 @@ async function writeStoredCredential(configurationHome, host, apiKey) { } test("prints usage without loading credentials", async () => { - const cliExecution = await runCli(["--help"]); + const helpEnvironment = { + XDG_CONFIG_HOME: CLI_ENTRYPOINT, + JAVACHAT_API_KEY: "", + JAVACHAT_HOST: "not a URL", + }; + const helpExecutions = await Promise.all( + [[], ["--help"], ["-h"]].map((helpArguments) => runCli(helpArguments, helpEnvironment)), + ); - assert.equal(cliExecution.exitCode, 0); - assert.match(cliExecution.standardOutput, /javachat ask/); - assert.match(cliExecution.standardOutput, /javachat auth login/); - assert.match(cliExecution.standardOutput, /javachat auth logout/); - assert.match(cliExecution.standardOutput, /javachat auth status/); - assert.equal(cliExecution.standardError, ""); + for (const helpExecution of helpExecutions) { + assert.equal(helpExecution.exitCode, 0); + assert.match(helpExecution.standardOutput, /javachat ask/); + assert.match(helpExecution.standardOutput, /javachat auth login/); + assert.match(helpExecution.standardOutput, /javachat auth logout/); + assert.match(helpExecution.standardOutput, /javachat auth status/); + assert.match(helpExecution.standardOutput, /javachat list all/); + assert.match(helpExecution.standardOutput, /javachat list knowledge/); + assert.match(helpExecution.standardOutput, /--help, -h/); + assert.equal(helpExecution.standardError, ""); + } + assert.equal(helpExecutions[0].standardOutput, helpExecutions[1].standardOutput); + assert.equal(helpExecutions[1].standardOutput, helpExecutions[2].standardOutput); }); test("prints the installed version without loading credentials", async () => { @@ -387,6 +401,23 @@ test("rejects unknown authentication commands", async () => { assert.match(cliExecution.standardError, /Unknown auth command: whoami/); }); +test("requires a supported list target", async () => { + const missingTarget = await runCli(["list"]); + const unknownTarget = await runCli(["list", "packages"]); + + assert.equal(missingTarget.exitCode, 1); + assert.match(missingTarget.standardError, /list requires a target: all or knowledge/); + assert.equal(unknownTarget.exitCode, 1); + assert.match(unknownTarget.standardError, /Unknown list target: packages/); +}); + +test("rejects the removed flat knowledge command", async () => { + const cliExecution = await runCli(["knowledge"]); + + assert.equal(cliExecution.exitCode, 1); + assert.match(cliExecution.standardError, /Unknown command: knowledge/); +}); + test("allows Java option tokens after question text begins", async (testContext) => { let receivedQuestion = ""; const apiServer = createServer((request, response) => { @@ -541,11 +572,11 @@ test("environment authentication takes precedence over a stored logout credentia assert.equal(storedCredentials["https://javachat.ai"].apiKey, "ak_secret_stored"); }); -test("knowledge requires a credential before calling the API", async (testContext) => { +test("list requires a credential before calling the API", async (testContext) => { const configurationHome = await mkdtemp(join(tmpdir(), "javachat-cli-test-")); testContext.after(() => rm(configurationHome, { recursive: true, force: true })); - const cliExecution = await runCli(["knowledge", "--host", "https://javachat.ai"], { + const cliExecution = await runCli(["list", "all", "--host", "https://javachat.ai"], { JAVACHAT_API_KEY: "", XDG_CONFIG_HOME: configurationHome, }); @@ -554,17 +585,40 @@ test("knowledge requires a credential before calling the API", async (testContex assert.match(cliExecution.standardError, /Not signed in to https:\/\/javachat\.ai/); }); -test("knowledge lists the ingested document groups", async (testContext) => { +test("list all and list knowledge show every ingested source", async (testContext) => { + let requestCount = 0; const apiServer = createServer((request, response) => { + requestCount += 1; assert.equal(request.url, "/api/knowledge/groups"); assert.equal(request.headers.authorization, `Bearer ${TEST_API_KEY}`); response.writeHead(200, { "content-type": "application/json" }); response.end( JSON.stringify({ groups: [ - { collection: "chat-docs", kind: "DOCS", name: "oracle/javase/25/api", chunks: 10 }, - { collection: "chat-docs", kind: "DOCS", name: "jetbrains/idea/2025/09", chunks: 1 }, - { collection: "chat-github-repo", kind: "GITHUB", name: "https://github.com/acme/repo", chunks: 7 }, + { + collection: "chat-docs", + kind: "DOCS", + name: "kotlin", + canonicalUrls: ["https://kotlinlang.org/docs/"], + ingestedVersions: ["2.4.10"], + chunks: 10, + }, + { + collection: "chat-docs", + kind: "DOCS", + name: "jetbrains/idea/2025/09", + canonicalUrls: ["https://blog.jetbrains.com/idea/2025/09/"], + ingestedVersions: ["25"], + chunks: 1, + }, + { + collection: "chat-github-repo", + kind: "GITHUB", + name: "https://github.com/acme/repo", + canonicalUrls: ["https://github.com/acme/repo"], + ingestedVersions: ["abc123"], + chunks: 7, + }, ], totalChunks: 18, }), @@ -577,21 +631,29 @@ test("knowledge lists the ingested document groups", async (testContext) => { assert.notEqual(apiAddress, null); assert.equal(typeof apiAddress, "object"); - const cliExecution = await runCli(["knowledge", "--host", `http://127.0.0.1:${apiAddress.port}`], { - JAVACHAT_API_KEY: TEST_API_KEY, - }); - - assert.equal(cliExecution.exitCode, 0); - assert.match(cliExecution.standardOutput, /Knowledge base at http:\/\/127\.0\.0\.1:/); - assert.match(cliExecution.standardOutput, /DOCS — chat-docs/); - assert.match(cliExecution.standardOutput, /oracle\/javase\/25\/api \(10 chunks\)/); - assert.match(cliExecution.standardOutput, /jetbrains\/idea\/2025\/09 \(1 chunk\)/); - assert.match(cliExecution.standardOutput, /GITHUB — chat-github-repo/); - assert.match(cliExecution.standardOutput, /18 chunks across 3 groups in 2 collections\./); - assert.equal(cliExecution.standardError, ""); -}); - -test("knowledge reports an empty knowledge base", async (testContext) => { + const host = `http://127.0.0.1:${apiAddress.port}`; + const [allExecution, aliasExecution] = await Promise.all([ + runCli(["list", "all", "--host", host], { JAVACHAT_API_KEY: TEST_API_KEY }), + runCli(["list", "knowledge", "--host", host], { JAVACHAT_API_KEY: TEST_API_KEY }), + ]); + + assert.equal(requestCount, 2); + assert.equal(allExecution.exitCode, 0); + assert.equal(aliasExecution.exitCode, 0); + assert.equal(allExecution.standardOutput, aliasExecution.standardOutput); + assert.match(allExecution.standardOutput, /Knowledge base at http:\/\/127\.0\.0\.1:/); + assert.match(allExecution.standardOutput, /DOCS — chat-docs/); + assert.match(allExecution.standardOutput, /kotlin \(10 chunks\)/); + assert.match(allExecution.standardOutput, /URL: https:\/\/kotlinlang\.org\/docs\//); + assert.match(allExecution.standardOutput, /Versions\/revisions: 2\.4\.10/); + assert.match(allExecution.standardOutput, /URL: https:\/\/github\.com\/acme\/repo/); + assert.match(allExecution.standardOutput, /Versions\/revisions: abc123/); + assert.match(allExecution.standardOutput, /18 chunks across 3 groups in 2 collections\./); + assert.equal(allExecution.standardError, ""); + assert.equal(aliasExecution.standardError, ""); +}); + +test("list reports an empty knowledge base", async (testContext) => { const apiServer = createServer((request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end('{"groups":[],"totalChunks":0}'); @@ -603,7 +665,7 @@ test("knowledge reports an empty knowledge base", async (testContext) => { assert.notEqual(apiAddress, null); assert.equal(typeof apiAddress, "object"); - const cliExecution = await runCli(["knowledge", "--host", `http://127.0.0.1:${apiAddress.port}`], { + const cliExecution = await runCli(["list", "all", "--host", `http://127.0.0.1:${apiAddress.port}`], { JAVACHAT_API_KEY: TEST_API_KEY, }); @@ -611,7 +673,7 @@ test("knowledge reports an empty knowledge base", async (testContext) => { assert.match(cliExecution.standardOutput, /No document groups are ingested/); }); -test("knowledge rejects a malformed group list", async (testContext) => { +test("list rejects a malformed group list", async (testContext) => { const apiServer = createServer((request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end('{"groups":[{"collection":"chat-docs","kind":"DOCS"}],"totalChunks":0}'); @@ -623,7 +685,7 @@ test("knowledge rejects a malformed group list", async (testContext) => { assert.notEqual(apiAddress, null); assert.equal(typeof apiAddress, "object"); - const cliExecution = await runCli(["knowledge", "--host", `http://127.0.0.1:${apiAddress.port}`], { + const cliExecution = await runCli(["list", "all", "--host", `http://127.0.0.1:${apiAddress.port}`], { JAVACHAT_API_KEY: TEST_API_KEY, }); @@ -631,11 +693,11 @@ test("knowledge rejects a malformed group list", async (testContext) => { assert.match(cliExecution.standardError, /malformed knowledge group/); }); -test("knowledge rejects unsafe chunk totals", async (testContext) => { +test("list rejects unsafe chunk totals", async (testContext) => { const apiServer = createServer((request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end( - '{"groups":[{"collection":"docs","kind":"DOCS","name":"java","chunks":9007199254740993}],"totalChunks":9007199254740993}', + '{"groups":[{"collection":"docs","kind":"DOCS","name":"java","canonicalUrls":["https://docs.example/"],"ingestedVersions":["25"],"chunks":9007199254740993}],"totalChunks":9007199254740993}', ); }); apiServer.listen(0, "127.0.0.1"); @@ -643,7 +705,7 @@ test("knowledge rejects unsafe chunk totals", async (testContext) => { testContext.after(() => apiServer.close()); const apiAddress = apiServer.address(); assert.equal(typeof apiAddress, "object"); - const cliExecution = await runCli(["knowledge", "--host", `http://127.0.0.1:${apiAddress.port}`], { + const cliExecution = await runCli(["list", "all", "--host", `http://127.0.0.1:${apiAddress.port}`], { JAVACHAT_API_KEY: TEST_API_KEY, }); @@ -651,11 +713,11 @@ test("knowledge rejects unsafe chunk totals", async (testContext) => { assert.match(cliExecution.standardError, /invalid knowledge inventory/); }); -test("knowledge rejects inconsistent chunk totals", async (testContext) => { +test("list rejects inconsistent chunk totals", async (testContext) => { const apiServer = createServer((request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end( - '{"groups":[{"collection":"docs","kind":"DOCS","name":"java","chunks":3}],"totalChunks":4}', + '{"groups":[{"collection":"docs","kind":"DOCS","name":"java","canonicalUrls":["https://docs.example/"],"ingestedVersions":["25"],"chunks":3}],"totalChunks":4}', ); }); apiServer.listen(0, "127.0.0.1"); @@ -664,7 +726,7 @@ test("knowledge rejects inconsistent chunk totals", async (testContext) => { const apiAddress = apiServer.address(); assert.equal(typeof apiAddress, "object"); - const cliExecution = await runCli(["knowledge", "--host", `http://127.0.0.1:${apiAddress.port}`], { + const cliExecution = await runCli(["list", "all", "--host", `http://127.0.0.1:${apiAddress.port}`], { JAVACHAT_API_KEY: TEST_API_KEY, }); @@ -672,7 +734,7 @@ test("knowledge rejects inconsistent chunk totals", async (testContext) => { assert.match(cliExecution.standardError, /inconsistent knowledge inventory/); }); -test("knowledge strips terminal control characters from server strings", async (testContext) => { +test("list strips terminal control characters from server strings", async (testContext) => { const apiServer = createServer((request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end( @@ -682,6 +744,8 @@ test("knowledge strips terminal control characters from server strings", async ( collection: "chat-docs", kind: "DOCS", name: "docs/\u001b]52;c;c3Bvb2Y=\u0007evil", + canonicalUrls: ["https://docs.example/\u001b]52;c;c3Bvb2Y=\u0007path"], + ingestedVersions: ["1.0\u001b]52;c;c3Bvb2Y=\u0007"], chunks: 3, }, ], @@ -696,7 +760,7 @@ test("knowledge strips terminal control characters from server strings", async ( assert.notEqual(apiAddress, null); assert.equal(typeof apiAddress, "object"); - const cliExecution = await runCli(["knowledge", "--host", `http://127.0.0.1:${apiAddress.port}`], { + const cliExecution = await runCli(["list", "all", "--host", `http://127.0.0.1:${apiAddress.port}`], { JAVACHAT_API_KEY: TEST_API_KEY, }); @@ -705,7 +769,7 @@ test("knowledge strips terminal control characters from server strings", async ( assert.match(cliExecution.standardOutput, /docs\/evil \(3 chunks\)/); }); -test("knowledge includes the server error body on failure", async (testContext) => { +test("list includes the server error body on failure", async (testContext) => { const apiServer = createServer((request, response) => { response.writeHead(401, { "content-type": "application/json" }); response.end('{"message":"API key is invalid, revoked, or expired."}'); @@ -717,7 +781,7 @@ test("knowledge includes the server error body on failure", async (testContext) assert.notEqual(apiAddress, null); assert.equal(typeof apiAddress, "object"); - const cliExecution = await runCli(["knowledge", "--host", `http://127.0.0.1:${apiAddress.port}`], { + const cliExecution = await runCli(["list", "knowledge", "--host", `http://127.0.0.1:${apiAddress.port}`], { JAVACHAT_API_KEY: TEST_API_KEY, }); diff --git a/docs/api.md b/docs/api.md index d7bf3cc4..69dc5cb1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -96,9 +96,11 @@ curl -N -H "Content-Type: application/json" \ - GET `/api/knowledge/groups` — lists every ingested document group (documentation-set tokens for the books/docs/articles/PDFs collections, repository URLs for indexed GitHub - collections) with its chunk count. Requires authentication (Clerk session or `ak_` API key); - the response is `{groups: [{collection, kind, name, chunks}], totalChunks}`. A backing-store - failure or facet overflow returns HTTP 502 instead of an incomplete inventory. + collections) with canonical source URLs, exact stored versions or revisions, and chunk counts. + Requires authentication (Clerk session or `ak_` API key); the response is + `{groups: [{collection, kind, name, canonicalUrls, ingestedVersions, chunks}], totalChunks}`. + A backing-store failure, unknown documentation source, or facet overflow returns HTTP 502 + instead of an incomplete inventory. ## Ingestion diff --git a/skills/javachat/SKILL.md b/skills/javachat/SKILL.md new file mode 100644 index 00000000..ec2f5f7f --- /dev/null +++ b/skills/javachat/SKILL.md @@ -0,0 +1,103 @@ +--- +name: javachat +description: "Answer software and platform documentation questions with the JavaChat CLI and retrieved citations. Use for Java, Kotlin, Docker, Docker Swarm, Dokploy, Clerk, Spring, indexed GitHub repositories, or any question that should be checked against JavaChat's current knowledge inventory." +--- + +# JavaChat documentation + +Use JavaChat from any repository to answer technical questions from the sources actually indexed +by the selected deployment. + +## Check the CLI + +The command and both help flags show the complete command surface without contacting the server: + +```bash +command -v javachat +javachat +javachat --help +javachat -h +javachat --version +``` + +JavaChat requires Node.js 24.18.0 or newer. If the command is missing and the local checkout exists, +install independent package bytes without publishing: + +```bash +package_directory="$(mktemp -d)" +npm pack --pack-destination "$package_directory" "$HOME/Developer/git/java-chat/cli" +npm install --global "$package_directory"/*.tgz +``` + +## Authenticate one deployment + +Credentials are stored per host. Development is the default documentation target unless the user +names another deployment: + +```bash +env -u JAVACHAT_API_KEY javachat --host https://dev.javachat.ai auth status +``` + +`auth login` creates and stores a credential. It is a human-operated command; agents must not run or +recommend credential issuance. A human can keep the callback process in the foreground and use: + +```bash +env -u JAVACHAT_API_KEY javachat \ + --host https://dev.javachat.ai \ + --no-browser \ + auth login +``` + +`auth logout` revokes the server key and removes the stored credential. It is also human-operated; +agents must never run or recommend credential revocation: + +```bash +env -u JAVACHAT_API_KEY javachat --host https://dev.javachat.ai auth logout +``` + +## Inspect available knowledge + +Check corpus presence before asking when the required package, repository, or version matters: + +```bash +env -u JAVACHAT_API_KEY javachat --host https://dev.javachat.ai list all +``` + +`list knowledge` is an exact alias: + +```bash +env -u JAVACHAT_API_KEY javachat --host https://dev.javachat.ai list knowledge +``` + +Each group shows its package or repository identity, canonical documentation or GitHub URL, exact +ingested versions or commit revisions, and chunk count. An unversioned source is labeled +`unversioned`; do not infer a version from its name. + +## Ask a grounded question + +Start a fresh conversation, show retrieval progress, and name the version or operational decision +in the question: + +```bash +env -u JAVACHAT_API_KEY javachat \ + --host https://dev.javachat.ai \ + --new \ + --verbose \ + ask "Using only retrieved official documentation or indexed official source, answer: . Name the applicable version, distinguish documented facts from inference, and cite the exact sources. If the required source is unavailable, say so." +``` + +For command syntax, ask for the exact current command and flags. For deployment behavior, ask for +prerequisites, incompatibilities, and rollback-relevant caveats. + +## Accept the answer + +Treat the answer as source-grounded only when: + +- the command exits successfully; +- retrieval progress appears before the answer; +- the requested package and version are addressed; +- `Sources:` contains relevant canonical URLs; and +- the cited pages support the material claims. + +Corpus presence, successful retrieval, and valid citations are separate proofs. Report a missing +source or retrieval gap instead of presenting uncited general knowledge as JavaChat documentation. diff --git a/src/main/java/com/williamcallahan/javachat/application/knowledge/KnowledgeBaseInventoryUseCase.java b/src/main/java/com/williamcallahan/javachat/application/knowledge/KnowledgeBaseInventoryUseCase.java index 0c80d397..614cbd28 100644 --- a/src/main/java/com/williamcallahan/javachat/application/knowledge/KnowledgeBaseInventoryUseCase.java +++ b/src/main/java/com/williamcallahan/javachat/application/knowledge/KnowledgeBaseInventoryUseCase.java @@ -1,5 +1,6 @@ package com.williamcallahan.javachat.application.knowledge; +import com.williamcallahan.javachat.config.DocsSourceRegistry; import com.williamcallahan.javachat.config.QdrantGitHubCollectionDiscovery; import com.williamcallahan.javachat.domain.knowledge.KnowledgeGroup; import com.williamcallahan.javachat.domain.knowledge.KnowledgeInventory; @@ -11,11 +12,17 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import org.springframework.stereotype.Service; /** Builds the complete retrievable inventory from current Qdrant collection state. */ @Service public final class KnowledgeBaseInventoryUseCase { + private static final int MAX_CONCURRENT_SOURCE_METADATA_READS = 8; + private final HybridVectorService hybridVectorService; private final Optional gitHubCollectionDiscovery; @@ -29,7 +36,8 @@ public KnowledgeBaseInventoryUseCase( /** Returns the current inventory or fails instead of returning an incomplete projection. */ public KnowledgeInventory listKnowledgeInventory() { - try { + try (ExecutorService inventoryExecutor = Executors.newFixedThreadPool( + MAX_CONCURRENT_SOURCE_METADATA_READS, Thread.ofVirtual().factory())) { List knowledgeGroups = new ArrayList<>(); for (QdrantCollectionKind collectionKind : QdrantCollectionKind.values()) { String collectionName = hybridVectorService.resolveCollectionName(collectionKind); @@ -40,7 +48,12 @@ public KnowledgeInventory listKnowledgeInventory() { case ARTICLES -> KnowledgeGroup.Kind.ARTICLES; case PDFS -> KnowledgeGroup.Kind.PDFS; }; - appendGroups(knowledgeGroups, collectionName, groupKind, QdrantPayloadFieldSchema.DOC_SET_FIELD); + appendGroups( + knowledgeGroups, + collectionName, + groupKind, + QdrantPayloadFieldSchema.DOC_SET_FIELD, + inventoryExecutor); } QdrantGitHubCollectionDiscovery discovery = gitHubCollectionDiscovery.orElseThrow( () -> new IllegalStateException("GitHub collection discovery is unavailable")); @@ -50,7 +63,8 @@ public KnowledgeInventory listKnowledgeInventory() { knowledgeGroups, gitHubCollection, KnowledgeGroup.Kind.GITHUB, - QdrantPayloadFieldSchema.REPO_URL_FIELD); + QdrantPayloadFieldSchema.REPO_URL_FIELD, + inventoryExecutor); } return new KnowledgeInventory(knowledgeGroups); } catch (IllegalStateException inventoryFailure) { @@ -62,10 +76,56 @@ private void appendGroups( List knowledgeGroups, String collectionName, KnowledgeGroup.Kind groupKind, - String payloadField) { - for (PayloadValueCount payloadCount : hybridVectorService.facetPayloadValues(collectionName, payloadField)) { - knowledgeGroups.add(new KnowledgeGroup( - collectionName, groupKind, payloadCount.payloadValue(), payloadCount.pointCount())); + String payloadField, + ExecutorService inventoryExecutor) { + List groupCounts = hybridVectorService.facetPayloadValues(collectionName, payloadField); + List> sourceMetadataReads = groupCounts.stream() + .map(payloadCount -> inventoryExecutor.submit( + () -> knowledgeGroup(collectionName, groupKind, payloadField, payloadCount))) + .toList(); + for (Future sourceMetadataRead : sourceMetadataReads) { + try { + knowledgeGroups.add(sourceMetadataRead.get()); + } catch (InterruptedException | ExecutionException metadataFailure) { + sourceMetadataReads.forEach(remainingRead -> remainingRead.cancel(true)); + if (metadataFailure instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + Throwable cause = + metadataFailure instanceof ExecutionException ? metadataFailure.getCause() : metadataFailure; + if (cause instanceof IllegalStateException inventoryFailure) { + throw inventoryFailure; + } + throw new IllegalStateException("Knowledge source metadata read failed", cause); + } } } + + private KnowledgeGroup knowledgeGroup( + String collectionName, KnowledgeGroup.Kind groupKind, String payloadField, PayloadValueCount payloadCount) { + String groupName = payloadCount.payloadValue(); + List canonicalUrls = + switch (groupKind) { + case GITHUB -> List.of(groupName); + case BOOKS -> List.of(DocsSourceRegistry.PUBLIC_PDFS_BASE); + case DOCS, ARTICLES, PDFS -> { + List citationBases = DocsSourceRegistry.citationBasesForDocSet(groupName); + if (citationBases.isEmpty()) { + throw new IllegalStateException( + "No canonical documentation URL is registered for " + groupName); + } + yield citationBases; + } + }; + String versionField = groupKind == KnowledgeGroup.Kind.GITHUB + ? QdrantPayloadFieldSchema.COMMIT_HASH_FIELD + : QdrantPayloadFieldSchema.DOC_VERSION_FIELD; + List ingestedVersions = + hybridVectorService.facetPayloadValues(collectionName, versionField, payloadField, groupName).stream() + .map(PayloadValueCount::payloadValue) + .filter(version -> !version.isBlank()) + .toList(); + return new KnowledgeGroup( + collectionName, groupKind, groupName, canonicalUrls, ingestedVersions, payloadCount.pointCount()); + } } diff --git a/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java b/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java index 50c8b966..c015805b 100644 --- a/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java +++ b/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java @@ -27,7 +27,7 @@ public final class DocsSourceRegistry { private static final String OFFICIAL_DOCUMENTATION_SOURCE_KIND = "official"; private static final String LOCAL_DOCS_ROOT = "/data/docs/"; private static final String LOCAL_DOCS_BOOKS = LOCAL_DOCS_ROOT + "books/"; - private static final String PUBLIC_PDFS_BASE = "/pdfs/"; + public static final String PUBLIC_PDFS_BASE = "/pdfs/"; private static final String PDF_EXTENSION = ".pdf"; private static final String HTML_EXTENSION = ".html"; private static final String HTM_EXTENSION = ".htm"; @@ -804,6 +804,22 @@ public static List documentationSources() { return DOCUMENTATION_SOURCES; } + /** Returns every canonical documentation root registered for an exact ingested document set. */ + public static List citationBasesForDocSet(String docSet) { + if (docSet == null || docSet.isBlank()) { + return List.of(); + } + return Stream.concat( + DOCUMENTATION_SOURCES.stream() + .filter(source -> source.docSet().equals(docSet)) + .map(DocumentationSource::citationBaseUrl), + JAVA_API_DOCUMENTATION_SOURCES.stream() + .filter(source -> source.relativeMirrorPath().equals(docSet)) + .map(JavaApiDocumentationSource::remoteBaseUrl)) + .distinct() + .toList(); + } + /** Returns retrieval identities used by official-document filters. */ public static List officialDocumentationSourceIdentities() { return List.copyOf(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); diff --git a/src/main/java/com/williamcallahan/javachat/domain/knowledge/KnowledgeGroup.java b/src/main/java/com/williamcallahan/javachat/domain/knowledge/KnowledgeGroup.java index 0ba40167..d290b212 100644 --- a/src/main/java/com/williamcallahan/javachat/domain/knowledge/KnowledgeGroup.java +++ b/src/main/java/com/williamcallahan/javachat/domain/knowledge/KnowledgeGroup.java @@ -1,9 +1,16 @@ package com.williamcallahan.javachat.domain.knowledge; +import java.util.List; import java.util.Objects; /** Describes one retrievable knowledge cohort and its exact stored chunk count. */ -public record KnowledgeGroup(String collection, Kind kind, String name, long chunks) { +public record KnowledgeGroup( + String collection, + Kind kind, + String name, + List canonicalUrls, + List ingestedVersions, + long chunks) { /** Closed inventory taxonomy exposed by the API and CLI. */ public enum Kind { BOOKS, @@ -18,11 +25,25 @@ public enum Kind { collection = requireText(collection, "collection"); kind = Objects.requireNonNull(kind, "kind"); name = requireText(name, "name"); + canonicalUrls = copyText(canonicalUrls, "canonicalUrls"); + if (canonicalUrls.isEmpty()) { + throw new IllegalArgumentException("canonicalUrls must not be empty"); + } + ingestedVersions = copyText(ingestedVersions, "ingestedVersions"); if (chunks < 0) { throw new IllegalArgumentException("chunks must not be negative"); } } + private static List copyText(List text, String fieldName) { + Objects.requireNonNull(text, fieldName); + return text.stream() + .map(value -> requireText(value, fieldName)) + .distinct() + .sorted() + .toList(); + } + private static String requireText(String text, String fieldName) { Objects.requireNonNull(text, fieldName); if (text.isBlank()) { diff --git a/src/main/java/com/williamcallahan/javachat/service/HybridVectorService.java b/src/main/java/com/williamcallahan/javachat/service/HybridVectorService.java index fa561637..42e48ad5 100644 --- a/src/main/java/com/williamcallahan/javachat/service/HybridVectorService.java +++ b/src/main/java/com/williamcallahan/javachat/service/HybridVectorService.java @@ -248,6 +248,26 @@ public long countPointsForUrl(String collectionName, String url) { * distinct-value count exceeds {@value #FACET_VALUE_LIMIT} */ public List facetPayloadValues(String collectionName, String payloadField) { + return facetPayloadValues(collectionName, payloadField, FacetCounts.newBuilder()); + } + + /** Counts distinct values within points matching one exact keyword payload value. */ + public List facetPayloadValues( + String collectionName, String payloadField, String filterField, String filterValue) { + Objects.requireNonNull(filterField, "filterField"); + Objects.requireNonNull(filterValue, "filterValue"); + if (filterField.isBlank() || filterValue.isBlank()) { + throw new IllegalArgumentException("facet filter field and value must not be blank"); + } + return facetPayloadValues( + collectionName, + payloadField, + FacetCounts.newBuilder() + .setFilter(Filter.newBuilder().addMust(matchKeyword(filterField, filterValue)))); + } + + private List facetPayloadValues( + String collectionName, String payloadField, FacetCounts.Builder facetRequest) { Objects.requireNonNull(collectionName, NULL_MESSAGE_COLLECTION_NAME); Objects.requireNonNull(payloadField, "payloadField"); if (collectionName.isBlank()) { @@ -259,7 +279,7 @@ public List facetPayloadValues(String collectionName, String List facetHits = RetrySupport.executeWithRetry( () -> QdrantFutureAwaiter.awaitFuture( - qdrantClient.facetAsync(FacetCounts.newBuilder() + qdrantClient.facetAsync(facetRequest .setCollectionName(collectionName) .setKey(payloadField) .setExact(true) diff --git a/src/test/java/com/williamcallahan/javachat/adapters/in/web/knowledge/KnowledgeBaseControllerTest.java b/src/test/java/com/williamcallahan/javachat/adapters/in/web/knowledge/KnowledgeBaseControllerTest.java index 6162074c..c1bea59d 100644 --- a/src/test/java/com/williamcallahan/javachat/adapters/in/web/knowledge/KnowledgeBaseControllerTest.java +++ b/src/test/java/com/williamcallahan/javachat/adapters/in/web/knowledge/KnowledgeBaseControllerTest.java @@ -39,15 +39,28 @@ class KnowledgeBaseControllerTest { void returnsGroupsAndAuthoritativeTotal() throws Exception { when(knowledgeBaseInventoryUseCase.listKnowledgeInventory()) .thenReturn(new KnowledgeInventory(List.of( - new KnowledgeGroup("chat-docs", KnowledgeGroup.Kind.DOCS, "oracle/javase/25/api", 10), new KnowledgeGroup( - "chat-github-repo", KnowledgeGroup.Kind.GITHUB, "https://github.com/acme/repo", 7)))); + "chat-docs", + KnowledgeGroup.Kind.DOCS, + "kotlin", + List.of("https://kotlinlang.org/docs/"), + List.of("2.4.10"), + 10), + new KnowledgeGroup( + "chat-github-repo", + KnowledgeGroup.Kind.GITHUB, + "https://github.com/acme/repo", + List.of("https://github.com/acme/repo"), + List.of("abc123"), + 7)))); mockMvc.perform(get("/api/knowledge/groups")) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalChunks").value(17)) .andExpect(jsonPath("$.groups[0].kind").value("DOCS")) .andExpect(jsonPath("$.groups[0].chunks").value(10)) + .andExpect(jsonPath("$.groups[0].canonicalUrls[0]").value("https://kotlinlang.org/docs/")) + .andExpect(jsonPath("$.groups[0].ingestedVersions[0]").value("2.4.10")) .andExpect(jsonPath("$.groups[1].kind").value("GITHUB")); } diff --git a/src/test/java/com/williamcallahan/javachat/application/knowledge/KnowledgeBaseInventoryUseCaseTest.java b/src/test/java/com/williamcallahan/javachat/application/knowledge/KnowledgeBaseInventoryUseCaseTest.java index 1e6ae5f9..7380f4c3 100644 --- a/src/test/java/com/williamcallahan/javachat/application/knowledge/KnowledgeBaseInventoryUseCaseTest.java +++ b/src/test/java/com/williamcallahan/javachat/application/knowledge/KnowledgeBaseInventoryUseCaseTest.java @@ -47,32 +47,97 @@ void setUp() { } @Test - void composesCurrentGroupsAndAuthoritativeTotal() { + void composesCurrentGroupsWithOwnFilteredVersionsAndAuthoritativeTotal() { when(hybridVectorService.facetPayloadValues(DOCS_COLLECTION, QdrantPayloadFieldSchema.DOC_SET_FIELD)) - .thenReturn(List.of(new PayloadValueCount("oracle/javase/25/api", 9))); + .thenReturn(List.of(new PayloadValueCount("kotlin", 9), new PayloadValueCount("groovy", 8))); when(hybridVectorService.facetPayloadValues(BOOKS_COLLECTION, QdrantPayloadFieldSchema.DOC_SET_FIELD)) .thenReturn(List.of(new PayloadValueCount("books/effective-java", 12))); when(hybridVectorService.facetPayloadValues(PDFS_COLLECTION, QdrantPayloadFieldSchema.DOC_SET_FIELD)) - .thenReturn(List.of(new PayloadValueCount("specs/jls-25", 4))); + .thenReturn(List.of(new PayloadValueCount("java/java25-complete", 4))); + when(hybridVectorService.facetPayloadValues( + DOCS_COLLECTION, + QdrantPayloadFieldSchema.DOC_VERSION_FIELD, + QdrantPayloadFieldSchema.DOC_SET_FIELD, + "kotlin")) + .thenReturn(List.of(new PayloadValueCount("2.4.10", 9))); + when(hybridVectorService.facetPayloadValues( + DOCS_COLLECTION, + QdrantPayloadFieldSchema.DOC_VERSION_FIELD, + QdrantPayloadFieldSchema.DOC_SET_FIELD, + "groovy")) + .thenReturn(List.of(new PayloadValueCount("5.0.7", 8))); + when(hybridVectorService.facetPayloadValues( + PDFS_COLLECTION, + QdrantPayloadFieldSchema.DOC_VERSION_FIELD, + QdrantPayloadFieldSchema.DOC_SET_FIELD, + "java/java25-complete")) + .thenReturn(List.of(new PayloadValueCount("25", 4))); when(gitHubCollectionDiscovery.refreshDiscoveredCollections()).thenReturn(List.of(GITHUB_COLLECTION)); when(hybridVectorService.facetPayloadValues(GITHUB_COLLECTION, QdrantPayloadFieldSchema.REPO_URL_FIELD)) .thenReturn(List.of(new PayloadValueCount("https://github.com/acme/widgets", 6))); + when(hybridVectorService.facetPayloadValues( + GITHUB_COLLECTION, + QdrantPayloadFieldSchema.COMMIT_HASH_FIELD, + QdrantPayloadFieldSchema.REPO_URL_FIELD, + "https://github.com/acme/widgets")) + .thenReturn(List.of(new PayloadValueCount("abc123", 6))); KnowledgeInventory inventory = new KnowledgeBaseInventoryUseCase( hybridVectorService, Optional.of(gitHubCollectionDiscovery)) .listKnowledgeInventory(); - assertEquals(31, inventory.totalChunks()); + assertEquals(39, inventory.totalChunks()); assertEquals( List.of( - new KnowledgeGroup(BOOKS_COLLECTION, KnowledgeGroup.Kind.BOOKS, "books/effective-java", 12), - new KnowledgeGroup(DOCS_COLLECTION, KnowledgeGroup.Kind.DOCS, "oracle/javase/25/api", 9), - new KnowledgeGroup(PDFS_COLLECTION, KnowledgeGroup.Kind.PDFS, "specs/jls-25", 4), new KnowledgeGroup( - GITHUB_COLLECTION, KnowledgeGroup.Kind.GITHUB, "https://github.com/acme/widgets", 6)), + BOOKS_COLLECTION, + KnowledgeGroup.Kind.BOOKS, + "books/effective-java", + List.of("/pdfs/"), + List.of(), + 12), + new KnowledgeGroup( + DOCS_COLLECTION, + KnowledgeGroup.Kind.DOCS, + "kotlin", + List.of("https://kotlinlang.org/docs/"), + List.of("2.4.10"), + 9), + new KnowledgeGroup( + DOCS_COLLECTION, + KnowledgeGroup.Kind.DOCS, + "groovy", + List.of("https://docs.groovy-lang.org/docs/groovy-5.0.7/html/documentation/"), + List.of("5.0.7"), + 8), + new KnowledgeGroup( + PDFS_COLLECTION, + KnowledgeGroup.Kind.PDFS, + "java/java25-complete", + List.of("https://docs.oracle.com/en/java/javase/25/docs/api/"), + List.of("25"), + 4), + new KnowledgeGroup( + GITHUB_COLLECTION, + KnowledgeGroup.Kind.GITHUB, + "https://github.com/acme/widgets", + List.of("https://github.com/acme/widgets"), + List.of("abc123"), + 6)), inventory.groups()); } + @Test + void rejectsAnIngestedDocumentationSetWithoutCanonicalSourceMetadata() { + when(hybridVectorService.facetPayloadValues(DOCS_COLLECTION, QdrantPayloadFieldSchema.DOC_SET_FIELD)) + .thenReturn(List.of(new PayloadValueCount("unregistered", 1))); + + KnowledgeBaseInventoryUseCase inventoryUseCase = + new KnowledgeBaseInventoryUseCase(hybridVectorService, Optional.of(gitHubCollectionDiscovery)); + + assertThrows(KnowledgeInventoryUnavailableException.class, inventoryUseCase::listKnowledgeInventory); + } + @Test void translatesFacetFailureToInventoryUnavailable() { when(hybridVectorService.facetPayloadValues(BOOKS_COLLECTION, QdrantPayloadFieldSchema.DOC_SET_FIELD)) diff --git a/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java b/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java index 44c6d6d8..307786e9 100644 --- a/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java @@ -281,6 +281,14 @@ void mapsEveryCanonicalDocumentationMirrorToItsCitationBaseUrl() { .count()); } + @Test + void resolvesCanonicalRootsByStoredDocumentSetRatherThanMirrorPath() { + assertEquals( + List.of("https://docs.groovy-lang.org/docs/groovy-5.0.7/html/documentation/"), + DocsSourceRegistry.citationBasesForDocSet("groovy")); + assertEquals(List.of(), DocsSourceRegistry.citationBasesForDocSet("groovy/5.0.7")); + } + @Test void assignsDistinctStorageUrlsToJavaPagesThatShareOneCanonicalSourceFile(@TempDir Path temporaryDirectory) { Path mirrorRoot = temporaryDirectory.resolve("jackson/2.22.2/api"); diff --git a/src/test/java/com/williamcallahan/javachat/service/HybridVectorServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/HybridVectorServiceTest.java index ffa7a97d..393013f1 100644 --- a/src/test/java/com/williamcallahan/javachat/service/HybridVectorServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/HybridVectorServiceTest.java @@ -1,5 +1,6 @@ package com.williamcallahan.javachat.service; +import static io.qdrant.client.ConditionFactory.matchKeyword; import static io.qdrant.client.PointIdFactory.id; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -294,6 +295,25 @@ void facetPayloadValuesReturnsExactCountsSortedByValue() { payloadValueCounts); } + @Test + void facetPayloadValuesAppliesAnExactPayloadFilter() { + ArgumentCaptor facetRequest = ArgumentCaptor.forClass(FacetCounts.class); + when(qdrantClient.facetAsync(facetRequest.capture())) + .thenReturn(Futures.immediateFuture(List.of(facetHit("2.4.10", 9L)))); + + List versions = hybridVectorService.facetPayloadValues( + COLLECTION_NAME, + QdrantPayloadFieldSchema.DOC_VERSION_FIELD, + QdrantPayloadFieldSchema.DOC_SET_FIELD, + "kotlin"); + + assertEquals(List.of(new PayloadValueCount("2.4.10", 9L)), versions); + assertTrue(facetRequest.getValue().hasFilter()); + assertEquals( + matchKeyword(QdrantPayloadFieldSchema.DOC_SET_FIELD, "kotlin"), + facetRequest.getValue().getFilter().getMust(0)); + } + @Test void facetPayloadValuesRejectsNonStringValues() { when(qdrantClient.facetAsync(any(FacetCounts.class))) diff --git a/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java b/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java index e73e7ab5..b7e5f25e 100644 --- a/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java @@ -79,14 +79,21 @@ void verifiedApiKeyReachesKnowledgeGroups() throws Exception { when(clerkApiKeyVerifier.verify(CLERK_API_KEY_SECRET)) .thenReturn(Optional.of(new VerifiedApiKey(CLERK_API_KEY_ID, CLERK_USER_ID))); when(knowledgeBaseInventoryUseCase.listKnowledgeInventory()) - .thenReturn(new KnowledgeInventory(List.of( - new KnowledgeGroup("chat-docs", KnowledgeGroup.Kind.DOCS, "oracle/javase/25/api", 10)))); + .thenReturn(new KnowledgeInventory(List.of(new KnowledgeGroup( + "chat-docs", + KnowledgeGroup.Kind.DOCS, + "kotlin", + List.of("https://kotlinlang.org/docs/"), + List.of("2.4.10"), + 10)))); mockMvc.perform(get("/api/knowledge/groups").header("Authorization", "Bearer " + CLERK_API_KEY_SECRET)) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalChunks").value(10)) .andExpect(jsonPath("$.groups[0].collection").value("chat-docs")) - .andExpect(jsonPath("$.groups[0].name").value("oracle/javase/25/api")); + .andExpect(jsonPath("$.groups[0].name").value("kotlin")) + .andExpect(jsonPath("$.groups[0].canonicalUrls[0]").value("https://kotlinlang.org/docs/")) + .andExpect(jsonPath("$.groups[0].ingestedVersions[0]").value("2.4.10")); } @Test From 8b3ab610ce4b23eab7c50b96a31468057414f029 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 00:51:12 -0700 Subject: [PATCH 04/29] fix(knowledge): return defensive metadata copies The record normalizes its lists at construction, but generated accessors still return the stored references and SpotBugs blocks publication. Override both accessors with List.copyOf so callers receive immutable lists and the ownership guarantee is visible to static analysis. - Preserve serialized values while satisfying EI_EXPOSE_REP analysis --- .../javachat/domain/knowledge/KnowledgeGroup.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/williamcallahan/javachat/domain/knowledge/KnowledgeGroup.java b/src/main/java/com/williamcallahan/javachat/domain/knowledge/KnowledgeGroup.java index d290b212..05d4bcb6 100644 --- a/src/main/java/com/williamcallahan/javachat/domain/knowledge/KnowledgeGroup.java +++ b/src/main/java/com/williamcallahan/javachat/domain/knowledge/KnowledgeGroup.java @@ -35,6 +35,16 @@ public enum Kind { } } + @Override + public List canonicalUrls() { + return List.copyOf(canonicalUrls); + } + + @Override + public List ingestedVersions() { + return List.copyOf(ingestedVersions); + } + private static List copyText(List text, String fieldName) { Objects.requireNonNull(text, fieldName); return text.stream() From f6516980bf760d149338ce70a2638a6d39b1f376 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 08:46:20 -0700 Subject: [PATCH 05/29] docs(readme): improve JavaChat onboarding Organize root and CLI docs around installation, first questions, local development, and artifact checks. --- README.md | 124 +++++++++++++++++++++++++----------------- cli/README.md | 148 +++++++++++++++++++++++++++++++------------------- 2 files changed, 167 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 0be3f113..60e9e35c 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,121 @@ -# Java Chat +# JavaChat -[![Java Chat Application](src/main/resources/static/images/java-chat-app.png)](https://javachat.ai) +[![JavaChat application](src/main/resources/static/images/java-chat-app.png)](https://javachat.ai) -Learn programming in Java and more with **streaming answers**, **citations**, and **guided lessons** grounded with a deep knowledge base of ingested documentation (RAG). +JavaChat answers software-development questions from retrieved documentation and source code. It +streams each answer as it is generated and shows the sources used, so you can verify the result +instead of accepting an uncited response. -Built with Spring Boot + WebFlux, Svelte, and Qdrant. +Use JavaChat in whichever form fits your workflow: -## Features +- **Web:** open [javachat.ai](https://javachat.ai) for chat and guided lessons. +- **Terminal:** install the [JavaChat CLI](cli/README.md) to ask the same grounded questions from + any directory. +- **Self-hosted development:** run the Svelte and Spring Boot application locally and connect it to + Qdrant and an OpenAI-compatible gateway. -- Streaming chat over SSE (`/api/chat/stream`) with a final `citation` event -- Guided learning mode (`/learn`) with lesson-scoped chat (`/api/guided/*`) -- Documentation ingestion pipeline (fetch → chunk → embed → dedupe → index) -- Chunking uses JTokkit's CL100K_BASE tokenizer (GPT-3.5/4 style) for token counting -- Embeddings use strict provider selection and fail fast when the provider is unavailable (no runtime fallbacks) +## Install the CLI -## Quick start +The CLI supports macOS and Linux and requires Node.js 24.18 or newer. + +```bash +npm install --global @wcallahan/javachat-cli +javachat auth login +javachat ask "How do Java records work?" +``` + +Use `javachat list all` to see the documentation packages, source repositories, versions, and +revisions available on the selected deployment. See the [CLI README](cli/README.md) for all +commands, non-interactive authentication, local development, and package verification. + +## What JavaChat provides + +- Citation-backed chat streamed over Server-Sent Events +- Guided lessons with separate, lesson-scoped conversations +- Version-aware retrieval across documentation and indexed GitHub repositories +- Exact source inventory, including canonical URLs and ingested versions or commit revisions +- A documentation pipeline that fetches, chunks, embeds, deduplicates, and indexes source material +- Hybrid Qdrant retrieval using dense and BM25 sparse vectors with reciprocal-rank fusion + +## Run the application locally ### Prerequisites -This project uses **Gradle Toolchains** with **BellSoft Liberica JDK 25** and **mise** (or **asdf**) for reproducible builds. +- BellSoft Liberica JDK 25 +- Node.js 24.18 +- Docker, when running the local Qdrant service +- `mise` or another Java version manager for the pinned development toolchain -#### Option 1: Using mise (recommended) +Install the pinned Java version: ```bash -# Install mise if you don't have it: https://mise.jdnow.dev/ mise install ``` -#### Option 2: Using asdf +Install and select the frontend's pinned Node.js version with `nvm`: ```bash -# Install asdf if you don't have it: https://asdf-vm.com/ -asdf plugin add java https://github.com/halcyon/asdf-java.git -asdf install +nvm install 24.18.0 +nvm use 24.18.0 ``` -**What happens**: Gradle Toolchains will auto-download BellSoft Liberica JDK 25 on first build if not present locally. The `mise`/`asdf` setup ensures your shell and IDE (IntelliJ) use the correct Java version. - -### Running +Then configure and start the full application: ```bash cp .env.example .env -# edit .env: set SPRING_PROFILE=local, shared generation collections, local state paths, -# and the shared gateway OPENAI_BASE_URL/OPENAI_API_KEY -make compose-up # fresh Qdrant 1.18.3 generation-specific volume -make dev # bootstraps missing schemas only on loopback Qdrant +# Set OPENAI_BASE_URL and OPENAI_API_KEY in .env. +make compose-up +make dev ``` -Open `http://localhost:8085/`. +Open [http://localhost:8085](http://localhost:8085). The local commands create missing Qdrant +schemas only for loopback connections; remote Qdrant deployments remain fail-closed. -### CLI development +For a fuller explanation of the environment variables, local collection setup, and optional +documentation ingestion, read [Getting started](docs/getting-started.md). -The CLI is its own npm package under `cli/`. Install it and run the source entrypoint from that -directory: +## Useful development commands + +```bash +make help # list repository-owned commands +make build # build the frontend and backend +make test # run shell contracts and JVM tests +make lint # run frontend and JVM static analysis +make health # check the running application +``` + +To work only on the npm package: ```bash cd cli npm install npm run dev -- --help -npm run dev -- --host http://localhost:8085 ask "How do Java records work?" +npm test ``` -See the **[CLI README](cli/README.md)** for authentication, inventory, production dogfood, -packaged-artifact checks, and the npm release procedure. Agents can reuse the repository's -**[JavaChat skill](skills/javachat/SKILL.md)** for source-grounded documentation questions. +## Index documentation and repositories -## Index documentation (RAG) +Documentation ingestion is optional for ordinary application development. Run it only after the +gateway and Qdrant preflight checks pass. ```bash -# Run only after the generation configuration and gateway/Qdrant preflight are valid. -make full-pipeline # fetch all docs + ingest into Qdrant -make process-all # ingest only (incremental, upload to Qdrant) +make full-pipeline REPO_PATH=/absolute/path/to/repository make process-github-repo REPO_URL=https://github.com/owner/repository make process-github-repo SYNC_EXISTING=1 make process-github-repo ``` -Ingestion writes dense + BM25 sparse vectors to four hybrid Qdrant collections, queried via RRF fusion. - -Full command reference (scrape flags, doc set filtering, HTTP API, full re-ingest): -**[docs/pipeline-commands.md](docs/pipeline-commands.md)** - -GitHub source repository ingestion details: -**[docs/github-repository-ingestion.md](docs/github-repository-ingestion.md)** +See [Pipeline commands](docs/pipeline-commands.md) for source selection and ingestion controls, and +[GitHub repository ingestion](docs/github-repository-ingestion.md) for repository synchronization. ## Documentation -Start with `docs/README.md`. - -## Contributing - -See `CONTRIBUTING.md`. +- [Documentation index](docs/README.md) +- [Getting started](docs/getting-started.md) +- [Architecture](docs/architecture.md) +- [HTTP and streaming API](docs/api.md) +- [Contributing](CONTRIBUTING.md) ## License -See `LICENSE.md`. +See [LICENSE.md](LICENSE.md). diff --git a/cli/README.md b/cli/README.md index 149549fc..a0910f27 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,103 +1,141 @@ # JavaChat CLI -Ask JavaChat from a terminal with the same retrieval and citations as the web application. +Ask JavaChat from any terminal and receive the same retrieved evidence and citations as the web +application. The CLI is useful when you want a documentation answer without leaving a repository, +editor, or remote shell. + +## Requirements + +- Node.js 24.18 or newer +- macOS or Linux +- A JavaChat account for interactive use, or an existing API key for automation + +The package has no runtime dependencies. ## Install ```bash npm install --global @wcallahan/javachat-cli +javachat --version +``` + +Authorize the default production deployment and ask a question: + +```bash javachat auth login +javachat auth status javachat ask "How do Java records work?" ``` -`javachat auth login` opens JavaChat in a browser. After sign-in and approval, the CLI stores the -personal Clerk API key in `$XDG_CONFIG_HOME/javachat/credentials.json` (or -`~/.config/javachat/credentials.json`) with POSIX owner-only permissions. The package supports -macOS and Linux. - -Use `JAVACHAT_API_KEY` for non-interactive environments and `JAVACHAT_HOST` to target another -JavaChat deployment. +Answers stream to standard output. When retrieval succeeds, the answer ends with a `Sources:` +section containing the cited documentation or source-repository URLs. ## Commands -- `javachat ask "question"` — ask a question with the same retrieval and citations as the website. -- `javachat auth login` / `javachat auth logout` — authorize or remove this machine's credential. -- `javachat auth status` — show who the stored key belongs to. -- `javachat list all` — list every ingested source with canonical URLs, versions or revisions, - and chunk counts. -- `javachat list knowledge` — alias for `javachat list all`. +```text +javachat ask "question" Ask a grounded question +javachat auth login Authorize this machine in a browser +javachat auth logout Revoke and remove the stored credential +javachat auth status Show the account for the selected deployment +javachat list all List every indexed source, URL, and version +javachat list knowledge Alias for list all +``` -Run `javachat`, `javachat --help`, or `javachat -h` for the complete command list. +Run `javachat`, `javachat --help`, or `javachat -h` for the complete command and option reference. -## Develop locally +### Inspect the available sources -The CLI has no runtime dependencies. Node.js 24.18 or newer and npm are sufficient: +Check the inventory before asking about an exact package or version: ```bash -cd cli -npm install -npm run dev -- --version -npm run dev -- --help +javachat list all ``` -Everything after `--` is passed to the CLI. It targets `https://javachat.ai` by default. Use -`--host` for another deployment: +Each group includes its canonical documentation or GitHub URL, exact ingested versions or commit +revisions, and indexed chunk count. `javachat list knowledge` produces the same output. + +### Start a fresh, verbose question ```bash -npm run dev -- --host http://localhost:8085 --help -npm run dev -- --host https://dev.javachat.ai --help -npm run dev -- --host https://javachat.ai --help +javachat --new --verbose ask \ + "According to the Java 25 Thread API, what does Thread.ofVirtual() return?" ``` -Credentials are stored separately for each host. Sign in to the host you intend to test: +`--new` starts a new conversation. `--verbose` writes retrieval progress to standard error while +the answer continues on standard output. Without `--new`, later questions resume the most recent +conversation for that host. + +## Authentication and deployments + +Credentials are stored separately for each JavaChat host. Interactive login writes the personal +credential to `$XDG_CONFIG_HOME/javachat/credentials.json`, or +`~/.config/javachat/credentials.json` when `XDG_CONFIG_HOME` is unset. On POSIX systems, the file +uses owner-only permissions. + +Browser-approved API keys expire after 30 days. On a machine with a browser available separately, +print the approval URL instead: ```bash -npm run dev -- --host https://javachat.ai auth login -npm run dev -- --host https://javachat.ai auth status +javachat --no-browser auth login ``` -For headless environments, set `JAVACHAT_API_KEY` instead of running `auth login`. Do not put API -keys in command arguments, package scripts, or committed files. +The approving browser must run on the same machine as the CLI because the callback listener uses +that machine's loopback interface. On a remote shell, use an existing `JAVACHAT_API_KEY` instead of +starting browser login. -## Dogfood the CLI +Use `--host` to select another deployment: -Exercise the inventory and a fresh, citation-backed answer against the target deployment: +```bash +javachat --host https://dev.javachat.ai auth login +javachat --host https://dev.javachat.ai auth status +javachat --host https://dev.javachat.ai list all +``` + +For CI or another non-interactive environment, provide an existing credential through +`JAVACHAT_API_KEY`. `JAVACHAT_HOST` changes the default deployment. ```bash -npm run dev -- --host https://javachat.ai list all -npm run dev -- --host https://javachat.ai --new --verbose ask \ - "Compare HikariCP 7.0.5 with Spring AI 1.1.5 and name every indexed version used as evidence." +JAVACHAT_HOST=https://javachat.ai \ +JAVACHAT_API_KEY="$EXISTING_JAVACHAT_API_KEY" \ +javachat --new ask "What does Thread.ofVirtual() return?" ``` -Then verify the package that npm would install, not only the source entrypoint: +Keep credentials out of command arguments, package scripts, logs, and committed files. + +## Develop the package + +From the repository's `cli/` directory: ```bash +npm install +npm run dev -- --version +npm run dev -- --help npm test -npm run pack:check -npm pack -npm install --global ./wcallahan-javachat-cli-0.0.1.tgz -javachat --version -javachat --host https://javachat.ai auth status -javachat --host https://javachat.ai list knowledge ``` -The tarball-installed `javachat` command uses the same per-host credential file as the source -entrypoint. +Everything after `npm run dev --` is passed to the CLI. To test against a locally running JavaChat +server: -## Publish the first npm release +```bash +npm run dev -- --host http://localhost:8085 ask "How do Java records work?" +``` -Package versions on npm are immutable. Confirm the prepared version and packed contents before -publishing from `cli/`: +## Verify the package artifact + +Test the tarball rather than relying only on the source entrypoint: ```bash npm test npm run pack:check -npm whoami -npm publish --access public -npm view @wcallahan/javachat-cli@0.0.1 version +npm pack +npm install --global ./wcallahan-javachat-cli-0.0.1.tgz +javachat --version +javachat auth status +javachat list knowledge ``` -Publishing a scoped public package requires control of the `wcallahan` npm scope and npm's current -two-factor authentication or granular access requirements. If `npm whoami` is not `wcallahan`, sign -in with `npm login` before publishing. Do not publish `0.0.1` until every source and tarball dogfood -check above passes; any correction after publication must use a new version. +The tarball-installed command uses the same host-scoped credential file as the source entrypoint. + +## License + +See [LICENSE.md](LICENSE.md). From 8b26605fce3102f90b7c127284509528dc868fa7 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 08:50:38 -0700 Subject: [PATCH 06/29] docs(readme): clarify retrieval and session behavior Document local CLI authentication and qualify source and session expectations. --- README.md | 7 ++++--- cli/README.md | 13 ++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 60e9e35c..6711346c 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ [![JavaChat application](src/main/resources/static/images/java-chat-app.png)](https://javachat.ai) -JavaChat answers software-development questions from retrieved documentation and source code. It -streams each answer as it is generated and shows the sources used, so you can verify the result -instead of accepting an uncited response. +JavaChat answers software-development questions from retrieved documentation and source code when +supporting sources are available. It streams each answer as it is generated and shows the sources +used. When retrieval cannot support a claim, JavaChat labels it source-unavailable rather than +presenting it as grounded. Use JavaChat in whichever form fits your workflow: diff --git a/cli/README.md b/cli/README.md index a0910f27..de41ec18 100644 --- a/cli/README.md +++ b/cli/README.md @@ -27,8 +27,9 @@ javachat auth status javachat ask "How do Java records work?" ``` -Answers stream to standard output. When retrieval succeeds, the answer ends with a `Sources:` -section containing the cited documentation or source-repository URLs. +Answers stream to standard output. When retrieval supplies supporting sources, the answer ends with +a `Sources:` section containing the cited documentation or source-repository URLs. Claims without +retrieved support are labeled source-unavailable rather than presented as grounded. ## Commands @@ -62,8 +63,9 @@ javachat --new --verbose ask \ ``` `--new` starts a new conversation. `--verbose` writes retrieval progress to standard error while -the answer continues on standard output. Without `--new`, later questions resume the most recent -conversation for that host. +the answer continues on standard output. Without `--new`, later questions made with a stored +interactive credential resume the most recent conversation for that host. Headless requests using +`JAVACHAT_API_KEY` always start a new session. ## Authentication and deployments @@ -114,9 +116,10 @@ npm test ``` Everything after `npm run dev --` is passed to the CLI. To test against a locally running JavaChat -server: +server, authenticate that host first: ```bash +npm run dev -- --host http://localhost:8085 auth login npm run dev -- --host http://localhost:8085 ask "How do Java records work?" ``` From 96daafac22354e39dcb39482314f3406f8cb0b8e Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 10:06:29 -0700 Subject: [PATCH 07/29] docs(cli): define JavaChat at every public entry point Every public entry point now introduces JavaChat independently. Version 0.0.1 is immutable, so the corrected CLI package must be released as 0.0.2. - Describe the sourced research workflow and representative verified coverage - Bump the CLI package metadata and lockfile to 0.0.2 --- README.md | 60 +++++++++++++++++++++++++++++++++---------- cli/README.md | 42 +++++++++++++++++++++++++----- cli/package-lock.json | 4 +-- cli/package.json | 4 +-- 4 files changed, 87 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 6711346c..935f728e 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,50 @@ [![JavaChat application](src/main/resources/static/images/java-chat-app.png)](https://javachat.ai) -JavaChat answers software-development questions from retrieved documentation and source code when -supporting sources are available. It streams each answer as it is generated and shows the sources -used. When retrieval cannot support a claim, JavaChat labels it source-unavailable rather than -presenting it as grounded. +JavaChat is a web application and command-line research assistant for software developers. It +searches a version-aware library of technical documentation before generating an answer. Use it to +learn an unfamiliar API, compare documented behavior across releases, or find the commands, +configuration, and migration details needed to move development work forward. Answers can include +links to the retrieved pages, and JavaChat is designed to make missing source coverage visible. Use JavaChat in whichever form fits your workflow: - **Web:** open [javachat.ai](https://javachat.ai) for chat and guided lessons. -- **Terminal:** install the [JavaChat CLI](cli/README.md) to ask the same grounded questions from - any directory. +- **Terminal:** install the [JavaChat CLI](cli/README.md) to research from any directory and receive + source links when retrieval finds supporting documentation. - **Self-hosted development:** run the Svelte and Spring Boot application locally and connect it to Qdrant and an OpenAI-compatible gateway. +## What JavaChat helps you do + +- **Learn APIs and platforms:** request a focused explanation, short example, applicable version, + and source pages you can inspect. +- **Research technical decisions:** investigate releases, migration paths, commands, configuration + rules, prerequisites, and operational caveats. +- **Move real work forward:** keep the CLI beside your code while implementing an integration, + debugging a library, planning an upgrade, or configuring a deployment. +- **See the source boundary:** JavaChat can retrieve the exact requested release or nearby releases + of the same technology when they are indexed, and can report when the requested evidence is absent. + +## Questions JavaChat can help research + +- “What does `Thread.ofVirtual()` return in Java 25, and what is the shortest documented example?” +- “When should I use `MULTISET` instead of a flat join in jOOQ 3.21.7?” +- “How do I add a 30-second delay between tasks in a Docker Swarm rolling update?” +- “What defaults can I configure with Spring AI 1.1.2 `ChatClient.Builder`?” +- “How does a Clerk session token differ from a backend Secret Key?” + +Representative coverage in the shared JavaChat knowledge index includes: + +- **Languages:** Java SE 21, 25, and 26; Kotlin 2.4.10; Groovy 5.0.7; Scala 3; and Python 3.14.7. +- **Libraries:** Spring Framework 7.0.7; Spring AI 1.1.x; four indexed Jackson 2.x/3.x releases; + jOOQ 3.21.7; HikariCP 7.x; and Lombok 1.18.46. +- **Platforms:** PostgreSQL 17/18, Docker, Cloudflare, Clerk, Dokploy, Traefik, Doppler, and Infisical. +- **Repository snapshots:** OpenAI Java, Anthropic SDKs, Langfuse, Dokploy, Infisical, and Traefik. + +Run `javachat list all` for the complete current inventory. Chat retrieves supporting material from +the documentation collections. + ## Install the CLI The CLI supports macOS and Linux and requires Node.js 24.18 or newer. @@ -29,14 +60,17 @@ Use `javachat list all` to see the documentation packages, source repositories, revisions available on the selected deployment. See the [CLI README](cli/README.md) for all commands, non-interactive authentication, local development, and package verification. -## What JavaChat provides +## How it works + +1. JavaChat searches the documentation index for material relevant to the question and requested + version. +2. The strongest matches are supplied as context for the answer. +3. The answer streams to the web app or CLI, with source links when retrieval returns citations. +4. Guided lessons keep their own conversations, while ordinary chat can continue a prior session. -- Citation-backed chat streamed over Server-Sent Events -- Guided lessons with separate, lesson-scoped conversations -- Version-aware retrieval across documentation and indexed GitHub repositories -- Exact source inventory, including canonical URLs and ingested versions or commit revisions -- A documentation pipeline that fetches, chunks, embeds, deduplicates, and indexes source material -- Hybrid Qdrant retrieval using dense and BM25 sparse vectors with reciprocal-rank fusion +The repository also contains the pipeline that fetches, chunks, embeds, deduplicates, and indexes +the source material. See [Architecture](docs/architecture.md) for Qdrant, dense and sparse search, +reciprocal-rank fusion, and streaming details. ## Run the application locally diff --git a/cli/README.md b/cli/README.md index de41ec18..273054b7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,8 +1,10 @@ # JavaChat CLI -Ask JavaChat from any terminal and receive the same retrieved evidence and citations as the web -application. The CLI is useful when you want a documentation answer without leaving a repository, -editor, or remote shell. +JavaChat is a hosted research and learning assistant at [javachat.ai](https://javachat.ai) for +software developers. It searches version-aware technical documentation before generating an +answer. Use the `javachat` CLI to learn an API, compare documented behavior across releases, or +find the commands, configuration, and migration details needed to move development work forward +without leaving your terminal. ## Requirements @@ -29,12 +31,40 @@ javachat ask "How do Java records work?" Answers stream to standard output. When retrieval supplies supporting sources, the answer ends with a `Sources:` section containing the cited documentation or source-repository URLs. Claims without -retrieved support are labeled source-unavailable rather than presented as grounded. +retrieved support may be preceded by `Source unavailable:`; treat that material as uncited and +verify it independently. + +## What it helps you do + +- Learn unfamiliar languages, libraries, SDKs, and platforms from retrieved documentation. +- Find APIs, commands, configuration rules, prerequisites, migration details, and operational + caveats while implementing, debugging, upgrading, or deploying software. +- Compare indexed releases without silently treating one version's behavior as another's. +- Inspect the cited pages when retrieval supplies supporting sources. + +## Questions to try + +- “What does `Thread.ofVirtual()` return in Java 25, and what is the shortest documented example?” +- “When should I use `MULTISET` instead of a flat join in jOOQ 3.21.7?” +- “How do I add a 30-second delay between tasks in a Docker Swarm rolling update?” +- “What defaults can I configure with Spring AI 1.1.2 `ChatClient.Builder`?” +- “How does a Clerk session token differ from a backend Secret Key?” + +Representative coverage in the shared JavaChat knowledge index includes: + +- **Languages:** Java SE 21, 25, and 26; Kotlin 2.4.10; Groovy 5.0.7; Scala 3; and Python 3.14.7. +- **Libraries:** Spring Framework 7.0.7; Spring AI 1.1.x; four indexed Jackson 2.x/3.x releases; + jOOQ 3.21.7; HikariCP 7.x; and Lombok 1.18.46. +- **Platforms:** PostgreSQL 17/18, Docker, Cloudflare, Clerk, Dokploy, Traefik, Doppler, and Infisical. +- **Repository snapshots:** OpenAI Java, Anthropic SDKs, Langfuse, Dokploy, Infisical, and Traefik. + +Use `javachat list all` for the complete current inventory. Chat retrieves supporting material from +the documentation collections. ## Commands ```text -javachat ask "question" Ask a grounded question +javachat ask "question" Ask a research question javachat auth login Authorize this machine in a browser javachat auth logout Revoke and remove the stored credential javachat auth status Show the account for the selected deployment @@ -131,7 +161,7 @@ Test the tarball rather than relying only on the source entrypoint: npm test npm run pack:check npm pack -npm install --global ./wcallahan-javachat-cli-0.0.1.tgz +npm install --global ./wcallahan-javachat-cli-0.0.2.tgz javachat --version javachat auth status javachat list knowledge diff --git a/cli/package-lock.json b/cli/package-lock.json index ec1788ce..781d6d75 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wcallahan/javachat-cli", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wcallahan/javachat-cli", - "version": "0.0.1", + "version": "0.0.2", "license": "SEE LICENSE IN LICENSE.md", "os": [ "darwin", diff --git a/cli/package.json b/cli/package.json index 2f729e48..fd2781e7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@wcallahan/javachat-cli", - "version": "0.0.1", - "description": "Ask Java Chat from any terminal, with the same retrieval and citations as javachat.ai.", + "version": "0.0.2", + "description": "Research languages, libraries, SDKs, and development tasks with JavaChat's version-aware documentation.", "license": "SEE LICENSE IN LICENSE.md", "bin": { "javachat": "bin/javachat.js" From aa8cd0e7c37412f425fe84c5cfa2bd8db12f6bcb Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 10:32:52 -0700 Subject: [PATCH 08/29] feat(ingestion): register phase-one research sources Register the requested CLI package and official documentation indexes for local retrieval. Keep source fetching and citation normalization aligned for the new single-document mirrors. - add JavaChat CLI, Mintlify, Fern, and Temporal documentation sources --- scripts/fetch_all_docs.sh | 7 ++- .../test_documentation_fetch_projection.sh | 55 ++++++++++++++----- .../javachat/config/DocsSourceRegistry.java | 36 ++++++++++++ .../config/DocsSourceRegistryTest.java | 12 ++++ 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/scripts/fetch_all_docs.sh b/scripts/fetch_all_docs.sh index 7694be9b..f68cf377 100755 --- a/scripts/fetch_all_docs.sh +++ b/scripts/fetch_all_docs.sh @@ -855,6 +855,10 @@ fetch_named_official_source() { amp-code) "$source_dispatch" fetch_source --url "https://ampcode.com/" --mirror-path "amp-code" --name "Amp Code CLI Manual" --source-version "current" --identity-regex "Amp" --cut-directories 0 --minimum-html-files 8 --seed-document-type html-links --seed-discovery-url "https://ampcode.com/manual" --seed-source-prefix "https://ampcode.com/" --seed-reject-regex '^https://ampcode\.com/(?:manual/appendix/legacy-permissions-rules\.txt$|(?!manual(?:/|$)).*)' --seed-url "https://ampcode.com/manual/orbs/oidc" --seed-url "https://ampcode.com/manual/sdk/python" --seed-url "https://ampcode.com/manual/sdk/typescript" ;; tinker) "$source_dispatch" fetch_source --url "https://tinker-docs.thinkingmachines.ai/" --mirror-path "tinker" --name "Tinker Documentation" --source-version "current" --identity-regex "Tinker" --cut-directories 0 --minimum-html-files 320 --seed-document-type xml-sitemap --seed-discovery-url "https://tinker-docs.thinkingmachines.ai/sitemap.xml" --seed-source-prefix "https://tinker-docs.thinkingmachines.ai/" ;; docker) "$source_dispatch" fetch_source --url "https://docs.docker.com/" --mirror-path "docker" --name "Docker Documentation" --source-version "current" --identity-regex "Docker" --cut-directories 0 --minimum-html-files 1600 --seed-document-type xml-sitemap --seed-discovery-url "https://docs.docker.com/sitemap.xml" --seed-source-prefix "https://docs.docker.com/" --seed-reject-regex '^https://docs\.docker\.com/(build/(buildkit/dockerfile-release-notes|release-notes)|enterprise/security/provisioning/scim|reference/(api/(dvp/latest|engine/version/v1\.[0-9]+|hub/latest|registry/latest)|cli/docker/(build|builder/build|exec|images|info|mcp/(feature|tools)/list|ps|pull|push|run))|scout/release-notes/cli)/$' ;; + javachat-cli) "$source_dispatch" fetch_source --url "https://www.npmjs.com/package/@wcallahan/javachat-cli" --mirror-path "javachat-cli" --name "JavaChat CLI 0.0.1 Package Documentation" --source-version "0.0.1-8b26605fce31" --identity-regex "JavaChat CLI" --cut-directories 0 --minimum-html-files 1 --plain-text-document-url "https://raw.githubusercontent.com/WilliamAGH/java-chat/8b26605fce3102f90b7c127284509528dc868fa7/cli/README.md" --plain-text-document-title "JavaChat CLI 0.0.1 Package Documentation" --minimum-plain-text-bytes 4000 --plain-text-required-text "# JavaChat CLI" --plain-text-required-text "@wcallahan/javachat-cli" ;; + mintlify) "$source_dispatch" fetch_source --url "https://www.mintlify.com/llms.txt" --mirror-path "mintlify" --name "Mintlify Documentation Index" --source-version "current" --identity-regex "Mintlify" --cut-directories 0 --minimum-html-files 1 --plain-text-document-url "https://www.mintlify.com/llms.txt" --plain-text-document-title "Mintlify Documentation Index" --minimum-plain-text-bytes 5000 --plain-text-required-text "# Mintlify" --plain-text-required-text "https://www.mintlify.com/docs/llms.txt" ;; + fern) "$source_dispatch" fetch_source --url "https://buildwithfern.com/llms.txt" --mirror-path "fern" --name "Fern Documentation Index" --source-version "current" --identity-regex "Fern Documentation" --cut-directories 0 --minimum-html-files 1 --plain-text-document-url "https://buildwithfern.com/llms.txt" --plain-text-document-title "Fern Documentation Index" --minimum-plain-text-bytes 40000 --plain-text-required-text "# Fern Documentation" --plain-text-required-text "https://buildwithfern.com/learn/llms.txt" ;; + temporal) "$source_dispatch" fetch_source --url "https://temporal.io/llms.txt" --mirror-path "temporal" --name "Temporal Documentation Index" --source-version "current" --identity-regex "Temporal" --cut-directories 0 --minimum-html-files 1 --plain-text-document-url "https://temporal.io/llms.txt" --plain-text-document-title "Temporal Documentation Index" --minimum-plain-text-bytes 11000 --plain-text-required-text "# Temporal" --plain-text-required-text "https://docs.temporal.io/llms.txt" ;; clerk) "$source_dispatch" fetch_source --url "https://clerk.com/docs/" --mirror-path "clerk" --name "Clerk Documentation" --source-version "current" --identity-regex "Clerk" --cut-directories 1 --minimum-html-files 1 --plain-text-document-url "https://clerk.com/docs/llms-full.txt" --plain-text-document-title "Clerk Documentation" --minimum-plain-text-bytes 500000 --plain-text-required-text "# Clerk" ;; traefik) "$source_dispatch" fetch_source --url "https://doc.traefik.io/traefik/" --mirror-path "traefik" --name "Traefik Proxy Documentation" --source-version "current" --identity-regex "Traefik Proxy" --cut-directories 1 --minimum-html-files 280 --seed-document-type xml-sitemap --seed-discovery-url "https://doc.traefik.io/sitemap.xml" --seed-source-prefix "https://doc.traefik.io/traefik/" ;; porkbun) "$source_dispatch" fetch_source --url "https://porkbun.com/api/json/v3/documentation" --mirror-path "porkbun" --name "Porkbun API v3.16 Documentation" --source-version "3.16" --identity-regex 'Porkbun API v3.*v3\.16|v3\.16.*Porkbun API' --required-identity-page "index.html" --required-identity-text "Porkbun API v3 — v3.16" --cut-directories 0 --minimum-html-files 1 --plain-text-document-url "https://porkbun.com/llms-full.txt" --plain-text-document-title "Porkbun API v3.16 Documentation" --minimum-plain-text-bytes 125000 --plain-text-required-text "# Porkbun API v3 — v3.16" --plain-text-required-text "## AI agents (MCP)" --plain-text-required-text "npx -y @porkbunllc/mcp-server" --plain-text-required-text "Retrieve all DNS records" --llm-sentinel-url "https://porkbun.com/llms.txt" --minimum-llm-sentinel-bytes 18000 --llm-sentinel-required-text "# Porkbun" --llm-sentinel-required-text "## Official MCP server" --llm-sentinel-required-text "https://porkbun.com/llms/agent-setup" ;; @@ -928,7 +932,8 @@ fetch_all_official_sources() { jackson-2.22.2-api jackson-spring-2.21.2-api \ jackson-3.2.2-api jackson-spring-3.1.2-api \ lombok-1.18.46-api lombok-1.18.46-reference anthropic-api claude-code amp-code tinker \ - docker clerk traefik dokploy infisical doppler-guides doppler-reference doppler-changelog \ + docker javachat-cli mintlify fern temporal \ + clerk traefik dokploy infisical doppler-guides doppler-reference doppler-changelog \ porkbun porkbun-mcp cloudflare \ spring-boot quarkus \ java/java21-complete java/java25-complete java/java26-complete \ diff --git a/scripts/test_documentation_fetch_projection.sh b/scripts/test_documentation_fetch_projection.sh index 17d149ec..8eff67c7 100644 --- a/scripts/test_documentation_fetch_projection.sh +++ b/scripts/test_documentation_fetch_projection.sh @@ -558,11 +558,9 @@ if ! grep -Fxq 'https://quarkus.io/guides/aesh/' "$QUARKUS_SEED_OUTPUT" \ fail_documentation_fetch_test "Quarkus extensionless guides did not project onto canonical directory URLs" fi -assert_current_documentation_source_dispatch() { +assert_documentation_source_dispatch() { local documentation_source_identifier="$1" - local expected_citation_base="$2" - local expected_mirror_path="$3" - local expected_discovery_url="$4" + shift if ! ( set -- # shellcheck source=fetch_all_docs.sh @@ -575,16 +573,43 @@ assert_current_documentation_source_dispatch() { } fetch_named_official_source "$documentation_source_identifier" ); then - fail_documentation_fetch_test "current documentation source dispatch failed: $documentation_source_identifier" - fi - if ! grep -Fxq -- "$expected_citation_base" "$ENVIRONMENT_OVERRIDE_CAPTURE" \ - || ! grep -Fxq -- "$expected_mirror_path" "$ENVIRONMENT_OVERRIDE_CAPTURE" \ - || ! grep -Fxq -- "$expected_discovery_url" "$ENVIRONMENT_OVERRIDE_CAPTURE"; then - fail_documentation_fetch_test "current documentation source dispatch lost its canonical boundary: $documentation_source_identifier" + fail_documentation_fetch_test "documentation source dispatch failed: $documentation_source_identifier" fi + local expected_argument + for expected_argument in "$@"; do + if ! grep -Fxq -- "$expected_argument" "$ENVIRONMENT_OVERRIDE_CAPTURE"; then + fail_documentation_fetch_test \ + "documentation source dispatch lost required evidence: $documentation_source_identifier $expected_argument" + fi + done } -assert_current_documentation_source_dispatch \ +assert_documentation_source_dispatch \ + javachat-cli \ + "https://raw.githubusercontent.com/WilliamAGH/java-chat/8b26605fce3102f90b7c127284509528dc868fa7/cli/README.md" \ + javachat-cli \ + "@wcallahan/javachat-cli" \ + "0.0.1-8b26605fce31" \ + "--plain-text-document-url" +assert_documentation_source_dispatch \ + mintlify \ + "https://www.mintlify.com/llms.txt" \ + mintlify \ + "https://www.mintlify.com/docs/llms.txt" \ + "--plain-text-document-url" +assert_documentation_source_dispatch \ + fern \ + "https://buildwithfern.com/llms.txt" \ + fern \ + "https://buildwithfern.com/learn/llms.txt" \ + "--plain-text-document-url" +assert_documentation_source_dispatch \ + temporal \ + "https://temporal.io/llms.txt" \ + temporal \ + "https://docs.temporal.io/llms.txt" \ + "--plain-text-document-url" +assert_documentation_source_dispatch \ anthropic-api \ "https://platform.claude.com/docs/en/" \ "anthropic/api" \ @@ -592,7 +617,7 @@ assert_current_documentation_source_dispatch \ if ! grep -Fxq -- '^https://platform\.claude\.com/docs/en/home$' "$ENVIRONMENT_OVERRIDE_CAPTURE"; then fail_documentation_fetch_test "Anthropic API dispatch retained its non-content landing shell" fi -assert_current_documentation_source_dispatch \ +assert_documentation_source_dispatch \ claude-code \ "https://code.claude.com/docs/en/" \ "anthropic/claude-code" \ @@ -679,7 +704,7 @@ assert_captured_arguments "$ENVIRONMENT_OVERRIDE_CAPTURE" \ "https://ampcode.com/manual/sdk/python" \ --seed-url \ "https://ampcode.com/manual/sdk/typescript" -assert_current_documentation_source_dispatch \ +assert_documentation_source_dispatch \ tinker \ "https://tinker-docs.thinkingmachines.ai/" \ tinker \ @@ -731,7 +756,7 @@ for required_porkbun_mcp_argument in \ fail_documentation_fetch_test "Porkbun MCP dispatch lost pinned official coverage: $required_porkbun_mcp_argument" fi done -assert_current_documentation_source_dispatch \ +assert_documentation_source_dispatch \ cloudflare \ "https://developers.cloudflare.com/" \ cloudflare \ @@ -811,7 +836,7 @@ assert_rejected_selector "kotlin,kotlin" assert_rejected_selector "kotlin,,java/java25-complete" assert_rejected_selector "all,kotlin" -assert_current_documentation_source_dispatch \ +assert_documentation_source_dispatch \ lombok-1.18.46-reference \ "https://projectlombok.org/features/" \ "lombok/1.18.46/reference" \ diff --git a/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java b/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java index c015805b..40a24300 100644 --- a/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java +++ b/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java @@ -299,6 +299,42 @@ public final class DocsSourceRegistry { "platform-reference", "current", DocumentationCitationPathStyle.EXTENSIONLESS_HTML), + new DocumentationSource( + "https://www.npmjs.com/package/@wcallahan/javachat-cli", + "javachat-cli", + "JavaChat CLI 0.0.1 Package Documentation", + "javachat-cli", + OFFICIAL_DOCUMENTATION_SOURCE_KIND, + "tool-reference", + "0.0.1-8b26605fce31", + DocumentationCitationPathStyle.SINGLE_DOCUMENT), + new DocumentationSource( + "https://www.mintlify.com/llms.txt", + "mintlify", + "Mintlify Documentation Index", + "mintlify", + OFFICIAL_DOCUMENTATION_SOURCE_KIND, + "platform-reference", + "current", + DocumentationCitationPathStyle.SINGLE_DOCUMENT), + new DocumentationSource( + "https://buildwithfern.com/llms.txt", + "fern", + "Fern Documentation Index", + "fern", + OFFICIAL_DOCUMENTATION_SOURCE_KIND, + "platform-reference", + "current", + DocumentationCitationPathStyle.SINGLE_DOCUMENT), + new DocumentationSource( + "https://temporal.io/llms.txt", + "temporal", + "Temporal Documentation Index", + "temporal", + OFFICIAL_DOCUMENTATION_SOURCE_KIND, + "platform-reference", + "current", + DocumentationCitationPathStyle.SINGLE_DOCUMENT), new DocumentationSource( "https://clerk.com/docs/llms-full.txt", "clerk", diff --git a/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java b/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java index 307786e9..469b912e 100644 --- a/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java @@ -369,6 +369,18 @@ void restoresExtensionlessCanonicalRoutesForMirroredPlatformDocumentation() { @Test void preservesCanonicalUrlsForSingleDocumentMirrors() { + assertEquals( + "https://www.npmjs.com/package/@wcallahan/javachat-cli", + DocsSourceRegistry.normalizeDocUrl("file:///data/docs/javachat-cli/index.html")); + assertEquals( + "https://www.mintlify.com/llms.txt", + DocsSourceRegistry.normalizeDocUrl("file:///data/docs/mintlify/index.html")); + assertEquals( + "https://buildwithfern.com/llms.txt", + DocsSourceRegistry.normalizeDocUrl("file:///data/docs/fern/index.html")); + assertEquals( + "https://temporal.io/llms.txt", + DocsSourceRegistry.normalizeDocUrl("file:///data/docs/temporal/index.html")); assertEquals( "https://porkbun.com/api/json/v3/documentation", DocsSourceRegistry.normalizeDocUrl("file:///data/docs/porkbun/index.html")); From e6844cfb4772d6fc3c905b15048bfa0e358c336d Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 11:50:19 -0700 Subject: [PATCH 09/29] feat(cli): add self-update and clearer source gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let npm-installed users upgrade the installation that owns the invoked binary while leaving source checkouts and temporary npx runs untouched. Answers remain useful when indexed documentation is absent and clearly label the model's general knowledge across chat and guided lessons. - add project-local and global npm update paths with boundary tests - publish package metadata and onboarding docs for version 0.0.3 - replace the abrupt unavailable-source label in shared answer guidance Validation receipts on final code bytes: - `cd cli && npm test`: 43/43 passed - `make test`: all shell contracts plus 1,133/1,133 JVM tests passed - `make lint`: frontend lint/type checks, ast-grep, SpotBugs, and PMD passed; only two pre-existing unrelated `ClickyAnalyticsInjector` legacy-name warnings - `cd cli && npm run pack:check`: four-file 0.0.3 artifact - `cd cli && npm publish --dry-run --access public`: public npm target, four files - temp-prefix tarball installation reported 0.0.3 and complete help including update - repo skill validator passed - `git diff --check` passed ADVERSARIAL: deep — independent gpt-5.6-terra review reconciled by root — 5 refuted, 0 retained, 0 added — dispositions: 0 fixed, 0 filed, 0 user-decision The reviewer’s only proposed P1 (bare npm install changes dev/optional dependency class) was refuted with live npm 12.0.2 trials: both devDependencies and optionalDependencies remained in their original sections. --- README.md | 7 +- cli/README.md | 27 ++- cli/bin/javachat.js | 104 ++++++++- cli/package-lock.json | 4 +- cli/package.json | 2 +- cli/test/javachat.test.js | 199 +++++++++++++++++- frontend/e2e/answer-accessibility.spec.ts | 6 +- skills/javachat/SKILL.md | 9 + .../javachat/config/SystemPromptConfig.java | 4 +- .../service/GuidedLearningService.java | 6 +- .../config/SystemPromptConfigTest.java | 10 +- .../GuidedLearningServiceCitationTest.java | 5 +- 12 files changed, 353 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 935f728e..842bd7b5 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Use JavaChat in whichever form fits your workflow: - **See the source boundary:** JavaChat can retrieve the exact requested release or nearby releases of the same technology when they are indexed, and can report when the requested evidence is absent. -## Questions JavaChat can help research +## Example queries possible with JavaChat - “What does `Thread.ofVirtual()` return in Java 25, and what is the shortest documented example?” - “When should I use `MULTISET` instead of a flat join in jOOQ 3.21.7?” @@ -56,6 +56,9 @@ javachat auth login javachat ask "How do Java records work?" ``` +Upgrade an npm-installed copy with `javachat update`. The command updates the project-local package +that owns the invoked binary, or the active global npm installation. + Use `javachat list all` to see the documentation packages, source repositories, versions, and revisions available on the selected deployment. See the [CLI README](cli/README.md) for all commands, non-interactive authentication, local development, and package verification. @@ -66,6 +69,8 @@ commands, non-interactive authentication, local development, and package verific version. 2. The strongest matches are supplied as context for the answer. 3. The answer streams to the web app or CLI, with source links when retrieval returns citations. + When matching source documents are absent, JavaChat still answers from the model's general + knowledge and labels that limitation in the answer. 4. Guided lessons keep their own conversations, while ordinary chat can continue a prior session. The repository also contains the pipeline that fetches, chunks, embeds, deduplicates, and indexes diff --git a/cli/README.md b/cli/README.md index 273054b7..a957a329 100644 --- a/cli/README.md +++ b/cli/README.md @@ -30,9 +30,9 @@ javachat ask "How do Java records work?" ``` Answers stream to standard output. When retrieval supplies supporting sources, the answer ends with -a `Sources:` section containing the cited documentation or source-repository URLs. Claims without -retrieved support may be preceded by `Source unavailable:`; treat that material as uncited and -verify it independently. +a `Sources:` section containing the cited documentation URLs. When matching source documents are +not available, JavaChat still answers from the model's general knowledge and states that the answer +is not supported by JavaChat source links. ## What it helps you do @@ -42,7 +42,7 @@ verify it independently. - Compare indexed releases without silently treating one version's behavior as another's. - Inspect the cited pages when retrieval supplies supporting sources. -## Questions to try +## Example queries possible with JavaChat - “What does `Thread.ofVirtual()` return in Java 25, and what is the shortest documented example?” - “When should I use `MULTISET` instead of a flat join in jOOQ 3.21.7?” @@ -68,12 +68,28 @@ javachat ask "question" Ask a research question javachat auth login Authorize this machine in a browser javachat auth logout Revoke and remove the stored credential javachat auth status Show the account for the selected deployment +javachat update Update the npm installation that owns this command javachat list all List every indexed source, URL, and version javachat list knowledge Alias for list all ``` Run `javachat`, `javachat --help`, or `javachat -h` for the complete command and option reference. +### Update the CLI + +```bash +javachat update +``` + +The command runs npm against the installation that owns the invoked `javachat` binary. A global +install is upgraded with the equivalent of `npm install --global @wcallahan/javachat-cli@latest`. +When `javachat` is a declared dependency in a project, the command instead runs the equivalent of +`npm install @wcallahan/javachat-cli@latest` in that project. Direct source and temporary `npx` +invocations are left unchanged. + +Versions before 0.0.3 do not contain `javachat update`. Upgrade those once with +`npm install --global @wcallahan/javachat-cli@latest`; later releases can use the command. + ### Inspect the available sources Check the inventory before asking about an exact package or version: @@ -161,8 +177,9 @@ Test the tarball rather than relying only on the source entrypoint: npm test npm run pack:check npm pack -npm install --global ./wcallahan-javachat-cli-0.0.2.tgz +npm install --global ./wcallahan-javachat-cli-0.0.3.tgz javachat --version +javachat update javachat auth status javachat list knowledge ``` diff --git a/cli/bin/javachat.js b/cli/bin/javachat.js index 0e9bb024..25bdd908 100755 --- a/cli/bin/javachat.js +++ b/cli/bin/javachat.js @@ -9,11 +9,12 @@ */ import { createServer } from "node:http"; -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { randomUUID, randomBytes } from "node:crypto"; +import { existsSync, realpathSync } from "node:fs"; import { hostname, homedir, platform } from "node:os"; import { mkdir, readFile, writeFile, chmod, rm } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { basename, dirname, join, resolve, sep } from "node:path"; import { stdout, stderr, argv, exit, env } from "node:process"; import { stripVTControlCharacters } from "node:util"; import packageMetadata from "../package.json" with { type: "json" }; @@ -24,6 +25,7 @@ const CREDENTIALS_DIRECTORY_MODE = 0o700; const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; const CITATION_DISPLAY_LIMIT = 5; const CLIENT_LABEL_MAX_LENGTH = 64; +const CLI_PACKAGE = packageMetadata.name; // The assistant is instructed to emit enrichment markers (SystemPromptConfig's // MARKER_USAGE_PROMPT); the web client renders each as a titled callout. Titles @@ -332,6 +334,99 @@ async function commandStatus(host) { return 0; } +/** Updates the npm installation that owns the invoked javachat command. */ +async function commandUpdate() { + const installTarget = await resolveNpmInstallTarget(); + stderr.write(`Updating ${CLI_PACKAGE} with npm...\n`); + const updateResult = spawnSync("npm", installTarget.npmArguments, { + cwd: installTarget.workingDirectory, + stdio: "inherit", + }); + if (updateResult.error) throw new Error(`Could not start npm: ${updateResult.error.message}`); + if (updateResult.signal) { + throw new Error(`npm install stopped after receiving ${updateResult.signal}.`); + } + const updateExitCode = updateResult.status ?? 1; + if (updateExitCode === 0) stdout.write("JavaChat CLI update complete.\n"); + return updateExitCode; +} + +/** Resolves a project-local or global npm install without guessing from the package scope. */ +async function resolveNpmInstallTarget() { + const invokedEntrypoint = argv[1] ? resolve(argv[1]) : ""; + if (basename(invokedEntrypoint) !== "javachat") { + throw new Error( + '"javachat update" must run through an npm-installed javachat command, not the source entrypoint.', + ); + } + + let packageRoot; + try { + packageRoot = dirname(dirname(realpathSync(invokedEntrypoint))); + } catch (entrypointFailure) { + throw new Error(`Could not resolve the installed javachat command: ${entrypointFailure.message}`); + } + if (existsSync(join(packageRoot, "package-lock.json")) && existsSync(join(packageRoot, "test"))) { + throw new Error( + '"javachat update" does not replace the repository checkout. Use npm install in cli/ for local development.', + ); + } + + const invokedDirectory = dirname(invokedEntrypoint); + if (basename(invokedDirectory) === ".bin" && basename(dirname(invokedDirectory)) === "node_modules") { + const projectRoot = dirname(dirname(invokedDirectory)); + if (projectRoot.split(sep).includes("_npx")) { + throw new Error('"javachat update" cannot update an ephemeral npx installation.'); + } + const projectManifest = await readProjectManifest(projectRoot); + const declaresJavaChat = [ + projectManifest.dependencies, + projectManifest.devDependencies, + projectManifest.optionalDependencies, + ].some((dependencyGroup) => Object.hasOwn(dependencyGroup ?? {}, CLI_PACKAGE)); + if (!declaresJavaChat) { + throw new Error( + `The project at ${projectRoot} does not declare ${CLI_PACKAGE}; npm will not be allowed to modify it.`, + ); + } + return { + npmArguments: ["install", `${CLI_PACKAGE}@latest`], + workingDirectory: projectRoot, + }; + } + + const prefixResult = spawnSync("npm", ["prefix", "--global"], { encoding: "utf8" }); + if (prefixResult.error) { + throw new Error(`Could not ask npm for its global prefix: ${prefixResult.error.message}`); + } + const globalPrefixOutput = prefixResult.stdout?.trim(); + if (prefixResult.status !== 0 || !globalPrefixOutput) { + throw new Error("npm could not identify its global installation prefix."); + } + const globalPrefix = resolve(globalPrefixOutput); + if (resolve(invokedDirectory) !== join(globalPrefix, "bin")) { + throw new Error( + '"javachat update" could not identify this command as a project-local or global npm installation.', + ); + } + return { + npmArguments: ["install", "--global", `${CLI_PACKAGE}@latest`], + workingDirectory: globalPrefix, + }; +} + +async function readProjectManifest(projectRoot) { + try { + const projectManifest = JSON.parse(await readFile(join(projectRoot, "package.json"), "utf8")); + if (!projectManifest || typeof projectManifest !== "object" || Array.isArray(projectManifest)) { + throw new Error("package.json must contain an object"); + } + return projectManifest; + } catch (manifestFailure) { + throw new Error(`Could not read ${join(projectRoot, "package.json")}: ${manifestFailure.message}`); + } +} + /** * Lists the document groups ingested in the deployment's knowledge base. * @@ -648,6 +743,7 @@ Usage javachat auth login Authorize this machine in your browser javachat auth logout Remove the local credential javachat auth status Show who the stored key belongs to + javachat update Update this npm installation javachat list all List every ingested source, URL, and version javachat list knowledge Alias for list all @@ -731,6 +827,10 @@ async function main() { stdout.write(USAGE); return 0; } + if (first === "update") { + if (rest.length > 0) throw new Error("javachat update takes no arguments."); + return await commandUpdate(); + } const normalizedHost = normalizeHost(host); if (first === "auth") { const [authCommandName, ...authArguments] = rest; diff --git a/cli/package-lock.json b/cli/package-lock.json index 781d6d75..88a617d6 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wcallahan/javachat-cli", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wcallahan/javachat-cli", - "version": "0.0.2", + "version": "0.0.3", "license": "SEE LICENSE IN LICENSE.md", "os": [ "darwin", diff --git a/cli/package.json b/cli/package.json index fd2781e7..ceef3a5c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wcallahan/javachat-cli", - "version": "0.0.2", + "version": "0.0.3", "description": "Research languages, libraries, SDKs, and development tasks with JavaChat's version-aware documentation.", "license": "SEE LICENSE IN LICENSE.md", "bin": { diff --git a/cli/test/javachat.test.js b/cli/test/javachat.test.js index 2fdc77df..286d3daa 100644 --- a/cli/test/javachat.test.js +++ b/cli/test/javachat.test.js @@ -4,17 +4,27 @@ import { createServer } from "node:http"; import { once } from "node:events"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { + chmod, + copyFile, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import packageMetadata from "../package.json" with { type: "json" }; const CLI_ENTRYPOINT = fileURLToPath(new URL("../bin/javachat.js", import.meta.url)); const TEST_API_KEY = "ak_secret_0123456789abcdef0123456789abcdef"; -function runCli(argumentsList, environmentVariables = {}) { +function runCli(argumentsList, environmentVariables = {}, entrypoint = CLI_ENTRYPOINT) { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [CLI_ENTRYPOINT, ...argumentsList], { + const child = spawn(process.execPath, [entrypoint, ...argumentsList], { env: { ...process.env, ...environmentVariables }, stdio: ["ignore", "pipe", "pipe"], }); @@ -31,6 +41,44 @@ function runCli(argumentsList, environmentVariables = {}) { }); } +async function createFakeNpm(testDirectory) { + const fakeBinDirectory = join(testDirectory, "fake-bin"); + const invocationLog = join(testDirectory, "npm-invocations.jsonl"); + const fakeNpm = join(fakeBinDirectory, "npm"); + await mkdir(fakeBinDirectory, { recursive: true }); + await writeFile( + fakeNpm, + `#!/usr/bin/env node +import { appendFileSync } from "node:fs"; +const argumentsList = process.argv.slice(2); +if (argumentsList.join(" ") === "prefix --global") { + process.stdout.write(process.env.TEST_NPM_GLOBAL_PREFIX + "\\n"); + process.exit(0); +} +appendFileSync(process.env.TEST_NPM_INVOCATION_LOG, JSON.stringify({ argumentsList, workingDirectory: process.cwd() }) + "\\n"); +process.exit(Number(process.env.TEST_NPM_EXIT_CODE || 0)); +`, + ); + await chmod(fakeNpm, 0o755); + return { fakeBinDirectory, invocationLog }; +} + +async function createInstalledCli(packageRoot, binaryPath) { + await mkdir(join(packageRoot, "bin"), { recursive: true }); + await mkdir(dirname(binaryPath), { recursive: true }); + await copyFile(CLI_ENTRYPOINT, join(packageRoot, "bin", "javachat.js")); + await copyFile(new URL("../package.json", import.meta.url), join(packageRoot, "package.json")); + await symlink(join(packageRoot, "bin", "javachat.js"), binaryPath); +} + +async function readNpmInvocations(invocationLog) { + return (await readFile(invocationLog, "utf8")) + .trim() + .split("\n") + .filter(Boolean) + .map((invocation) => JSON.parse(invocation)); +} + async function startBrowserLogin(host, configurationHome) { const child = spawn( process.execPath, @@ -98,6 +146,7 @@ test("prints usage without loading credentials", async () => { assert.match(helpExecution.standardOutput, /javachat auth login/); assert.match(helpExecution.standardOutput, /javachat auth logout/); assert.match(helpExecution.standardOutput, /javachat auth status/); + assert.match(helpExecution.standardOutput, /javachat update/); assert.match(helpExecution.standardOutput, /javachat list all/); assert.match(helpExecution.standardOutput, /javachat list knowledge/); assert.match(helpExecution.standardOutput, /--help, -h/); @@ -107,6 +156,123 @@ test("prints usage without loading credentials", async () => { assert.equal(helpExecutions[1].standardOutput, helpExecutions[2].standardOutput); }); +test("updates the global npm installation that owns the invoked command", async (testContext) => { + const testDirectory = await mkdtemp(join(tmpdir(), "javachat-cli-update-test-")); + testContext.after(() => rm(testDirectory, { recursive: true, force: true })); + const globalPrefix = join(testDirectory, "global-prefix"); + const packageRoot = join( + globalPrefix, + "lib", + "node_modules", + "@wcallahan", + "javachat-cli", + ); + const binaryPath = join(globalPrefix, "bin", "javachat"); + await createInstalledCli(packageRoot, binaryPath); + const { fakeBinDirectory, invocationLog } = await createFakeNpm(testDirectory); + + const cliExecution = await runCli( + ["update"], + { + JAVACHAT_HOST: "not a URL", + PATH: `${fakeBinDirectory}:${process.env.PATH}`, + TEST_NPM_GLOBAL_PREFIX: globalPrefix, + TEST_NPM_INVOCATION_LOG: invocationLog, + }, + binaryPath, + ); + + assert.equal(cliExecution.exitCode, 0); + assert.match(cliExecution.standardOutput, /JavaChat CLI update complete/); + assert.equal(cliExecution.standardError, "Updating @wcallahan/javachat-cli with npm...\n"); + assert.deepEqual(await readNpmInvocations(invocationLog), [ + { + argumentsList: ["install", "--global", "@wcallahan/javachat-cli@latest"], + workingDirectory: await realpath(globalPrefix), + }, + ]); +}); + +test("updates a project that declares a local JavaChat dependency", async (testContext) => { + const projectRoot = await mkdtemp(join(tmpdir(), "javachat-cli-project-test-")); + testContext.after(() => rm(projectRoot, { recursive: true, force: true })); + await writeFile( + join(projectRoot, "package.json"), + JSON.stringify({ dependencies: { "@wcallahan/javachat-cli": "0.0.2" } }), + ); + const packageRoot = join(projectRoot, "node_modules", "@wcallahan", "javachat-cli"); + const binaryPath = join(projectRoot, "node_modules", ".bin", "javachat"); + await createInstalledCli(packageRoot, binaryPath); + const { fakeBinDirectory, invocationLog } = await createFakeNpm(projectRoot); + + const cliExecution = await runCli( + ["update"], + { + PATH: `${fakeBinDirectory}:${process.env.PATH}`, + TEST_NPM_INVOCATION_LOG: invocationLog, + }, + binaryPath, + ); + + assert.equal(cliExecution.exitCode, 0); + assert.deepEqual(await readNpmInvocations(invocationLog), [ + { + argumentsList: ["install", "@wcallahan/javachat-cli@latest"], + workingDirectory: await realpath(projectRoot), + }, + ]); +}); + +test("refuses to update an npm link to the repository source", async (testContext) => { + const testDirectory = await mkdtemp(join(tmpdir(), "javachat-cli-source-update-test-")); + testContext.after(() => rm(testDirectory, { recursive: true, force: true })); + const globalPrefix = join(testDirectory, "global-prefix"); + const binaryPath = join(globalPrefix, "bin", "javachat"); + await mkdir(dirname(binaryPath), { recursive: true }); + await symlink(CLI_ENTRYPOINT, binaryPath); + const { fakeBinDirectory, invocationLog } = await createFakeNpm(testDirectory); + + const cliExecution = await runCli( + ["update"], + { + PATH: `${fakeBinDirectory}:${process.env.PATH}`, + TEST_NPM_GLOBAL_PREFIX: globalPrefix, + TEST_NPM_INVOCATION_LOG: invocationLog, + }, + binaryPath, + ); + + assert.equal(cliExecution.exitCode, 1); + assert.match(cliExecution.standardError, /does not replace the repository checkout/); + await assert.rejects(readFile(invocationLog, "utf8"), { code: "ENOENT" }); +}); + +test("propagates an npm update failure", async (testContext) => { + const projectRoot = await mkdtemp(join(tmpdir(), "javachat-cli-update-failure-test-")); + testContext.after(() => rm(projectRoot, { recursive: true, force: true })); + await writeFile( + join(projectRoot, "package.json"), + JSON.stringify({ devDependencies: { "@wcallahan/javachat-cli": "0.0.2" } }), + ); + const packageRoot = join(projectRoot, "node_modules", "@wcallahan", "javachat-cli"); + const binaryPath = join(projectRoot, "node_modules", ".bin", "javachat"); + await createInstalledCli(packageRoot, binaryPath); + const { fakeBinDirectory, invocationLog } = await createFakeNpm(projectRoot); + + const cliExecution = await runCli( + ["update"], + { + PATH: `${fakeBinDirectory}:${process.env.PATH}`, + TEST_NPM_EXIT_CODE: "7", + TEST_NPM_INVOCATION_LOG: invocationLog, + }, + binaryPath, + ); + + assert.equal(cliExecution.exitCode, 7); + assert.doesNotMatch(cliExecution.standardOutput, /update complete/); +}); + test("prints the installed version without loading credentials", async () => { const cliExecution = await runCli(["--version"], { XDG_CONFIG_HOME: CLI_ENTRYPOINT, @@ -199,6 +365,31 @@ test("streams text and citations through the public API", async (testContext) => assert.equal(identityRequestCount, 0); }); +test("prints a general-knowledge answer without inventing sources", async (testContext) => { + const apiServer = createServer((request, response) => { + assert.equal(request.url, "/api/chat/stream"); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end( + 'event: text\ndata: {"text":"The API behaves this way.\\n\\nNote: Matching source documents for Example SDK 3 were not available to JavaChat, so this answer uses the model\'s general knowledge."}\n\n', + ); + }); + apiServer.listen(0, "127.0.0.1"); + await once(apiServer, "listening"); + testContext.after(() => apiServer.close()); + const apiAddress = apiServer.address(); + assert.equal(typeof apiAddress, "object"); + + const cliExecution = await runCli( + ["--host", `http://127.0.0.1:${apiAddress.port}`, "ask", "How does Example SDK 3 work?"], + { JAVACHAT_API_KEY: TEST_API_KEY }, + ); + + assert.equal(cliExecution.exitCode, 0); + assert.match(cliExecution.standardOutput, /Matching source documents for Example SDK 3/); + assert.match(cliExecution.standardOutput, /model's general knowledge/); + assert.doesNotMatch(cliExecution.standardOutput, /Source unavailable:|Sources:/); +}); + test("renders enrichment markers split across stream chunks", async (testContext) => { const apiServer = createServer((request, response) => { if (request.url === "/api/me") { diff --git a/frontend/e2e/answer-accessibility.spec.ts b/frontend/e2e/answer-accessibility.spec.ts index 0bbe457b..ddd7ad3b 100644 --- a/frontend/e2e/answer-accessibility.spec.ts +++ b/frontend/e2e/answer-accessibility.spec.ts @@ -13,7 +13,7 @@ const LONG_SOURCE_REQUEST = Array.from( const ANSWER_TEXT = "The retained Java and Spring records support this `transaction` summary.\n\n" + - "Source unavailable: jOOQ 3.20 official documentation. The jOOQ note below is general knowledge and is not verified by the Sources panel."; + "Note: Matching source documents for jOOQ 3.20 were not available to JavaChat, so this answer uses the model's general knowledge."; const CITATIONS = [ { @@ -59,9 +59,7 @@ for (const theme of ["dark", "light"] as const) { await openAnswerScreen(page, theme); - await expect( - page.getByText("Source unavailable: jOOQ 3.20 official documentation."), - ).toBeVisible(); + await expect(page.getByText(/Matching source documents for jOOQ 3\.20/)).toBeVisible(); await page.getByRole("button", { name: "2 sources" }).click(); await expect(page.getByRole("link", { name: /CompletableFuture/ })).toHaveAttribute( "href", diff --git a/skills/javachat/SKILL.md b/skills/javachat/SKILL.md index ec2f5f7f..65c633da 100644 --- a/skills/javachat/SKILL.md +++ b/skills/javachat/SKILL.md @@ -20,6 +20,15 @@ javachat -h javachat --version ``` +When the user asks to upgrade a published installation, use the installation-aware updater: + +```bash +javachat update +``` + +The command updates the global npm installation or the declaring project's local dependency. It +refuses direct source and temporary `npx` invocations. + JavaChat requires Node.js 24.18.0 or newer. If the command is missing and the local checkout exists, install independent package bytes without publishing: diff --git a/src/main/java/com/williamcallahan/javachat/config/SystemPromptConfig.java b/src/main/java/com/williamcallahan/javachat/config/SystemPromptConfig.java index 3b6139c8..ae0d0d9e 100644 --- a/src/main/java/com/williamcallahan/javachat/config/SystemPromptConfig.java +++ b/src/main/java/com/williamcallahan/javachat/config/SystemPromptConfig.java @@ -31,7 +31,7 @@ public class SystemPromptConfig { static final String GENERATED_CONTROL_FLOW_CLAUSE = "Preserve the behavior described by every generated code example. In per-candidate processing, record exactly one terminal outcome for each candidate. Rejected, deferred, and failed branches must exit or otherwise prevent fall-through to success, and computed outcomes must never be ignored. For Kotlin `runCatching`, account explicitly for the value returned by `getOrElse` before recording success."; static final String SOURCE_FIDELITY_CLAUSE = - "Treat a material factual claim as retrieval-grounded when a provided [CTX n] SOURCE RECORD covers the claimed library or source family at the requested major version, or when it is explicitly labeled as adjacent same-family evidence for a missing requested version. Name that record's exact version inline with the claim, for example `per Jackson 2.22.2`, on every grounded claim; when it differs from the version the reader asked about, name both. Never substitute another dependency or related project, and never merge SOURCE RECORDS from different major versions into one claim: attribute each major separately and state how they differ. When an exact requested version is unavailable, answer using provided nearest lower and higher records from the same source family, name the requested and evidence versions, and explain any documented difference across that span; outside the indexed range, use the nearest available same-family version. When a SOURCE RECORD covering the span between the requested and retrieved versions documents a behavior change, state that change and the release that introduced it. Use clearly labeled general knowledge for the uncovered version delta. Never refuse an in-scope question solely because exact-version documentation is absent: missing evidence changes provenance, not answer availability. If no SOURCE RECORD covers the claimed dependency or source family at the requested major or an adjacent same-family version, write `Source unavailable: ` before presenting the claim, identify it as general knowledge, and never imply that the Sources panel verifies it."; + "Treat a material factual claim as retrieval-grounded when a provided [CTX n] SOURCE RECORD covers the claimed library or source family at the requested major version, or when it is explicitly labeled as adjacent same-family evidence for a missing requested version. Name that record's exact version inline with the claim, for example `per Jackson 2.22.2`, on every grounded claim; when it differs from the version the reader asked about, name both. Never substitute another dependency or related project, and never merge SOURCE RECORDS from different major versions into one claim: attribute each major separately and state how they differ. When an exact requested version is unavailable, answer using provided nearest lower and higher records from the same source family, name the requested and evidence versions, and explain any documented difference across that span; outside the indexed range, use the nearest available same-family version. When a SOURCE RECORD covering the span between the requested and retrieved versions documents a behavior change, state that change and the release that introduced it. Use clearly labeled general knowledge for the uncovered version delta. Never refuse an in-scope question solely because exact-version documentation is absent: missing evidence changes provenance, not answer availability. If no SOURCE RECORD covers the claimed dependency or source family at the requested major or an adjacent same-family version, answer the question as well as possible using general knowledge. Then write `Note: Matching source documents for were not available to JavaChat, so this answer uses the model's general knowledge.` Never imply that the Sources list verifies the general-knowledge answer."; private static final String MARKER_USAGE_PROMPT = """ - {{hint:Text here}} (Helpful Hints) - {{background:Text here}} (Background Context) @@ -92,7 +92,7 @@ If the user asks about a feature, answer for Java __JDK_VERSION__ (preview disab ## Citation Handling Do NOT include footnote references like [1], [2] or citation/reference sections in your response. - The UI automatically displays source citations from retrieved documents in a separate panel. + The client displays citations from retrieved documents separately as Sources. Simply reference sources naturally in prose when relevant (e.g., "the JDK documentation explains..."). ## Version Awareness diff --git a/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java b/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java index 037ac81c..a62a26dd 100644 --- a/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java +++ b/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java @@ -82,10 +82,8 @@ public class GuidedLearningService { private static final String OFFICIAL_DOCUMENTATION_GUIDANCE_TEMPLATE = "You are a learning assistant for %s. Use" + " the canonical curated lesson context %s.%s Use" + " ONLY the canonical curated lesson and retrieved official documentation context from these allowed docSet" - + " values for factual claims: %s. If those sources do not cover a topic, say so plainly; do not answer from" - + " general knowledge, except for a clearly labeled version delta when the exact requested version is absent" - + " and adjacent same-family evidence is supplied. This lesson source policy otherwise overrides any" - + " general-knowledge fallback instruction. Do NOT" + + " values for retrieval-grounded factual claims: %s. If those sources do not cover an in-scope topic," + + " answer it from general knowledge under the core source-attribution rules. Do NOT" + " include footnote references like [1] or a citations section; the UI shows sources separately. Embed" + " learning aids according to the core system instructions. Prefer short, correct" + " explanations with clear code examples when appropriate. If unsure, state the limitation.%n%n## Current" diff --git a/src/test/java/com/williamcallahan/javachat/config/SystemPromptConfigTest.java b/src/test/java/com/williamcallahan/javachat/config/SystemPromptConfigTest.java index a9c6bd5a..97392010 100644 --- a/src/test/java/com/williamcallahan/javachat/config/SystemPromptConfigTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/SystemPromptConfigTest.java @@ -70,7 +70,7 @@ void shouldRequireExactlyOneTerminalOutcomePerGeneratedCandidate() { } @Test - void shouldExposeExactUnavailableSourcesBeforeUnsupportedClaims() { + void shouldAnswerUnsupportedClaimsWithClearlyLabeledGeneralKnowledge() { String corePrompt = systemPromptConfig.getCoreSystemPrompt(); assertAll( @@ -81,8 +81,12 @@ void shouldExposeExactUnavailableSourcesBeforeUnsupportedClaims() { () -> assertTrue(corePrompt.contains("Never substitute another dependency or related project")), () -> assertTrue(corePrompt.contains("missing evidence changes provenance, not answer availability")), () -> assertTrue(corePrompt.contains("Never refuse an in-scope question")), - () -> assertTrue(corePrompt.contains("Source unavailable: ")), - () -> assertTrue(corePrompt.contains("never imply that the Sources panel verifies it"))); + () -> assertTrue(corePrompt.contains( + "Matching source documents for were not available to JavaChat")), + () -> assertTrue( + corePrompt.contains("answer the question as well as possible using general knowledge")), + () -> assertTrue(corePrompt.contains("Never imply that the Sources list verifies")), + () -> assertFalse(corePrompt.contains("Source unavailable:"))); } /** diff --git a/src/test/java/com/williamcallahan/javachat/service/GuidedLearningServiceCitationTest.java b/src/test/java/com/williamcallahan/javachat/service/GuidedLearningServiceCitationTest.java index fd262bcc..ed884741 100644 --- a/src/test/java/com/williamcallahan/javachat/service/GuidedLearningServiceCitationTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/GuidedLearningServiceCitationTest.java @@ -147,6 +147,8 @@ void guidedEnrichmentAndPromptFlowsUseTheLessonOfficialDocSetConstraint() throws assertFalse(guidanceCaptor.getValue().contains("## Work with text using `String`")); assertTrue(guidanceCaptor.getValue().contains("copy one complete fenced example byte-for-byte")); assertTrue(guidanceCaptor.getValue().contains("Do not transcribe it from memory")); + assertTrue(guidanceCaptor.getValue().contains("answer it from general knowledge")); + assertFalse(guidanceCaptor.getValue().contains("Source unavailable:")); } @Test @@ -341,8 +343,7 @@ void missingJavaReleaseUsesNearestOlderAndNewerDocumentation() { ArgumentCaptor guidanceCaptor = ArgumentCaptor.forClass(String.class); verify(chatService) .buildStructuredPromptWithContextAndGuidance(any(), anyString(), any(), guidanceCaptor.capture()); - assertTrue(guidanceCaptor.getValue().contains("clearly labeled version delta")); - assertTrue(guidanceCaptor.getValue().contains("adjacent same-family evidence")); + assertTrue(guidanceCaptor.getValue().contains("core source-attribution rules")); } @Test From 1a1befdc6085e79043e406032219bd6ce8cb8d74 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 12:26:44 -0700 Subject: [PATCH 10/29] fix(ingestion): snapshot local staging scripts Keep one staging run isolated from live script edits. --- .gitignore | 1 + scripts/lib/common_qdrant.sh | 3 +- scripts/run_local_embedding_staging.sh | 23 ++++- .../test_local_embedding_staging_contract.sh | 98 ++++++++++++++++++- 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 0b6664f4..acb92611 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,7 @@ bin/ !cli/bin/** dogfood-output/ lesson-curation-work/ +/.local-embedding-staging-run.*/ ### Build artifacts (root level) ### BOOT-INF/ diff --git a/scripts/lib/common_qdrant.sh b/scripts/lib/common_qdrant.sh index 7eeddfe0..94fc4b5e 100755 --- a/scripts/lib/common_qdrant.sh +++ b/scripts/lib/common_qdrant.sh @@ -171,7 +171,8 @@ build_application() { local log_file="${1:-/dev/null}" echo -e "${YELLOW}Building application...${NC}" cd "$PROJECT_ROOT" || exit 1 - if ! ./scripts/with_build_state_lock.sh ./gradlew buildForScripts --no-configuration-cache --quiet >> "$log_file" 2>&1; then + if ! "$COMMON_QDRANT_LIB_DIR/../with_build_state_lock.sh" \ + ./gradlew buildForScripts --no-configuration-cache --quiet >> "$log_file" 2>&1; then echo -e "${RED}Build failed. Last 20 lines of build output:${NC}" tail -20 "$log_file" 2>/dev/null || true exit 1 diff --git a/scripts/run_local_embedding_staging.sh b/scripts/run_local_embedding_staging.sh index abc6161f..97ea1b01 100755 --- a/scripts/run_local_embedding_staging.sh +++ b/scripts/run_local_embedding_staging.sh @@ -7,9 +7,28 @@ set -euo pipefail SCRIPT_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$SCRIPT_DIRECTORY/.." +readonly STAGED_SCRIPT_DIRECTORY_PREFIX=".local-embedding-staging-run." readonly EXPECTED_REPOSITORY_COUNT=23 readonly DOCUMENTATION_SETS="jooq/3.21/manual,jooq/3.21/api,python/3.14,postgresql/17,postgresql/18,hikaricp/7.1.0/api,hikaricp/7.0.2/api,jackson/2.22.2/api,jackson/2.21.2/api,jackson/3.2.2/api,jackson/3.1.2/api,lombok/1.18.46/api,lombok/1.18.46/reference,anthropic/api,anthropic/claude-code,amp-code,tinker,docker,traefik,dokploy,infisical,doppler/docs,doppler/reference,doppler/changelog,spring-framework/7.0.7/api" +cleanup_staged_scripts() { + local staged_directory="$1" + chmod -R u+w "$staged_directory" 2>/dev/null || true + rm -rf -- "$staged_directory" +} + +if [[ "$(basename "$SCRIPT_DIRECTORY")" == "$STAGED_SCRIPT_DIRECTORY_PREFIX"* ]]; then + trap 'cleanup_staged_scripts "$SCRIPT_DIRECTORY"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM +else + staged_script_directory="$(mktemp -d "$PROJECT_ROOT/$STAGED_SCRIPT_DIRECTORY_PREFIX"XXXXXX)" + trap 'cleanup_staged_scripts "$staged_script_directory"' EXIT + cp -R "$SCRIPT_DIRECTORY"/. "$staged_script_directory"/ + chmod -R a-w "$staged_script_directory" + exec "$staged_script_directory/run_local_embedding_staging.sh" "$@" +fi + cd "$PROJECT_ROOT" # shellcheck source=lib/common_qdrant.sh @@ -57,11 +76,11 @@ staging_timestamp() { } echo "LOCAL_STAGING_RESUME $(staging_timestamp) BATCH_SIZE=8 CONCURRENCY=8 DOCS=25 REPOS=$EXPECTED_REPOSITORY_COUNT" -./scripts/process_all_to_qdrant.sh --doc-sets="$DOCUMENTATION_SETS" +"$SCRIPT_DIRECTORY/process_all_to_qdrant.sh" --doc-sets="$DOCUMENTATION_SETS" for repository_path in "${repository_paths[@]}"; do echo "REPOSITORY_START $(staging_timestamp) $repository_path" - ./scripts/process_github_repo.sh --repo-path="$repository_path" + "$SCRIPT_DIRECTORY/process_github_repo.sh" --repo-path="$repository_path" echo "REPOSITORY_COMPLETE $(staging_timestamp) $repository_path" done diff --git a/scripts/test_local_embedding_staging_contract.sh b/scripts/test_local_embedding_staging_contract.sh index f0f16f93..ebd0d473 100644 --- a/scripts/test_local_embedding_staging_contract.sh +++ b/scripts/test_local_embedding_staging_contract.sh @@ -6,7 +6,17 @@ set -euo pipefail TEST_SCRIPT_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TEST_WORK_DIRECTORY="$(mktemp -d)" -trap 'rm -rf -- "$TEST_WORK_DIRECTORY"' EXIT +staging_process_id="" + +cleanup_local_staging_contract_test() { + if [ -n "$staging_process_id" ]; then + kill "$staging_process_id" 2>/dev/null || true + wait "$staging_process_id" 2>/dev/null || true + fi + rm -rf -- "$TEST_WORK_DIRECTORY" +} + +trap cleanup_local_staging_contract_test EXIT fail_local_staging_contract_test() { printf 'FAIL: %s\n' "$1" >&2 @@ -79,4 +89,90 @@ if [ "$writer_lease_line" -le "$repository_validation_line" ]; then fail_local_staging_contract_test "launcher contends on the writer lease before repository validation" fi +fake_project_root="$TEST_WORK_DIRECTORY/project" +fake_script_directory="$fake_project_root/scripts" +fake_binary_directory="$TEST_WORK_DIRECTORY/bin" +staging_trace="$TEST_WORK_DIRECTORY/staging.trace" +documentation_started_marker="$TEST_WORK_DIRECTORY/documentation.started" +documentation_release_marker="$TEST_WORK_DIRECTORY/documentation.release" +staging_output="$TEST_WORK_DIRECTORY/staging.output" +mkdir -p "$fake_script_directory/lib" "$fake_binary_directory" "$fake_project_root/data/repos/github/test" +cp "$launcher_path" "$fake_script_directory/run_local_embedding_staging.sh" + +for repository_index in {1..23}; do + mkdir -p "$fake_project_root/data/repos/github/test/repository-$repository_index/.git" +done + +printf '%s\n' \ + '#!/bin/bash' \ + 'exit 0' \ + > "$fake_binary_directory/git" +printf '%s\n' \ + '#!/bin/bash' \ + 'set -euo pipefail' \ + 'printf "DOCUMENTATION_STAGED\\n" >> "$STAGING_TRACE"' \ + 'touch "$DOCUMENTATION_STARTED_MARKER"' \ + 'while [ ! -f "$DOCUMENTATION_RELEASE_MARKER" ]; do sleep 0.05; done' \ + > "$fake_script_directory/process_all_to_qdrant.sh" +printf '%s\n' \ + '#!/bin/bash' \ + 'set -euo pipefail' \ + 'SCRIPT_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"' \ + 'source "$SCRIPT_DIRECTORY/lib/common_qdrant.sh"' \ + 'record_staged_library' \ + 'printf "REPOSITORY_STAGED\\n" >> "$STAGING_TRACE"' \ + > "$fake_script_directory/process_github_repo.sh" +printf '%s\n' \ + 'acquire_qdrant_writer_lease() { :; }' \ + 'record_staged_library() { printf "LIBRARY_STAGED\\n" >> "$STAGING_TRACE"; }' \ + > "$fake_script_directory/lib/common_qdrant.sh" +chmod +x \ + "$fake_binary_directory/git" \ + "$fake_script_directory/run_local_embedding_staging.sh" \ + "$fake_script_directory/process_all_to_qdrant.sh" \ + "$fake_script_directory/process_github_repo.sh" + +PATH="$fake_binary_directory:$PATH" \ +STAGING_TRACE="$staging_trace" \ +DOCUMENTATION_STARTED_MARKER="$documentation_started_marker" \ +DOCUMENTATION_RELEASE_MARKER="$documentation_release_marker" \ + "$fake_script_directory/run_local_embedding_staging.sh" > "$staging_output" 2>&1 & +staging_process_id=$! + +for ((wait_attempt = 0; wait_attempt < 200; wait_attempt++)); do + [ -f "$documentation_started_marker" ] && break + sleep 0.05 +done +if [ ! -f "$documentation_started_marker" ]; then + fail_local_staging_contract_test "staged documentation child did not start" +fi + +printf '#!/bin/bash\nexit 99\n' > "$fake_script_directory/run_local_embedding_staging.sh" +printf '#!/bin/bash\nexit 99\n' > "$fake_script_directory/process_all_to_qdrant.sh" +printf '#!/bin/bash\nexit 99\n' > "$fake_script_directory/process_github_repo.sh" +printf 'return 99\n' > "$fake_script_directory/lib/common_qdrant.sh" +touch "$documentation_release_marker" + +if ! wait "$staging_process_id"; then + staging_process_id="" + fail_local_staging_contract_test "staged launcher failed after live scripts changed" +fi +staging_process_id="" + +repository_staged_count="$(grep -c '^REPOSITORY_STAGED$' "$staging_trace" || true)" +library_staged_count="$(grep -c '^LIBRARY_STAGED$' "$staging_trace" || true)" +if [ "$repository_staged_count" -ne 23 ] || [ "$library_staged_count" -ne 23 ]; then + fail_local_staging_contract_test "staged launcher did not use all snapshotted repository children and libraries" +fi +if ! grep -q '^LOCAL_STAGING_COMPLETE ' "$staging_output"; then + fail_local_staging_contract_test "staged launcher did not reach its completion boundary" +fi +shopt -s nullglob +staging_residue=("$fake_project_root"/.local-embedding-staging-run.*) +shopt -u nullglob +if [ "${#staging_residue[@]}" -ne 0 ]; then + fail_local_staging_contract_test "staged launcher left a script snapshot behind" +fi + printf 'PASS: durable staging forces local Qdrant and masks cloud credentials across .env loading.\n' +printf 'PASS: durable staging executes one immutable script snapshot across live in-place edits.\n' From 478afdc85dd9357cd0de2c2bec5cbd1e9af68242 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 12:27:13 -0700 Subject: [PATCH 11/29] fix(auth): retain API-key availability after revoked responses Record a complete lifecycle response as provider availability. --- .../out/clerk/ClerkApiKeyVerifier.java | 2 +- .../out/clerk/ClerkApiKeyVerifierTest.java | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifier.java b/src/main/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifier.java index 22c3270a..2b3f9695 100644 --- a/src/main/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifier.java +++ b/src/main/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifier.java @@ -192,10 +192,10 @@ public Optional verify(String presentedSecret) { if (verification.revoked() == null || verification.expired() == null) { throw new ApiKeyOperationUnavailableException("Clerk returned incomplete API key lifecycle state"); } + markApiKeyFeatureEnabled(); if (verification.revoked() || verification.expired()) { return Optional.empty(); } - markApiKeyFeatureEnabled(); VerifiedApiKey verifiedApiKey = new VerifiedApiKey(verification.id(), verification.subject()); return Optional.of(verifiedApiKey); } diff --git a/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java b/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java index 9cfeb35f..19a56d74 100644 --- a/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java +++ b/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java @@ -17,6 +17,8 @@ import com.williamcallahan.javachat.application.auth.VerifiedApiKey; import java.util.Optional; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.test.web.client.MockRestServiceServer; @@ -238,6 +240,29 @@ void acceptsProviderValidBadRequestAvailabilityResponse() { clerkServer.verify(); } + @ParameterizedTest + @CsvSource({"true,false", "false,true"}) + void acceptsCompleteRevokedOrExpiredAvailabilityResponse(boolean revoked, boolean expired) { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer clerkServer = + MockRestServiceServer.bindTo(restClientBuilder).build(); + ClerkApiKeyVerifier verifier = new ClerkApiKeyVerifier(restClientBuilder.build(), CLERK_SECRET_KEY); + clerkServer + .expect(once(), requestTo(CLERK_VERIFY_ENDPOINT)) + .andRespond(withSuccess(""" + { + "id": "ak_0123456789abcdef0123456789abcdef", + "subject": "user_0123456789abcdefghijklmnopq", + "revoked": %s, + "expired": %s + } + """.formatted(revoked, expired), MediaType.APPLICATION_JSON)); + + assertTrue(verifier.isAvailable()); + assertTrue(verifier.isAvailable(), "the complete lifecycle response must not trigger another probe"); + clerkServer.verify(); + } + @Test void throttlesAvailabilityProbeAfterTransientProviderFailure() { RestClient.Builder restClientBuilder = RestClient.builder(); From ce8b434ca09f5abe9a4ae19ace615d045022d719 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 12:28:43 -0700 Subject: [PATCH 12/29] fix(auth): log unavailable API-key revocations Make failed revocation operations diagnosable without leaking credentials. --- .../web/AuthenticatedUserController.java | 5 +++ ...edUserEndpointSecurityIntegrationTest.java | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/main/java/com/williamcallahan/javachat/web/AuthenticatedUserController.java b/src/main/java/com/williamcallahan/javachat/web/AuthenticatedUserController.java index 18d6c40e..5c5d6e56 100644 --- a/src/main/java/com/williamcallahan/javachat/web/AuthenticatedUserController.java +++ b/src/main/java/com/williamcallahan/javachat/web/AuthenticatedUserController.java @@ -4,6 +4,8 @@ import com.williamcallahan.javachat.application.auth.ApiKeyLifecycle; import com.williamcallahan.javachat.application.auth.ApiKeyOperationUnavailableException; import com.williamcallahan.javachat.domain.errors.ApiErrorResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.CacheControl; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -27,6 +29,8 @@ @RequestMapping("/api") @PreAuthorize("isAuthenticated()") public final class AuthenticatedUserController { + private static final Logger log = LoggerFactory.getLogger(AuthenticatedUserController.class); + private static final String API_KEY_REVOCATION_UNAVAILABLE_LOG_MESSAGE = "Clerk API key revocation was unavailable"; private final ApiKeyLifecycle apiKeyLifecycle; @@ -66,6 +70,7 @@ public ResponseEntity revokeCurrentApiKey(Authentication authentication) { try { apiKeyLifecycle.revoke(clerkApiKey.getCredentials()); } catch (ApiKeyOperationUnavailableException unavailableClerk) { + log.error(API_KEY_REVOCATION_UNAVAILABLE_LOG_MESSAGE, unavailableClerk); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) .body(ApiErrorResponse.error("API key revocation is temporarily unavailable. Please retry.")); } diff --git a/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java b/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java index b7e5f25e..e9eedf5c 100644 --- a/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java @@ -1,5 +1,8 @@ package com.williamcallahan.javachat.web; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; @@ -8,17 +11,23 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; import com.williamcallahan.javachat.adapters.out.clerk.ClerkApiKeyVerifier; +import com.williamcallahan.javachat.application.auth.ApiKeyOperationUnavailableException; import com.williamcallahan.javachat.application.auth.VerifiedApiKey; import com.williamcallahan.javachat.application.knowledge.KnowledgeBaseInventoryUseCase; import com.williamcallahan.javachat.domain.knowledge.KnowledgeGroup; import com.williamcallahan.javachat.domain.knowledge.KnowledgeInventory; import com.williamcallahan.javachat.service.EmbeddingClient; +import com.williamcallahan.javachat.support.logging.ExpectedLogEvents; import io.qdrant.client.QdrantClient; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; import org.mockito.Answers; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -135,6 +144,35 @@ void verifiedApiKeyCanRevokeItself() throws Exception { verify(clerkApiKeyVerifier).revoke(CLERK_API_KEY_ID); } + @Test + void unavailableApiKeyRevocationReturns503AndLogsOneFailure() throws Exception { + when(clerkApiKeyVerifier.verify(CLERK_API_KEY_SECRET)) + .thenReturn(Optional.of(new VerifiedApiKey(CLERK_API_KEY_ID, CLERK_USER_ID))); + ApiKeyOperationUnavailableException revocationFailure = + new ApiKeyOperationUnavailableException("Clerk unavailable"); + doThrow(revocationFailure).when(clerkApiKeyVerifier).revoke(CLERK_API_KEY_ID); + Logger controllerLogger = (Logger) LoggerFactory.getLogger(AuthenticatedUserController.class); + + try (ExpectedLogEvents revocationLogEvents = ExpectedLogEvents.capture(controllerLogger)) { + mockMvc.perform(delete("/api/me/api-key").header("Authorization", "Bearer " + CLERK_API_KEY_SECRET)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.message") + .value("API key revocation is temporarily unavailable. Please retry.")); + + assertEquals(1, revocationLogEvents.events().size()); + ILoggingEvent revocationLogEvent = revocationLogEvents.events().getFirst(); + assertEquals(Level.ERROR, revocationLogEvent.getLevel()); + assertEquals("Clerk API key revocation was unavailable", revocationLogEvent.getFormattedMessage()); + assertEquals( + ApiKeyOperationUnavailableException.class.getName(), + revocationLogEvent.getThrowableProxy().getClassName()); + assertFalse(revocationLogEvent.getFormattedMessage().contains(CLERK_API_KEY_SECRET)); + assertFalse(revocationLogEvent.getFormattedMessage().contains(CLERK_API_KEY_ID)); + assertFalse(revocationLogEvent.getFormattedMessage().contains(CLERK_USER_ID)); + } + } + @Test void publicChatSurfaceStaysAnonymous() throws Exception { mockMvc.perform(get("/api/security/csrf")).andExpect(status().isOk()); From ccf4491d6fe2522823036046bda4af4400772491 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 12:29:13 -0700 Subject: [PATCH 13/29] fix(ingestion): surface local file enumeration failures Fail the CLI when documentation files cannot be enumerated. --- .../javachat/cli/DocumentProcessor.java | 28 +++++++------- .../DocumentProcessorFailureContractTest.java | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/cli/DocumentProcessor.java b/src/main/java/com/williamcallahan/javachat/cli/DocumentProcessor.java index 547c0dd0..f3cbd0d6 100644 --- a/src/main/java/com/williamcallahan/javachat/cli/DocumentProcessor.java +++ b/src/main/java/com/williamcallahan/javachat/cli/DocumentProcessor.java @@ -216,22 +216,22 @@ private ProcessingOutcome processDocumentationSet(final Path basePath, final Doc return new ProcessingOutcome.Failed(docSet.displayName(), 0, 0); } - final long fileCount = countEligibleFiles(docsPath); - if (fileCount <= 0) { - if (LOGGER.isDebugEnabled()) { - LOGGER.debug(LOG_SKIP_NO_ELIGIBLE); + try { + final long fileCount = countEligibleFiles(docsPath); + if (fileCount <= 0) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(LOG_SKIP_NO_ELIGIBLE); + } + return new ProcessingOutcome.Failed(docSet.displayName(), 0, 0); } - return new ProcessingOutcome.Failed(docSet.displayName(), 0, 0); - } - if (LOGGER.isInfoEnabled()) { - LOGGER.info(LOG_SECTION_LINE); - LOGGER.info(LOG_PROCESSING_SET); - LOGGER.info(LOG_FILES_TO_PROCESS, fileCount); - } + if (LOGGER.isInfoEnabled()) { + LOGGER.info(LOG_SECTION_LINE); + LOGGER.info(LOG_PROCESSING_SET); + LOGGER.info(LOG_FILES_TO_PROCESS, fileCount); + } - final long startMillis = System.currentTimeMillis(); - try { + final long startMillis = System.currentTimeMillis(); final IngestionLocalOutcome outcome = ingestionService.ingestLocalDirectory(docsPath.toString(), CLI_FILE_LIMIT); final int processed = outcome.backlog().processedFiles(); @@ -265,7 +265,7 @@ private ProcessingOutcome processDocumentationSet(final Path basePath, final Doc LOGGER.info(LOG_DOCSET_POSTCONDITION, safeDocumentationSet); return new ProcessingOutcome.Success(processed, duplicates); - } catch (IOException | IngestionAlreadyRunningException processingFailure) { + } catch (IOException | UncheckedIOException | IngestionAlreadyRunningException processingFailure) { if (LOGGER.isErrorEnabled()) { LOGGER.error(LOG_PROCESSING_FAILED, processingFailure.getClass().getSimpleName()); } diff --git a/src/test/java/com/williamcallahan/javachat/cli/DocumentProcessorFailureContractTest.java b/src/test/java/com/williamcallahan/javachat/cli/DocumentProcessorFailureContractTest.java index f0a7f958..cc9bfc5f 100644 --- a/src/test/java/com/williamcallahan/javachat/cli/DocumentProcessorFailureContractTest.java +++ b/src/test/java/com/williamcallahan/javachat/cli/DocumentProcessorFailureContractTest.java @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -21,8 +23,11 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; import java.util.List; import java.util.Objects; +import java.util.Set; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -160,6 +165,39 @@ void failsCliWhenAnotherProcessOwnsTheIngestionRun(@TempDir Path temporaryDirect assertFalse(containsLogMessage(DOCUMENT_PROCESSING_COMPLETE)); } + @Test + void failsCliWhenFileEnumerationFails(@TempDir Path temporaryDirectory) throws IOException { + assumeTrue( + Files.getFileStore(temporaryDirectory).supportsFileAttributeView(PosixFileAttributeView.class), + "test requires POSIX file permissions"); + String documentationDirectoryName = "unreadable-documentation-set"; + Path documentationDirectory = Files.createDirectory(temporaryDirectory.resolve(documentationDirectoryName)); + Path unreadableDirectory = Files.createDirectory(documentationDirectory.resolve("unreadable")); + Set originalPermissions = Files.getPosixFilePermissions(unreadableDirectory); + Files.setPosixFilePermissions(unreadableDirectory, Set.of()); + + try { + assumeFalse(Files.isReadable(unreadableDirectory), "test requires enforced POSIX file permissions"); + LocalDocumentationIngestionUseCase ingestionService = mock(LocalDocumentationIngestionUseCase.class); + DocumentProcessor documentProcessor = new DocumentProcessor(ingestionService, mock(ProgressTracker.class)); + DocumentationSet documentationSet = new DocumentationSet( + "Unreadable documentation set", documentationDirectoryName, documentationDirectoryName); + + DocumentProcessor.DocumentProcessingException thrown = assertThrows( + DocumentProcessor.DocumentProcessingException.class, + () -> documentProcessor.processDocumentationSets(temporaryDirectory, List.of(documentationSet))); + + assertEquals("Document processing completed with 1 failed documentation set(s)", thrown.getMessage()); + verify(ingestionService, never()) + .ingestLocalDirectory(documentationDirectory.toString(), EXPECTED_CLI_FILE_LIMIT); + assertTrue(containsLogMessage("Failed to process documentation set (exceptionType=UncheckedIOException)")); + assertTrue(containsLogMessage(DOCUMENT_PROCESSING_FAILED)); + assertFalse(containsLogMessage(DOCUMENT_PROCESSING_COMPLETE)); + } finally { + Files.setPosixFilePermissions(unreadableDirectory, originalPermissions); + } + } + private boolean containsLogMessage(final String expectedMessage) { return documentProcessorLogEvents.events().stream() .map(logEvent -> logEvent.getFormattedMessage()) From 1d56c328b1ba46a695a51dd5ca872796add5d18a Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 12:29:37 -0700 Subject: [PATCH 14/29] fix(docs): ignore blank citation base overrides Keep configured citation routes usable when an override is blank. --- .../javachat/config/DocsSourceRegistry.java | 4 ++-- .../config/DocsSourceRegistryTest.java | 20 ++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java b/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java index 40a24300..c8bd6548 100644 --- a/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java +++ b/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java @@ -914,11 +914,11 @@ public static Optional javaApiDocumentationSourceFor /** Resolves a citation base by preferring a JVM property, then process environment, then built-in default. */ static String resolveRuntimeBaseUrl(String settingKey, String defaultBaseUrl) { String systemPropertyBaseUrl = System.getProperty(settingKey); - if (systemPropertyBaseUrl != null) { + if (systemPropertyBaseUrl != null && !systemPropertyBaseUrl.isBlank()) { return systemPropertyBaseUrl; } String environmentBaseUrl = System.getenv(settingKey); - return environmentBaseUrl != null ? environmentBaseUrl : defaultBaseUrl; + return environmentBaseUrl != null && !environmentBaseUrl.isBlank() ? environmentBaseUrl : defaultBaseUrl; } private static Map buildLocalPrefixLookup() { diff --git a/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java b/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java index 469b912e..be727777 100644 --- a/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java @@ -14,6 +14,8 @@ import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * Verifies JVM citation and provenance source behavior. @@ -38,7 +40,8 @@ void resolvesSystemPropertyBeforeEnvironmentForRuntimeBaseUrl() { @Test void resolvesEnvironmentOrDefaultWhenSystemPropertyIsAbsent() { String environmentBaseUrl = System.getenv(ORACLE_JAVASE_BASE_SETTING); - String expectedBaseUrl = environmentBaseUrl == null ? DEFAULT_TEST_BASE_URL : environmentBaseUrl; + String expectedBaseUrl = + environmentBaseUrl == null || environmentBaseUrl.isBlank() ? DEFAULT_TEST_BASE_URL : environmentBaseUrl; withoutSystemProperty( ORACLE_JAVASE_BASE_SETTING, @@ -47,6 +50,21 @@ void resolvesEnvironmentOrDefaultWhenSystemPropertyIsAbsent() { DocsSourceRegistry.resolveRuntimeBaseUrl(ORACLE_JAVASE_BASE_SETTING, DEFAULT_TEST_BASE_URL))); } + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void ignoresBlankSystemPropertyForRuntimeBaseUrl(String blankSystemProperty) { + String environmentBaseUrl = System.getenv(ORACLE_JAVASE_BASE_SETTING); + String expectedBaseUrl = + environmentBaseUrl == null || environmentBaseUrl.isBlank() ? DEFAULT_TEST_BASE_URL : environmentBaseUrl; + + withSystemProperty( + ORACLE_JAVASE_BASE_SETTING, + blankSystemProperty, + () -> assertEquals( + expectedBaseUrl, + DocsSourceRegistry.resolveRuntimeBaseUrl(ORACLE_JAVASE_BASE_SETTING, DEFAULT_TEST_BASE_URL))); + } + @Test void returnsImmutableJavaApiDocumentationSourceSnapshot() { List javaApiDocumentationSources = DocsSourceRegistry.javaApiDocumentationSources(); From 1564c5eb7755ec702884e57a223c1847ffd4b776 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 12:30:08 -0700 Subject: [PATCH 15/29] fix(ingestion): preserve document citation provenance Retain page anchors and replace changed extraction semantics. --- .../javachat/service/DocumentFactory.java | 1 + .../LocalDocsFileIngestionProcessor.java | 6 +- .../support/JavaApiPageExtractor.java | 4 +- .../service/HtmlContentExtractorTest.java | 1 - .../service/RetrievalServiceCitationTest.java | 17 ++--- .../LocalDocsFileIngestionProcessorTest.java | 64 ++++++++++++++++++- 6 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/service/DocumentFactory.java b/src/main/java/com/williamcallahan/javachat/service/DocumentFactory.java index 488bcc17..6870d470 100644 --- a/src/main/java/com/williamcallahan/javachat/service/DocumentFactory.java +++ b/src/main/java/com/williamcallahan/javachat/service/DocumentFactory.java @@ -149,6 +149,7 @@ public org.springframework.ai.document.Document createDocumentWithPages( var doc = createDocument(text, url, title, chunkIndex, packageName, hash); doc.getMetadata().put(QdrantPayloadFieldSchema.PAGE_START_FIELD, pageStart); doc.getMetadata().put(QdrantPayloadFieldSchema.PAGE_END_FIELD, pageEnd); + doc.getMetadata().put(QdrantPayloadFieldSchema.ANCHOR_FIELD, "page=" + pageStart); return doc; } diff --git a/src/main/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessor.java b/src/main/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessor.java index 71ac8702..8f6f175e 100644 --- a/src/main/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessor.java +++ b/src/main/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessor.java @@ -44,12 +44,11 @@ public class LocalDocsFileIngestionProcessor { private static final String FILE_URL_PREFIX = "file://"; private static final String NAVIGATION_FRAMESET_SELECTOR = "frameset"; - private static final String NAVIGATION_NOFRAMES_SELECTOR = "noframes"; private static final String INTERACTIVE_API_REFERENCE_SELECTOR = "main > article.redoc-container > redoc[spec-url]"; private static final String INTERACTIVE_API_ALTERNATE_SELECTOR = "head > link[rel=alternate][type=\"text/markdown\"], head > link[rel=alternate][type=\"application/yaml\"]"; static final int MAX_EMBEDDING_BATCH_DOCUMENTS = 256; - static final String LOCAL_DOCS_EXTRACTION_SEMANTICS_VERSION = "utf8-document-extraction-provenance-v5"; + static final String LOCAL_DOCS_EXTRACTION_SEMANTICS_VERSION = "utf8-document-extraction-provenance-v6"; private final FileContentServices fileContentServices; private final IngestionStorageServices storage; @@ -724,8 +723,7 @@ private LocalDocsFileOutcome processExcludedPage(MarkerContext markerContext, bo } private static boolean isNavigationOnlyDocument(org.jsoup.nodes.Document parsedDocument) { - boolean navigationFrameset = parsedDocument.selectFirst(NAVIGATION_FRAMESET_SELECTOR) != null - && parsedDocument.selectFirst(NAVIGATION_NOFRAMES_SELECTOR) != null; + boolean navigationFrameset = parsedDocument.selectFirst(NAVIGATION_FRAMESET_SELECTOR) != null; boolean interactiveApiReferenceShell = parsedDocument.selectFirst(INTERACTIVE_API_REFERENCE_SELECTOR) != null && parsedDocument.selectFirst(INTERACTIVE_API_ALTERNATE_SELECTOR) != null; return navigationFrameset || interactiveApiReferenceShell; diff --git a/src/main/java/com/williamcallahan/javachat/support/JavaApiPageExtractor.java b/src/main/java/com/williamcallahan/javachat/support/JavaApiPageExtractor.java index 8bdde7be..a53e6ab4 100644 --- a/src/main/java/com/williamcallahan/javachat/support/JavaApiPageExtractor.java +++ b/src/main/java/com/williamcallahan/javachat/support/JavaApiPageExtractor.java @@ -25,7 +25,6 @@ public final class JavaApiPageExtractor { private static final String JAVA_API_CLASS_DECLARATION_PAGE_CLASS = "class-declaration-page"; private static final String JAVA_API_CLASS_USE_PAGE_CLASS = "class-use-page"; private static final String JAVA_API_FRAMESET_SELECTOR = "frameset"; - private static final String JAVA_API_NOFRAMES_SELECTOR = "noframes"; private static final String JAVA_API_CLASS_TITLE_SELECTOR = "main > .header .title, .header .title, h1.title"; private static final String JAVA_API_CLASS_DESCRIPTION_SELECTOR = "main > section.class-description, section.class-description"; @@ -68,8 +67,7 @@ public JavaApiPageExtraction extract(Document document) { if (documentBody.hasClass(JAVA_API_CLASS_USE_PAGE_CLASS)) { return JavaApiPageExtraction.excludedClassUsePage(); } - if (extractionDocument.selectFirst(JAVA_API_FRAMESET_SELECTOR) != null - && extractionDocument.selectFirst(JAVA_API_NOFRAMES_SELECTOR) != null) { + if (extractionDocument.selectFirst(JAVA_API_FRAMESET_SELECTOR) != null) { return JavaApiPageExtraction.excludedNavigationPage(); } if (!documentBody.hasClass(JAVA_API_CLASS_DECLARATION_PAGE_CLASS)) { diff --git a/src/test/java/com/williamcallahan/javachat/service/HtmlContentExtractorTest.java b/src/test/java/com/williamcallahan/javachat/service/HtmlContentExtractorTest.java index a221a44b..5f6eda70 100644 --- a/src/test/java/com/williamcallahan/javachat/service/HtmlContentExtractorTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/HtmlContentExtractorTest.java @@ -191,7 +191,6 @@ void explicitlyExcludesJavadocFramesetNavigationPages() { - <p>Link to <a href="overview-summary.html">Non-frame version</a>.</p> """); diff --git a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceCitationTest.java b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceCitationTest.java index 8d765544..6a120243 100644 --- a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceCitationTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceCitationTest.java @@ -245,20 +245,15 @@ void collapsesFragmentlessDocumentsFromTheSamePage() { } @Test - void retainsDistinctPdfPageAnchors() { + void retainsDistinctPdfPageAnchorsCreatedByDocumentFactory() { String pdfCitationUrl = "https://example.test/books/Reference.pdf"; + DocumentFactory documentFactory = new DocumentFactory(new ContentHasher()); RetrievalService.CitationOutcome citationOutcome = citationService() .toCitations(List.of( - Document.builder() - .id("first-pdf-page-chunk") - .text("First PDF page") - .metadata(QdrantPayloadFieldSchema.URL_FIELD, pdfCitationUrl + "#page=1") - .build(), - Document.builder() - .id("second-pdf-page-chunk") - .text("Second PDF page") - .metadata(QdrantPayloadFieldSchema.URL_FIELD, pdfCitationUrl + "#page=2") - .build())); + documentFactory.createDocumentWithPages( + "First PDF page", pdfCitationUrl, "Reference", 0, "", "first-pdf-page-hash", 1, 1), + documentFactory.createDocumentWithPages( + "Second PDF page", pdfCitationUrl, "Reference", 1, "", "second-pdf-page-hash", 2, 2))); assertEquals( List.of(pdfCitationUrl, pdfCitationUrl), diff --git a/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessorTest.java b/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessorTest.java index 151b6aa9..612e4912 100644 --- a/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessorTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessorTest.java @@ -72,6 +72,8 @@ class LocalDocsFileIngestionProcessorTest { private static final String JAVA_API_DESCRIPTION = "Detailed Java API documentation explains mutability, character sequences, and method contracts. " .repeat(JAVA_API_DESCRIPTION_REPEAT_COUNT); + private static final String PDF_MARKER_WITHOUT_PAGE_ANCHOR_SEMANTICS_VERSION = + "utf8-document-extraction-provenance-v5"; private static final long METADATA_ONLY_MODIFIED_TIME_OFFSET_MILLIS = 1_000L; @Test @@ -529,6 +531,67 @@ void shouldForceCompleteReplacementWhenOnlyPartOfChunkSetWasSkipped(@TempDir Pat assertEquals(completeChunkHashes, completedMarkerCaptor.getValue().chunkHashes()); } + @Test + void shouldReplacePdfWhenStoredMarkerUsesPreviousExtractionSemantics(@TempDir Path temporaryDirectory) + throws IOException { + DocumentationSource documentationSource = + DocsSourceRegistry.documentationSources().getFirst(); + Path localDocsRoot = temporaryDirectory.resolve("data").resolve("docs"); + Path pdfFile = + localDocsRoot.resolve(documentationSource.relativeMirrorPath()).resolve("reference.pdf"); + Files.createDirectories(Objects.requireNonNull(pdfFile.getParent(), "pdfFile parent")); + Files.writeString(pdfFile, "%PDF-1.7", StandardCharsets.UTF_8); + + String expectedPdfUrl = documentationSource.citationBaseUrl() + "reference.pdf"; + ContentHasher contentHasher = new ContentHasher(); + IngestionProvenanceDeriver.IngestionProvenance ingestionProvenance = + new IngestionProvenanceDeriver().derive(localDocsRoot, pdfFile, expectedPdfUrl); + String matchingIngestionFingerprint = + contentHasher.sha256(ingestionProvenance.fingerprintInput(contentHasher.sha256(pdfFile))); + FileIngestionRecord stalePdfMarker = new FileIngestionRecord( + Files.size(pdfFile), + Files.getLastModifiedTime(pdfFile).toMillis(), + matchingIngestionFingerprint, + PDF_MARKER_WITHOUT_PAGE_ANCHOR_SEMANTICS_VERSION, + "documentation", + List.of("old-pdf-hash")); + + LocalDocsIngestionFixture ingestionFixture = new LocalDocsIngestionFixture(); + Document replacementDocument = new Document("replacement-pdf-point", "Replacement PDF page", new HashMap<>()); + when(ingestionFixture.fileIngestionMarkerStore.readFileIngestionRecord(expectedPdfUrl)) + .thenReturn(Optional.of(stalePdfMarker)); + when(ingestionFixture.hybridVectorService.resolveCollectionName(any())).thenReturn("documentation"); + when(ingestionFixture.hybridVectorService.hasExactPointIdsForUrl( + any(QdrantCollectionKind.class), + eq(expectedPdfUrl), + eq(List.of(contentHasher.uuidFromHash("old-pdf-hash"))))) + .thenReturn(true); + when(ingestionFixture.chunkProcessingService.processPdfAndStoreWithPagesForce( + eq(pdfFile), eq(expectedPdfUrl), isNull(), eq(""))) + .thenReturn(new ChunkProcessingService.ChunkProcessingOutcome( + List.of(replacementDocument), List.of("current-pdf-hash"), 1, 0)); + + LocalDocsFileOutcome processingOutcome = + ingestionFixture.ingestionProcessor().process(localDocsRoot, pdfFile); + + assertTrue(processingOutcome.processed()); + verify(ingestionFixture.chunkProcessingService) + .processPdfAndStoreWithPagesForce(eq(pdfFile), eq(expectedPdfUrl), isNull(), eq("")); + verify(ingestionFixture.chunkProcessingService, never()) + .processPdfAndStoreWithPages(any(), anyString(), any(), anyString()); + verify(ingestionFixture.hybridVectorService) + .replaceUrlDocuments( + any(QdrantCollectionKind.class), eq(expectedPdfUrl), eq(List.of(replacementDocument))); + verify(ingestionFixture.ingestedFilePruneService) + .pruneObsoleteLocalStateAfterReplacement(expectedPdfUrl, stalePdfMarker, List.of("current-pdf-hash")); + ArgumentCaptor updatedMarkerCaptor = ArgumentCaptor.forClass(FileIngestionRecord.class); + verify(ingestionFixture.fileIngestionMarkerStore) + .markFileIngested(eq(expectedPdfUrl), updatedMarkerCaptor.capture()); + assertEquals( + LocalDocsFileIngestionProcessor.LOCAL_DOCS_EXTRACTION_SEMANTICS_VERSION, + updatedMarkerCaptor.getValue().extractionSemanticsVersion()); + } + @Test void shouldReplaceChangedJavadocBeforePruningObsoleteStateWhenChunkCountShrinks(@TempDir Path temporaryDirectory) throws IOException { @@ -869,7 +932,6 @@ void shouldMarkGenericFramesetNavigationPageAsExcluded(@TempDir Path temporaryDi - Link to the non-frame overview. """, StandardCharsets.UTF_8); From 907790e7f1d80f83057799d835a250614935dc7f Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 12:42:35 -0700 Subject: [PATCH 16/29] fix(auth): satisfy lifecycle fixture lint Use platform line separators in the formatted Clerk lifecycle response. - Preserve the existing mocked JSON semantics --- .../out/clerk/ClerkApiKeyVerifierTest.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java b/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java index 19a56d74..eb6d2f0b 100644 --- a/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java +++ b/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java @@ -249,14 +249,15 @@ void acceptsCompleteRevokedOrExpiredAvailabilityResponse(boolean revoked, boolea ClerkApiKeyVerifier verifier = new ClerkApiKeyVerifier(restClientBuilder.build(), CLERK_SECRET_KEY); clerkServer .expect(once(), requestTo(CLERK_VERIFY_ENDPOINT)) - .andRespond(withSuccess(""" - { - "id": "ak_0123456789abcdef0123456789abcdef", - "subject": "user_0123456789abcdefghijklmnopq", - "revoked": %s, - "expired": %s - } - """.formatted(revoked, expired), MediaType.APPLICATION_JSON)); + .andRespond(withSuccess( + ("{%n" + + " \"id\": \"ak_0123456789abcdef0123456789abcdef\",%n" + + " \"subject\": \"user_0123456789abcdefghijklmnopq\",%n" + + " \"revoked\": %s,%n" + + " \"expired\": %s%n" + + "}%n") + .formatted(revoked, expired), + MediaType.APPLICATION_JSON)); assertTrue(verifier.isAvailable()); assertTrue(verifier.isAvailable(), "the complete lifecycle response must not trigger another probe"); From cacf64936643717f68095d776fffd183fb22b0ef Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 12:59:26 -0700 Subject: [PATCH 17/29] fix(retrieval): preserve exact and adjacent version intent Resolve numerically equivalent evidence versions as exact. Retain same-family adjacent sources only for missing documentation types. --- .../javachat/config/DocsSourceRegistry.java | 87 ++++++++++++----- .../javachat/service/ChatService.java | 2 +- .../javachat/service/RetrievalService.java | 18 ++-- .../javachat/util/QueryVersionExtractor.java | 7 ++ .../config/DocsSourceRegistryTest.java | 46 +++++++++ .../javachat/service/ChatServiceTest.java | 74 ++++++++++---- .../service/RetrievalServiceTest.java | 97 +++++++++++++++++++ .../util/QueryVersionExtractorTest.java | 15 +++ 8 files changed, 293 insertions(+), 53 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java b/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java index 40a24300..a2e604c7 100644 --- a/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java +++ b/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java @@ -158,6 +158,15 @@ public final class DocsSourceRegistry { OFFICIAL_DOCUMENTATION_SOURCE_KIND, "language-reference", "2.4.10"), + new DocumentationSource( + "https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-nothing/", + "kotlin-api", + "Kotlin 2.4 Nothing API", + "kotlin-api", + OFFICIAL_DOCUMENTATION_SOURCE_KIND, + "api-docs", + "2.4", + DocumentationCitationPathStyle.SINGLE_DOCUMENT), new DocumentationSource( "https://docs.scala-lang.org/scala3/reference/", "scala", @@ -617,6 +626,11 @@ public record VersionedDocumentationEvidence( Objects.requireNonNull(requestedVersion, "requestedVersion"); sources = List.copyOf(sources); } + + /** Returns whether one evidence version is numerically exact for the request. */ + public boolean isExactVersion(String evidenceVersion) { + return compareNumericVersions(evidenceVersion, requestedVersion) == 0; + } } /** Resolves a named versioned dependency in a query to exact or adjacent same-family sources. */ @@ -662,43 +676,66 @@ public static List versionedDocumentationEvidenc .find()) { continue; } + List adjacencyEligibleSources = familySources.getValue().stream() + .filter(source -> + !"release-notes".equals(source.docType()) && !"article".equals(source.docType())) + .toList(); List exactSources = familySources.getValue().stream() - .filter(source -> source.docVersion().equals(requestedVersion) - || source.docVersion().startsWith(requestedVersion + "-")) + .filter(source -> compareNumericVersions(source.docVersion(), requestedVersion) == 0) .toList(); if (!exactSources.isEmpty()) { - resolvedEvidence.add( - new VersionedDocumentationEvidence(familySources.getKey(), requestedVersion, exactSources)); + List exactDocumentTypes = exactSources.stream() + .map(DocumentationSource::docType) + .distinct() + .toList(); + List supplementalSources = adjacencyEligibleSources.stream() + .filter(source -> !exactDocumentTypes.contains(source.docType())) + .collect(java.util.stream.Collectors.groupingBy( + DocumentationSource::docType, + LinkedHashMap::new, + java.util.stream.Collectors.toList())) + .values() + .stream() + .map(sources -> adjacentDocumentationSources(sources, requestedVersion)) + .flatMap(List::stream) + .toList(); + List evidenceSources = Stream.concat( + exactSources.stream(), supplementalSources.stream()) + .toList(); + resolvedEvidence.add(new VersionedDocumentationEvidence( + familySources.getKey(), requestedVersion, evidenceSources)); continue; } - List adjacentFamilySources = familySources.getValue().stream() - .filter(source -> - !"release-notes".equals(source.docType()) && !"article".equals(source.docType())) - .toList(); - if (adjacentFamilySources.isEmpty()) { + if (adjacencyEligibleSources.isEmpty()) { continue; } - Optional lowerVersion = adjacentFamilySources.stream() - .map(DocumentationSource::docVersion) - .filter(version -> compareNumericVersions(version, requestedVersion) < 0) - .max(DocsSourceRegistry::compareNumericVersions); - Optional higherVersion = adjacentFamilySources.stream() - .map(DocumentationSource::docVersion) - .filter(version -> compareNumericVersions(version, requestedVersion) > 0) - .min(DocsSourceRegistry::compareNumericVersions); - List evidenceVersions = Stream.concat(lowerVersion.stream(), higherVersion.stream()) - .toList(); - List evidenceSources = evidenceVersions.stream() - .flatMap(evidenceVersion -> adjacentFamilySources.stream() - .filter(source -> evidenceVersion.equals(source.docVersion()))) - .toList(); - resolvedEvidence.add( - new VersionedDocumentationEvidence(familySources.getKey(), requestedVersion, evidenceSources)); + List evidenceSources = + adjacentDocumentationSources(adjacencyEligibleSources, requestedVersion); + if (!evidenceSources.isEmpty()) { + resolvedEvidence.add(new VersionedDocumentationEvidence( + familySources.getKey(), requestedVersion, evidenceSources)); + } } } return List.copyOf(resolvedEvidence); } + private static List adjacentDocumentationSources( + List familySources, String requestedVersion) { + Optional lowerVersion = familySources.stream() + .map(DocumentationSource::docVersion) + .filter(version -> compareNumericVersions(version, requestedVersion) < 0) + .max(DocsSourceRegistry::compareNumericVersions); + Optional higherVersion = familySources.stream() + .map(DocumentationSource::docVersion) + .filter(version -> compareNumericVersions(version, requestedVersion) > 0) + .min(DocsSourceRegistry::compareNumericVersions); + return Stream.concat(lowerVersion.stream(), higherVersion.stream()) + .flatMap(evidenceVersion -> + familySources.stream().filter(source -> evidenceVersion.equals(source.docVersion()))) + .toList(); + } + /** Returns a stable source-family identity from a versioned or unversioned documentation set. */ public static String documentationSourceFamily(String documentationSet) { if (documentationSet == null || documentationSet.isBlank()) { diff --git a/src/main/java/com/williamcallahan/javachat/service/ChatService.java b/src/main/java/com/williamcallahan/javachat/service/ChatService.java index dc7460a1..ffb12109 100644 --- a/src/main/java/com/williamcallahan/javachat/service/ChatService.java +++ b/src/main/java/com/williamcallahan/javachat/service/ChatService.java @@ -312,10 +312,10 @@ private List buildContextSegments( .toList() : dependencyEvidence.stream() .filter(evidence -> evidence.sourceFamily().equals(resolvedSourceFamily)) - .filter(evidence -> !evidence.requestedVersion().equals(resolvedSourceVersion)) .filter(evidence -> evidence.sources().stream() .anyMatch(source -> source.docSet().equals(documentationSet) && source.docVersion().equals(resolvedSourceVersion))) + .filter(evidence -> !evidence.isExactVersion(resolvedSourceVersion)) .map(DocsSourceRegistry.VersionedDocumentationEvidence::requestedVersion) .toList(); String adjacentEvidenceFields = adjacentRequestedVersions.isEmpty() diff --git a/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java b/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java index bd37a1e3..84dc33fb 100644 --- a/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java +++ b/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java @@ -63,7 +63,7 @@ public final class RetrievalService { private final AppProperties appProperties; private final RerankerService rerankerService; private final DocumentFactory documentFactory; - private final String defaultJavaApiDocumentationDocSet; + private final List defaultJavaApiDocumentationDocSets; /** * Creates a retrieval service backed by gRPC hybrid search with RRF fusion and a reranker. @@ -83,12 +83,10 @@ public RetrievalService( this.rerankerService = rerankerService; this.documentFactory = documentFactory; String configuredJavaRelease = Integer.toString(appProperties.getDocs().getJdkVersion()); - this.defaultJavaApiDocumentationDocSet = DocsSourceRegistry.javaApiDocumentationSources().stream() - .filter(source -> configuredJavaRelease.equals(source.javaRelease())) - .map(DocsSourceRegistry.JavaApiDocumentationSource::relativeMirrorPath) - .findFirst() - .orElseThrow(() -> new IllegalStateException( - "Configured Java release has no indexed API source: " + configuredJavaRelease)); + this.defaultJavaApiDocumentationDocSets = + DocsSourceRegistry.javaApiDocumentationSourcesForRelease(configuredJavaRelease).stream() + .map(DocsSourceRegistry.JavaApiDocumentationSource::relativeMirrorPath) + .toList(); } /** @@ -464,10 +462,10 @@ private static boolean requiresJavaMemberEvidence(String query, RetrievalConstra } /** - * Uses the configured default Java API source for a generic broad official-doc request. + * Uses the configured exact or adjacent Java API sources for a generic broad official-doc request. * *

Historical Java API mirrors contain near-duplicate API pages and dominate the broad - * documentation corpus. Generic requests therefore use the configured Java API source while + * documentation corpus. Generic requests therefore use the configured Java API evidence while * retaining every non-Java documentation set. Explicit release requests, caller-owned release * filters, and exact-overload lookups retain their full source scope for historical evidence.

*/ @@ -483,7 +481,7 @@ private RetrievalConstraint defaultJavaApiScope( } List defaultJavaApiDocSets = retrievalConstraint.docSet().stream() .filter(docSet -> !JAVA_API_DOCUMENTATION_DOC_SETS.contains(docSet) - || defaultJavaApiDocumentationDocSet.equals(docSet)) + || defaultJavaApiDocumentationDocSets.contains(docSet)) .toList(); return retrievalConstraint.withDocSetScope(defaultJavaApiDocSets); } diff --git a/src/main/java/com/williamcallahan/javachat/util/QueryVersionExtractor.java b/src/main/java/com/williamcallahan/javachat/util/QueryVersionExtractor.java index 827f1638..c0dbb0e1 100644 --- a/src/main/java/com/williamcallahan/javachat/util/QueryVersionExtractor.java +++ b/src/main/java/com/williamcallahan/javachat/util/QueryVersionExtractor.java @@ -12,6 +12,8 @@ public final class QueryVersionExtractor { private static final int MINIMUM_PLAUSIBLE_JAVA_RELEASE = 8; private static final Pattern EXPLICIT_VERSION_PATTERN = Pattern.compile("\\b(?:java\\s*se|javase|java|jdk)[\\s-]*(\\d{1,3})\\b", Pattern.CASE_INSENSITIVE); + private static final Pattern EXPLICIT_QUANTITY_SUFFIX_PATTERN = + Pattern.compile("[\\s-]+(?:days?|hours?|minutes?|seconds?|times|lines?)\\b", Pattern.CASE_INSENSITIVE); private static final Pattern SHORTHAND_VERSION_PATTERN = Pattern.compile( "^\\s*(?:,|and\\b|vs(?:\\.|\\b)|versus\\b|\\+|/|&)\\s*(\\d{1,3})\\b", Pattern.CASE_INSENSITIVE); @@ -32,7 +34,12 @@ public static List extractVersionNumbers(String query) { } Set retainedVersions = new LinkedHashSet<>(); Matcher explicitVersionMatcher = EXPLICIT_VERSION_PATTERN.matcher(query); + Matcher quantitySuffixMatcher = EXPLICIT_QUANTITY_SUFFIX_PATTERN.matcher(query); while (explicitVersionMatcher.find()) { + quantitySuffixMatcher.region(explicitVersionMatcher.end(), query.length()); + if (quantitySuffixMatcher.lookingAt()) { + continue; + } retainedVersions.add(explicitVersionMatcher.group(1)); collectShorthandVersions(query, explicitVersionMatcher.end(), retainedVersions); } diff --git a/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java b/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java index 469b912e..de47ff7d 100644 --- a/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java @@ -1,6 +1,7 @@ package com.williamcallahan.javachat.config; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -86,6 +87,7 @@ void resolvesVersionedDependencyGapsWithinOneSourceFamily() { assertEquals("hikaricp", evidence.sourceFamily()); assertEquals("7.0.5", evidence.requestedVersion()); + assertFalse(evidence.isExactVersion("7.0.2")); assertEquals( List.of("7.0.2", "7.1.0"), evidence.sources().stream().map(DocumentationSource::docVersion).toList()); @@ -97,6 +99,15 @@ void resolvesVersionedDependencyGapsWithinOneSourceFamily() { .stream() .map(DocumentationSource::docVersion) .toList()); + DocsSourceRegistry.VersionedDocumentationEvidence shorthandEvidence = + DocsSourceRegistry.versionedDocumentationEvidence("HikariCP 7.1 connection pooling") + .orElseThrow(); + assertTrue(shorthandEvidence.isExactVersion("7.1.0")); + assertEquals( + List.of("7.1.0"), + shorthandEvidence.sources().stream() + .map(DocumentationSource::docVersion) + .toList()); assertEquals( List.of("7.0.2"), DocsSourceRegistry.versionedDocumentationEvidence("HikariCP 6.9 connection pooling") @@ -195,6 +206,41 @@ void resolvesVersionedDependencyGapsWithinOneSourceFamily() { .toList()); } + @Test + void resolvesExactKotlinLanguageAndAdjacentApiLineEvidence() { + List kotlinEvidence = + DocsSourceRegistry.versionedDocumentationEvidenceAll("Kotlin 2.4.10 Nothing"); + + assertEquals( + List.of("kotlin@2.4.10@exact", "kotlin-api@2.4@adjacent"), + kotlinEvidence.stream() + .flatMap(evidence -> evidence.sources().stream() + .map(source -> source.docSet() + "@" + source.docVersion() + "@" + + (evidence.isExactVersion(source.docVersion()) ? "exact" : "adjacent"))) + .toList()); + } + + @Test + void registersKotlinNothingApiWithTruthfulProvenance() { + DocumentationSource kotlinApiSource = DocsSourceRegistry.documentationSources().stream() + .filter(source -> "kotlin-api".equals(source.docSet())) + .findFirst() + .orElseThrow(); + + assertEquals( + "https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-nothing/", kotlinApiSource.citationBaseUrl()); + assertEquals("kotlin-api", kotlinApiSource.relativeMirrorPath()); + assertEquals("2.4", kotlinApiSource.docVersion()); + assertEquals("official", kotlinApiSource.sourceKind()); + assertEquals("api-docs", kotlinApiSource.docType()); + String kotlinLanguageMirror = DocsSourceRegistry.documentationSources().stream() + .filter(source -> "kotlin".equals(source.docSet())) + .map(DocumentationSource::relativeMirrorPath) + .findFirst() + .orElseThrow(); + assertFalse(kotlinApiSource.relativeMirrorPath().startsWith(kotlinLanguageMirror + "/")); + } + @Test void returnsImmutableDocumentationSourceSnapshot() { List documentationSources = DocsSourceRegistry.documentationSources(); diff --git a/src/test/java/com/williamcallahan/javachat/service/ChatServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/ChatServiceTest.java index 2be28168..2f0bea95 100644 --- a/src/test/java/com/williamcallahan/javachat/service/ChatServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/ChatServiceTest.java @@ -1,7 +1,9 @@ package com.williamcallahan.javachat.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; @@ -141,9 +143,6 @@ void structuredPromptDoesNotInventVersionForUnversionedRetainedContext() { @Test void structuredPromptLabelsAdjacentSameFamilyDependencyEvidence() { - RetrievalService retrievalService = mock(RetrievalService.class); - SystemPromptConfig systemPromptConfig = mock(SystemPromptConfig.class); - when(systemPromptConfig.getCoreSystemPrompt()).thenReturn("Use same-family source records."); Document hikaricpDocumentation = Document.builder() .id("hikaricp-702") .text("Connection timeout behavior") @@ -152,27 +151,68 @@ void structuredPromptLabelsAdjacentSameFamilyDependencyEvidence() { .metadata(QdrantPayloadFieldSchema.DOC_SET_FIELD, "hikaricp/7.0.2/api") .metadata(QdrantPayloadFieldSchema.DOC_VERSION_FIELD, "7.0.2") .build(); + + assertTrue( + sourceRecordFor("HikariCP 7.0.5 connection timeout", hikaricpDocumentation) + .contains( + "family=\"hikaricp\" version=\"7.0.2\" requestedVersions=\"7.0.5\" evidenceRelation=\"adjacent-same-family\"")); + } + + @Test + void structuredPromptRendersNumericEquivalentDependencyEvidenceAsExact() { + Document hikaricpDocumentation = Document.builder() + .id("hikaricp-710") + .text("Connection timeout behavior") + .metadata(QdrantPayloadFieldSchema.URL_FIELD, "https://javadoc.io/doc/com.zaxxer/HikariCP/7.1.0/") + .metadata(QdrantPayloadFieldSchema.SOURCE_NAME_FIELD, "hikaricp/7.1.0/api") + .metadata(QdrantPayloadFieldSchema.DOC_SET_FIELD, "hikaricp/7.1.0/api") + .metadata(QdrantPayloadFieldSchema.DOC_VERSION_FIELD, "7.1.0") + .build(); + String sourceRecord = sourceRecordFor("HikariCP 7.1 connection timeout", hikaricpDocumentation); + + assertTrue(sourceRecord.contains("[SOURCE RECORD family=\"hikaricp\" version=\"7.1.0\"]")); + assertFalse(sourceRecord.contains("requestedVersions")); + assertFalse(sourceRecord.contains("evidenceRelation")); + } + + @Test + void structuredPromptDoesNotTreatQuantityAsRequestedJavaRelease() { + Document javaDocumentation = Document.builder() + .id("java-26-days-of-code") + .text("Practice consistently") + .metadata( + QdrantPayloadFieldSchema.URL_FIELD, + "https://docs.oracle.com/en/java/javase/26/docs/api/java.base/java/lang/Object.html") + .metadata(QdrantPayloadFieldSchema.SOURCE_NAME_FIELD, "java/java26-complete") + .metadata(QdrantPayloadFieldSchema.DOC_SET_FIELD, "java/java26-complete") + .metadata(QdrantPayloadFieldSchema.DOC_VERSION_FIELD, "26") + .build(); + String sourceRecord = sourceRecordFor("Java 100 days of code", javaDocumentation); + + assertTrue(sourceRecord.contains("[SOURCE RECORD family=\"java\" version=\"26\"]")); + assertFalse(sourceRecord.contains("requestedVersions")); + assertFalse(sourceRecord.contains("evidenceRelation")); + } + + private static String sourceRecordFor(String query, Document documentation) { + RetrievalService retrievalService = mock(RetrievalService.class); + SystemPromptConfig systemPromptConfig = mock(SystemPromptConfig.class); + when(systemPromptConfig.getCoreSystemPrompt()).thenReturn("Use source records."); when(retrievalService.retrieveWithLimitOutcome( - eq("HikariCP 7.0.5 connection timeout"), + eq(query), eq(ModelConfiguration.RAG_LIMIT_CONSTRAINED), eq(ModelConfiguration.RAG_TOKEN_LIMIT_CONSTRAINED), any(RetrievalConstraint.class), any(), anyLong())) - .thenReturn(new RetrievalService.RetrievalOutcome(List.of(hikaricpDocumentation), List.of())); + .thenReturn(new RetrievalService.RetrievalOutcome(List.of(documentation), List.of())); ChatService chatService = new ChatService( mock(OpenAIStreamingService.class), retrievalService, systemPromptConfig, new AppProperties()); - - ChatService.StructuredPromptOutcome promptOutcome = - chatService.buildStructuredPromptWithContextOutcome(List.of(), "HikariCP 7.0.5 connection timeout"); - - org.junit.jupiter.api.Assertions.assertTrue( - promptOutcome - .structuredPrompt() - .contextDocuments() - .getFirst() - .content() - .contains( - "family=\"hikaricp\" version=\"7.0.2\" requestedVersions=\"7.0.5\" evidenceRelation=\"adjacent-same-family\"")); + return chatService + .buildStructuredPromptWithContextOutcome(List.of(), query) + .structuredPrompt() + .contextDocuments() + .getFirst() + .content(); } } diff --git a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java index f299e142..d3a274f7 100644 --- a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java @@ -101,6 +101,72 @@ void genericBroadOfficialScopeUsesDefaultJavaApiSourceAndRetainsEveryNonJavaSour .toList()); } + @Test + void explicitQuantityPhraseUsesBroadOfficialScopeAndReturnsNonJavaEvidence() { + HybridSearchService hybridSearchService = mock(HybridSearchService.class); + RerankerService rerankerService = mock(RerankerService.class); + RetrievalService retrievalService = new RetrievalService( + hybridSearchService, new AppProperties(), rerankerService, mock(DocumentFactory.class)); + RetrievalConstraint officialDocumentationConstraint = + RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); + RetrievalConstraint expectedScopedConstraint = defaultJavaApiBroadOfficialConstraint(); + Document kotlinDocument = Document.builder() + .id("kotlin-days-of-code") + .text("Practice plan") + .metadata(QdrantPayloadFieldSchema.DOC_SET_FIELD, "kotlin") + .metadata(QdrantPayloadFieldSchema.URL_FIELD, "https://kotlinlang.org/docs/basic-syntax.html") + .build(); + when(hybridSearchService.searchOutcome(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) + .thenReturn(new HybridSearchService.SearchOutcome(List.of(kotlinDocument), List.of())); + when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) + .thenReturn(List.of(kotlinDocument)); + + RetrievalService.RetrievalOutcome retrievalOutcome = + retrievalService.retrieveOutcome("Java 100 days of code", officialDocumentationConstraint); + + assertEquals(List.of(kotlinDocument), retrievalOutcome.documents()); + } + + @Test + void configuredUnindexedJavaReleaseUsesBothAdjacentApiSources() { + HybridSearchService hybridSearchService = mock(HybridSearchService.class); + RerankerService rerankerService = mock(RerankerService.class); + AppProperties appProperties = new AppProperties(); + appProperties.getDocs().setJdkVersion(24); + RetrievalService retrievalService = + new RetrievalService(hybridSearchService, appProperties, rerankerService, mock(DocumentFactory.class)); + RetrievalConstraint officialDocumentationConstraint = + RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); + List adjacentJavaApiDocSets = DocsSourceRegistry.javaApiDocumentationSourcesForRelease("24").stream() + .map(DocsSourceRegistry.JavaApiDocumentationSource::relativeMirrorPath) + .toList(); + RetrievalConstraint expectedScopedConstraint = + RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES.stream() + .filter(sourceIdentity -> !JAVA_API_DOCUMENTATION_SOURCE_IDENTITIES.contains(sourceIdentity) + || adjacentJavaApiDocSets.contains(sourceIdentity)) + .toList()); + Document kotlinDocument = Document.builder() + .id("kotlin-broad-scope") + .text("Kotlin documentation") + .metadata(QdrantPayloadFieldSchema.DOC_SET_FIELD, "kotlin") + .metadata(QdrantPayloadFieldSchema.URL_FIELD, "https://kotlinlang.org/docs/home.html") + .build(); + when(hybridSearchService.searchOutcome(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) + .thenReturn(new HybridSearchService.SearchOutcome(List.of(kotlinDocument), List.of())); + when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) + .thenReturn(List.of(kotlinDocument)); + + RetrievalService.RetrievalOutcome retrievalOutcome = + retrievalService.retrieveOutcome("Java collections", officialDocumentationConstraint); + + assertEquals(List.of(kotlinDocument), retrievalOutcome.documents()); + assertEquals( + List.of("java/java21-complete", "java/java25-complete"), + expectedScopedConstraint.docSet().stream() + .filter(JAVA_API_DOCUMENTATION_SOURCE_IDENTITIES::contains) + .toList()); + } + @Test void bareJavaMemberUsesDeterministicSparseEvidenceWithoutReranking() { HybridSearchService hybridSearchService = mock(HybridSearchService.class); @@ -604,6 +670,37 @@ void multipleDependencyGapsRetainEveryDistinctEvidenceSource() { .toList()); } + @Test + void exactKotlinRequestRetainsLanguageAndApiLineEvidence() { + HybridSearchService hybridSearchService = mock(HybridSearchService.class); + RerankerService rerankerService = mock(RerankerService.class); + RetrievalService retrievalService = new RetrievalService( + hybridSearchService, new AppProperties(), rerankerService, mock(DocumentFactory.class)); + RetrievalConstraint officialDocumentationConstraint = + RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); + List evidenceConstraints = List.of( + documentationEvidenceConstraint("kotlin", "language-reference", "2.4.10"), + documentationEvidenceConstraint("kotlin-api", "api-docs", "2.4")); + Document languageDocument = versionedDocument("kotlin-language", "2.4.10", "kotlin-language-hash", "kotlin"); + Document apiDocument = versionedDocument("kotlin-nothing-api", "2.4", "kotlin-api-hash", "kotlin-api"); + when(hybridSearchService.searchOutcomes(anyString(), eq(10), eq(evidenceConstraints), anyLong())) + .thenReturn(List.of( + new HybridSearchService.SearchOutcome(List.of(languageDocument), List.of()), + new HybridSearchService.SearchOutcome(List.of(apiDocument), List.of()))); + when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) + .thenReturn(List.of(languageDocument, apiDocument)); + + RetrievalService.RetrievalOutcome retrievalOutcome = + retrievalService.retrieveOutcome("Kotlin 2.4.10 Nothing", officialDocumentationConstraint); + + assertEquals( + List.of("kotlin@2.4.10", "kotlin-api@2.4"), + retrievalOutcome.documents().stream() + .map(document -> document.getMetadata().get(QdrantPayloadFieldSchema.DOC_SET_FIELD) + "@" + + document.getMetadata().get(QdrantPayloadFieldSchema.DOC_VERSION_FIELD)) + .toList()); + } + @Test void versionedDependencyMentionDoesNotEscapeCallerOwnedSourceScope() { HybridSearchService hybridSearchService = mock(HybridSearchService.class); diff --git a/src/test/java/com/williamcallahan/javachat/util/QueryVersionExtractorTest.java b/src/test/java/com/williamcallahan/javachat/util/QueryVersionExtractorTest.java index 7e63e6d9..26482cec 100644 --- a/src/test/java/com/williamcallahan/javachat/util/QueryVersionExtractorTest.java +++ b/src/test/java/com/williamcallahan/javachat/util/QueryVersionExtractorTest.java @@ -37,6 +37,21 @@ void ignoresUnprefixedNumbersOutsideAComparisonChain() { assertEquals(List.of("100"), QueryVersionExtractor.extractVersionNumbers("Java 100")); } + @Test + void rejectsExplicitQuantityPhrasesWithoutSuppressingReleaseRequests() { + for (String quantityNoun : List.of( + "day", "days", "hour", "hours", "minute", "minutes", "second", "seconds", "times", "line", "lines")) { + assertEquals( + List.of(), + QueryVersionExtractor.extractVersionNumbers("Java 100 " + quantityNoun + " of practice")); + } + assertEquals(List.of(), QueryVersionExtractor.extractVersionNumbers("JDK-30-HOURS of video")); + assertEquals(List.of("5"), QueryVersionExtractor.extractVersionNumbers("Java 5")); + assertEquals(List.of("21"), QueryVersionExtractor.extractVersionNumbers("Java 21 time API")); + assertEquals(List.of("25"), QueryVersionExtractor.extractVersionNumbers("Java 25 examples")); + assertEquals(List.of("21"), QueryVersionExtractor.extractVersionNumbers("Java 100 days of code in Java 21")); + } + @Test void returnsEmptyVersionsForMissingQueries() { assertEquals(List.of(), QueryVersionExtractor.extractVersionNumbers(null)); From 70323c1c890b97775ff22ae188c25196a3c84ab1 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 12:59:49 -0700 Subject: [PATCH 18/29] feat(ingestion): add Kotlin Nothing API corpus Fetch and index the stable Nothing API as a dedicated sibling corpus. Keep queued staging and selector projection aligned with the source. --- scripts/fetch_all_docs.sh | 3 +- ...n_queued_platform_documentation_staging.sh | 2 +- .../test_documentation_fetch_projection.sh | 57 +++++++++++++++++++ ...platform_documentation_staging_contract.sh | 2 +- .../cli/DocumentProcessorSelectionTest.java | 10 ++++ 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/scripts/fetch_all_docs.sh b/scripts/fetch_all_docs.sh index f68cf377..2211c2c7 100755 --- a/scripts/fetch_all_docs.sh +++ b/scripts/fetch_all_docs.sh @@ -834,6 +834,7 @@ fetch_named_official_source() { case "$source_identifier" in dev-java) "$source_dispatch" fetch_source --url "https://dev.java/learn/" --mirror-path "dev-java" --name "Dev.java Learning" --source-version "stable-current" --identity-regex "Learn Java" --cut-directories 1 --minimum-html-files 40 ;; kotlin) "$source_dispatch" fetch_source --url "https://kotlinlang.org/docs/" --mirror-path "kotlin" --name "Kotlin 2.4.10 Documentation" --source-version "2.4.10" --identity-regex "2\\.4\\.10" --required-identity-page "faq.html" --required-identity-text "The currently released version is 2.4.10, published on July 14, 2026." --cut-directories 1 --minimum-html-files 250 --reject-regex "(^|/)(custom-frontend-app(/|$)|home\\.html$|multiplatform/(compose-multiplatform|get-started)\\.html$)|(^|/)([Ee][Aa][Pp]|[Ss][Nn][Aa][Pp][Ss][Hh][Oo][Tt])(/|(-[^/]+)?\\.html$)|(^|/)[^/]*-([Ee][Aa][Pp]|[Ss][Nn][Aa][Pp][Ss][Hh][Oo][Tt])(-[^/]+)?\\.html$" --seed-document-type xml-sitemap --seed-discovery-url "https://kotlinlang.org/sitemap.xml" --seed-source-prefix "https://kotlinlang.org/docs/" ;; + kotlin-api) "$source_dispatch" fetch_source --url "https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-nothing/" --mirror-path "kotlin-api" --name "Kotlin 2.4 Nothing API" --source-version "2.4" --identity-regex "kotlin-stdlib_2\\.4_latest" --required-identity-page "index.html" --required-identity-text "Nothing has no instances." --cut-directories 5 --minimum-html-files 1 --single-page ;; scala) "$source_dispatch" fetch_source --url "https://docs.scala-lang.org/scala3/reference/" --mirror-path "scala" --name "Scala 3 Documentation" --source-version "3-stable" --identity-regex "Scala 3" --cut-directories 2 --minimum-html-files 300 --seed-document-type html-links --seed-discovery-url "https://docs.scala-lang.org/scala3/reference/" --seed-source-prefix "https://docs.scala-lang.org/scala3/reference/" --seed-reject-regex "/index\\.html$" ;; groovy) "$source_dispatch" fetch_source --url "https://archive.apache.org/dist/groovy/5.0.7/distribution/apache-groovy-docs-5.0.7.zip" --mirror-path "groovy/5.0.7" --name "Groovy 5.0.7 Documentation" --source-version "5.0.7" --identity-regex "Groovy.*5\\.0\\.7|5\\.0\\.7.*Groovy" --required-identity-page "core-introduction.html" --required-identity-text "version 5.0.7" --cut-directories 0 --minimum-html-files 40 --archive-format zip --archive-publication-root "groovy-5.0.7/html/documentation" ;; clojure) "$source_dispatch" fetch_source --url "https://clojure.org/guides/" --mirror-path "clojure" --name "Clojure Guides" --source-version "stable-current" --identity-regex "Clojure" --cut-directories 1 --minimum-html-files 20 --reject-regex "/guides/guides$" --seed-document-type xml-sitemap --seed-discovery-url "https://clojure.org/sitemap.xml" --seed-source-prefix "https://clojure.org/guides/" ;; @@ -926,7 +927,7 @@ fetch_selected_official_sources() { fetch_all_official_sources() { local source_identifier - for source_identifier in dev-java kotlin scala groovy clojure \ + for source_identifier in dev-java kotlin kotlin-api scala groovy clojure \ jooq-3.21-manual jooq-3.21-api python-3.14 postgresql-17 postgresql-18 \ hikaricp-7.1.0-api hikaricp-spring-7.0.2-api \ jackson-2.22.2-api jackson-spring-2.21.2-api \ diff --git a/scripts/run_queued_platform_documentation_staging.sh b/scripts/run_queued_platform_documentation_staging.sh index a2d98018..63e5db32 100755 --- a/scripts/run_queued_platform_documentation_staging.sh +++ b/scripts/run_queued_platform_documentation_staging.sh @@ -10,7 +10,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIRECTORY/.." && pwd)" readonly ACTIVE_STAGING_SERVICE="java-chat-local-embedding-staging.service" readonly QUEUE_POLL_INTERVAL_SECONDS=60 readonly STAGING_INVOCATION_RECEIPT="$HOME/.local/state/java-chat/local-embedding-staging.invocation" -readonly QUEUED_DOCUMENTATION_SOURCES="porkbun,porkbun-mcp,cloudflare,dev-java,kotlin,scala,groovy,clojure,spring-boot,quarkus,java/java21-complete,java/java25-complete,spring-ai-reference,spring-ai-api-stable,spring-framework-reference,spring-framework-api,oracle-java25-release-notes,ibm-java25-overview,jetbrains-java25-article" +readonly QUEUED_DOCUMENTATION_SOURCES="porkbun,porkbun-mcp,cloudflare,dev-java,kotlin,kotlin-api,scala,groovy,clojure,spring-boot,quarkus,java/java21-complete,java/java25-complete,spring-ai-reference,spring-ai-api-stable,spring-framework-reference,spring-framework-api,oracle-java25-release-notes,ibm-java25-overview,jetbrains-java25-article" readonly FINAL_JAVA_DOCUMENTATION_SOURCE="java/java26-complete" readonly QUEUED_STAGING_ATTEMPT_RECEIPT="$HOME/.local/state/java-chat/queued-platform-documentation-staging.attempted" diff --git a/scripts/test_documentation_fetch_projection.sh b/scripts/test_documentation_fetch_projection.sh index 8eff67c7..499d792d 100644 --- a/scripts/test_documentation_fetch_projection.sh +++ b/scripts/test_documentation_fetch_projection.sh @@ -323,6 +323,33 @@ assert_captured_arguments "$SELECTED_SOURCE_CAPTURE" \ --seed-source-prefix \ "https://kotlinlang.org/docs/" +if ! ( + run_documentation_fetch --doc-sets=kotlin-api > /dev/null +); then + fail_documentation_fetch_test "named Kotlin API selection did not complete" +fi + +assert_captured_arguments "$SELECTED_SOURCE_CAPTURE" \ + --url \ + "https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-nothing/" \ + --mirror-path \ + "kotlin-api" \ + --name \ + "Kotlin 2.4 Nothing API" \ + --source-version \ + "2.4" \ + --identity-regex \ + "kotlin-stdlib_2\\.4_latest" \ + --required-identity-page \ + "index.html" \ + --required-identity-text \ + "Nothing has no instances." \ + --cut-directories \ + 5 \ + --minimum-html-files \ + 1 \ + --single-page + if ! ( run_documentation_fetch --doc-sets=docker > /dev/null ); then @@ -789,6 +816,7 @@ if ! ( grep -Fxq porkbun "$DEFAULT_SOURCE_CAPTURE" \ && grep -Fxq porkbun-mcp "$DEFAULT_SOURCE_CAPTURE" \ && grep -Fxq cloudflare "$DEFAULT_SOURCE_CAPTURE" \ + && grep -Fxq kotlin-api "$DEFAULT_SOURCE_CAPTURE" \ && grep -Fxq java/java21-complete "$DEFAULT_SOURCE_CAPTURE" \ && grep -Fxq java/java25-complete "$DEFAULT_SOURCE_CAPTURE" \ && grep -Fxq java/java26-complete "$DEFAULT_SOURCE_CAPTURE" \ @@ -1550,6 +1578,35 @@ if python3 "$SCRIPT_DIR/documentation_seed.py" \ fail_documentation_fetch_test "a newer rolling Kotlin publication was accepted as 2.4.10" fi +KOTLIN_API_PINNED_STAGE="$TEST_WORK_DIRECTORY/kotlin-api-pinned-stage" +mkdir -p "$KOTLIN_API_PINNED_STAGE" +printf '
Nothing has no instances.
\n' \ + > "$KOTLIN_API_PINNED_STAGE/index.html" +if ! validate_staged_documentation_mirror \ + "$KOTLIN_API_PINNED_STAGE" \ + "Kotlin 2.4 Nothing API fixture" \ + 1 \ + 'kotlin-stdlib_2\.4_latest'; then + fail_documentation_fetch_test "Kotlin 2.4 API identity was rejected" +fi +if ! python3 "$SCRIPT_DIR/documentation_seed.py" \ + --validate-published-identity \ + --root "$KOTLIN_API_PINNED_STAGE" \ + --required-page "index.html" \ + --required-text "Nothing has no instances."; then + fail_documentation_fetch_test "Kotlin Nothing API definition was rejected" +fi +printf '
Nothing has no instances.
\n' \ + > "$KOTLIN_API_PINNED_STAGE/index.html" +if validate_staged_documentation_mirror \ + "$KOTLIN_API_PINNED_STAGE" \ + "Kotlin 2.4 Nothing API fixture" \ + 1 \ + 'kotlin-stdlib_2\.4_latest' \ + > /dev/null 2>&1; then + fail_documentation_fetch_test "a newer rolling Kotlin API page was accepted as 2.4" +fi + SPRING_AI_PINNED_STAGE="$TEST_WORK_DIRECTORY/spring-ai-pinned-stage" mkdir -p "$SPRING_AI_PINNED_STAGE" printf '\n' \ diff --git a/scripts/test_queued_platform_documentation_staging_contract.sh b/scripts/test_queued_platform_documentation_staging_contract.sh index 6cfb9549..55f1f9ea 100755 --- a/scripts/test_queued_platform_documentation_staging_contract.sh +++ b/scripts/test_queued_platform_documentation_staging_contract.sh @@ -87,7 +87,7 @@ if grep -Fq 'date -Ins' "$queued_launcher" \ fail_queued_platform_test "queued job does not use portable UTC timestamps" fi -grep -Fxq 'readonly QUEUED_DOCUMENTATION_SOURCES="porkbun,porkbun-mcp,cloudflare,dev-java,kotlin,scala,groovy,clojure,spring-boot,quarkus,java/java21-complete,java/java25-complete,spring-ai-reference,spring-ai-api-stable,spring-framework-reference,spring-framework-api,oracle-java25-release-notes,ibm-java25-overview,jetbrains-java25-article"' "$queued_launcher" \ +grep -Fxq 'readonly QUEUED_DOCUMENTATION_SOURCES="porkbun,porkbun-mcp,cloudflare,dev-java,kotlin,kotlin-api,scala,groovy,clojure,spring-boot,quarkus,java/java21-complete,java/java25-complete,spring-ai-reference,spring-ai-api-stable,spring-framework-reference,spring-framework-api,oracle-java25-release-notes,ibm-java25-overview,jetbrains-java25-article"' "$queued_launcher" \ || fail_queued_platform_test "remaining documentation fetch inventory or order changed" if grep -Fq 'QUEUED_DOCUMENTATION_SETS' "$queued_launcher"; then fail_queued_platform_test "documentation queue duplicates the canonical ingestion registry" diff --git a/src/test/java/com/williamcallahan/javachat/cli/DocumentProcessorSelectionTest.java b/src/test/java/com/williamcallahan/javachat/cli/DocumentProcessorSelectionTest.java index 3c09cd62..7c4c72b9 100644 --- a/src/test/java/com/williamcallahan/javachat/cli/DocumentProcessorSelectionTest.java +++ b/src/test/java/com/williamcallahan/javachat/cli/DocumentProcessorSelectionTest.java @@ -31,6 +31,7 @@ class DocumentProcessorSelectionTest { private static final String DOCSET_SPRING_FRAMEWORK_API_PATH = "spring-framework-api"; private static final String DOCSET_SPRING_FRAMEWORK_QUICK_PATH = "spring-framework"; private static final String DOCSET_GROOVY_MIRROR_PATH = "groovy/5.0.7"; + private static final String DOCSET_KOTLIN_API_MIRROR_PATH = "kotlin-api"; private final DocumentProcessor documentProcessor = new DocumentProcessor(mock(LocalDocumentationIngestionUseCase.class), mock(ProgressTracker.class)); @@ -119,6 +120,15 @@ void carriesExactIndexedDocSetSeparatelyFromVersionedMirrorPath() { assertEquals("groovy", groovyDocumentationSet.indexedDocSet()); } + @Test + void selectsKotlinApiMirrorWithItsDedicatedIndexedDocSet() { + DocumentationSet kotlinApiDocumentationSet = + selectDocumentationSets(DOCSET_KOTLIN_API_MIRROR_PATH, false).getFirst(); + + assertEquals(DOCSET_KOTLIN_API_MIRROR_PATH, kotlinApiDocumentationSet.relativePath()); + assertEquals("kotlin-api", kotlinApiDocumentationSet.indexedDocSet()); + } + @Test void initializesQdrantBeforeStartingDocumentProcessing() { QdrantIndexInitializer qdrantIndexInitializer = mock(QdrantIndexInitializer.class); From c4f4604bb68d0b678bb02da8c165f463633625cf Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 13:00:07 -0700 Subject: [PATCH 19/29] fix(ingestion): remove skip-link text before flattening Exclude fragment skip links before extracting article text. Retain short direct links that carry content. --- .../service/HtmlContentExtractor.java | 15 +++++++ .../service/HtmlContentExtractorTest.java | 43 ++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/williamcallahan/javachat/service/HtmlContentExtractor.java b/src/main/java/com/williamcallahan/javachat/service/HtmlContentExtractor.java index 0b1e3c3f..5ad154be 100644 --- a/src/main/java/com/williamcallahan/javachat/service/HtmlContentExtractor.java +++ b/src/main/java/com/williamcallahan/javachat/service/HtmlContentExtractor.java @@ -109,6 +109,8 @@ private String extractCleanContentFromClone(Document extractionDocument) { contentElement = extractionDocument.body(); } + removeSkipNavigationLinks(contentElement); + // Extract and clean the text String text = extractTextFromElement(contentElement); @@ -116,6 +118,16 @@ private String extractCleanContentFromClone(Document extractionDocument) { return filterNoise(text); } + private static void removeSkipNavigationLinks(Element contentElement) { + contentElement.select("a[href^=#]").stream() + .filter(anchor -> { + String normalizedAnchorText = AsciiTextNormalizer.toLowerAscii(anchor.text()); + return normalizedAnchorText.length() <= MIN_INLINE_TEXT_LENGTH + && normalizedAnchorText.startsWith("skip to "); + }) + .forEach(Element::remove); + } + private void removeNonContentElements(Element extractionElement) { for (String selector : REMOVE_SELECTORS) { extractionElement.select(selector).remove(); @@ -184,6 +196,9 @@ private void appendFormattedChild(Element child, StringBuilder sb) { sb.append("\n"); } case "table" -> appendTableContent(child, sb); + case String tag + when "a".equals(tag) && !child.attr("href").startsWith("#") -> + sb.append(" ").append(text); default -> { if (!isNavigationElement(child) && text.length() > MIN_INLINE_TEXT_LENGTH) { sb.append(" ").append(text); diff --git a/src/test/java/com/williamcallahan/javachat/service/HtmlContentExtractorTest.java b/src/test/java/com/williamcallahan/javachat/service/HtmlContentExtractorTest.java index a221a44b..e5ffa609 100644 --- a/src/test/java/com/williamcallahan/javachat/service/HtmlContentExtractorTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/HtmlContentExtractorTest.java @@ -47,7 +47,9 @@ void retainsMainContainerWhoseArticleFollowsSkipLinkText() { Skip to content

Show source citations in responses

-

Display source citations alongside generated answers.

+

Display source citations alongside generated answers. + Show source +

@@ -58,6 +60,45 @@ void retainsMainContainerWhoseArticleFollowsSkipLinkText() { assertTrue(extractedText.contains("Show source citations in responses")); assertTrue(extractedText.contains("Display source citations alongside generated answers.")); + assertTrue(extractedText.contains("Show source")); + assertFalse(extractedText.contains("Skip to content")); + assertEquals(2, document.select("a").size()); + assertEquals("Skip to content", document.selectFirst("a[href=#article]").text()); + } + + @Test + void removesSkipLinkBeforeFlatteningDirectArticleContent() { + Document document = Jsoup.parse(""" +
+ +
+ """); + HtmlContentExtractor extractor = new HtmlContentExtractor(); + + String extractedText = extractor.extractCleanContent(document); + + assertTrue(extractedText.contains("Article heading")); + assertTrue(extractedText.contains("Article body with Show details.")); + assertFalse(extractedText.contains("Skip to main content")); + assertEquals(2, document.select("a").size()); + } + + @Test + void retainsShortDirectShowLink() { + Document document = Jsoup.parse(""" +
+

Source controls

+ Show source +
+ """); + + String extractedText = new HtmlContentExtractor().extractCleanContent(document); + + assertTrue(extractedText.contains("Show source")); } @Test From 724c810edeb0845f1ebf29542e73b09d1fa50dcc Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 13:04:16 -0700 Subject: [PATCH 20/29] fix(markdown): preserve list and inline-code boundaries Preserve semantic list labels and keep enrichment parsing outside Markdown code regions. - retain colon-delimited labels, CommonMark fence indentation, and inline-code marker literals --- frontend/src/lib/services/markdown.test.ts | 27 ++++++- frontend/src/lib/services/markdown.ts | 78 ++++++++++++++----- .../services/numericListFenceNesting.test.ts | 23 ++++++ .../lib/services/numericListFenceNesting.ts | 4 +- .../service/markdown/InlineListParser.java | 4 - .../ComprehensiveListFormattingTest.java | 8 +- .../javachat/service/MarkdownServiceTest.java | 15 ++++ .../markdown/InlineListParserTest.java | 38 ++++++++- 8 files changed, 165 insertions(+), 32 deletions(-) diff --git a/frontend/src/lib/services/markdown.test.ts b/frontend/src/lib/services/markdown.test.ts index 25698268..23dc934f 100644 --- a/frontend/src/lib/services/markdown.test.ts +++ b/frontend/src/lib/services/markdown.test.ts @@ -85,7 +85,8 @@ describe("parseMarkdown", () => { "\ufeff", "\u200b", "\u2060", - " \t\u00a0\u2003\u200b\u2060\u3000\n", + "\u061c\u202e\u2066\u{e0001}", + " \t\u00a0\u2003\u200b\u2060\u3000\u{e0001}\n", ]; for (const isStreaming of [false, true]) { @@ -98,6 +99,30 @@ describe("parseMarkdown", () => { } }); + it("keeps enrichment markers inside inline code literal", () => { + const inlineCodeCases = ["`{{hint:test}}`", "``{{hint: test}}``"]; + + for (const isStreaming of [false, true]) { + for (const inlineCode of inlineCodeCases) { + const renderedHtml = parseMarkdown(`${inlineCode} then {{warning:outside}}`, isStreaming); + + expect(renderedHtml).toContain("{{hint:"); + expect(renderedHtml).toContain(""); + expect(renderedHtml.match(/data-enrichment-type=/g)).toHaveLength(1); + expect(renderedHtml).toContain('data-enrichment-type="warning"'); + } + } + }); + + it("renders a real enrichment after an unmatched backtick", () => { + for (const isStreaming of [false, true]) { + const renderedHtml = parseMarkdown("`unfinished text {{hint:outside}}", isStreaming); + + expect(renderedHtml).toContain('data-enrichment-type="hint"'); + expect(renderedHtml).toContain("outside"); + } + }); + it("keeps multiline inline-code close candidates literal in complete and streaming enrichments", () => { const enrichmentMarkdown = [ "{{warning: Keep `literal }}", diff --git a/frontend/src/lib/services/markdown.ts b/frontend/src/lib/services/markdown.ts index 5ef15336..f8ebb695 100644 --- a/frontend/src/lib/services/markdown.ts +++ b/frontend/src/lib/services/markdown.ts @@ -9,8 +9,7 @@ interface EnrichmentPresentation { } const NEWLINE = "\n"; -const ZERO_WIDTH_SPACE_CODE_POINT = 0x200b; -const WORD_JOINER_CODE_POINT = 0x2060; +const UNICODE_FORMAT_CHARACTER_PATTERN = /\p{Cf}/u; /** Matches absolute web URLs; relative and fragment links stay in the current tab. */ const EXTERNAL_LINK_PATTERN = /^https?:\/\//i; @@ -477,20 +476,59 @@ function readEnrichmentOpening(src: string, index: number): EnrichmentOpening | } function findEnrichmentStart(src: string): number { - let openingIndex = -1; - for (const kind of ENRICHMENT_PRESENTATIONS_BY_TOKEN.keys()) { - const candidateIndex = src.indexOf(`{{${kind}:`); - if (candidateIndex >= 0 && (openingIndex < 0 || candidateIndex < openingIndex)) { - openingIndex = candidateIndex; + const codeRegionState = new MarkdownCodeRegionState(); + for (let cursor = 0; cursor < src.length; ) { + const startOfLine = cursor === 0 || src[cursor - 1] === NEWLINE; + if (startOfLine && !codeRegionState.isInsideInlineCode()) { + if (!codeRegionState.isInsideFence() && isIndentedCodeLine(src, cursor)) { + const lineEnd = src.indexOf(NEWLINE, cursor); + cursor = lineEnd < 0 ? src.length : lineEnd + 1; + continue; + } + + const fenceCandidate = scanFenceAfterCommonMarkIndentation(src, cursor); + if (fenceCandidate) { + const { marker, markerIndex } = fenceCandidate; + if (!codeRegionState.isInsideFence()) { + codeRegionState.enterFence(marker, false); + } else if (codeRegionState.wouldCloseFence(src, markerIndex, marker)) { + codeRegionState.exitFence(); + } + cursor = markerIndex + marker.length; + continue; + } } + + if (!codeRegionState.isInsideFence()) { + const backtickRun = scanBacktickRun(src, cursor); + if (backtickRun) { + codeRegionState.processBacktickRun(src, cursor, backtickRun); + cursor += backtickRun.length; + continue; + } + } + + if ( + !codeRegionState.isInsideFence() && + !codeRegionState.isInsideInlineCode() && + src[cursor] === "{" && + src[cursor + 1] === "{" + ) { + const opening = readEnrichmentOpening(src, cursor); + if (opening) { + let precedingIndex = cursor - 1; + while (precedingIndex >= 0 && " \t\r\n".includes(src[precedingIndex])) { + precedingIndex--; + } + return src[precedingIndex] === "}" && src[precedingIndex - 1] !== "}" + ? precedingIndex + : cursor; + } + } + + cursor++; } - let precedingIndex = openingIndex - 1; - while (precedingIndex >= 0 && " \t\r\n".includes(src[precedingIndex])) { - precedingIndex--; - } - return src[precedingIndex] === "}" && src[precedingIndex - 1] !== "}" - ? precedingIndex - : openingIndex; + return -1; } /** @@ -569,11 +607,8 @@ function findEnrichmentClose(src: string, startIndex: number, isStreaming: boole function isBlankEnrichmentText(enrichmentMarkdown: string): boolean { for (const character of enrichmentMarkdown) { - const codePoint = character.codePointAt(0); const isBlankCharacter = - character.trim().length === 0 || - codePoint === ZERO_WIDTH_SPACE_CODE_POINT || - codePoint === WORD_JOINER_CODE_POINT; + character.trim().length === 0 || UNICODE_FORMAT_CHARACTER_PATTERN.test(character); if (!isBlankCharacter) return false; } @@ -589,13 +624,16 @@ function createEnrichmentExtension( isStreaming: boolean, markdownParser: Marked, ): TokenizerExtension & RendererExtension { + let fullBlockSource = ""; return { name: "enrichment", level: "block", - start(src: string) { - return findEnrichmentStart(src); + start() { + const openingIndex = findEnrichmentStart(fullBlockSource); + return openingIndex > 0 ? openingIndex - 1 : -1; }, tokenizer(src: string): EnrichmentToken | undefined { + fullBlockSource = src; if (src[0] === "}") { return { type: "enrichment", raw: "}", kind: "", content: "", resolved: false }; } diff --git a/frontend/src/lib/services/numericListFenceNesting.test.ts b/frontend/src/lib/services/numericListFenceNesting.test.ts index 5f90d146..84202028 100644 --- a/frontend/src/lib/services/numericListFenceNesting.test.ts +++ b/frontend/src/lib/services/numericListFenceNesting.test.ts @@ -100,6 +100,29 @@ describe("numeric list fence nesting", () => { } }); + it("accepts CommonMark closing indentation after a column-zero opening fence", () => { + for (const fenceMarker of ["```", "~~~"]) { + for (const closingOffset of [1, 2, 3, 4]) { + const closingIndentation = " ".repeat(3 + closingOffset); + const markdown = [ + "1. Template example", + `${fenceMarker}text`, + "template body", + `${closingIndentation}${fenceMarker}`, + ].join("\n"); + + for (const isStreaming of [false, true]) { + const renderedContainer = renderMarkdown(markdown, isStreaming); + const nestedCodeBlocks = renderedContainer.querySelectorAll("ol > li pre > code"); + + expect(nestedCodeBlocks).toHaveLength(1); + expect(nestedCodeBlocks[0].textContent).toContain("template body"); + expect(nestedCodeBlocks[0].textContent?.includes(fenceMarker)).toBe(closingOffset === 4); + } + } + } + }); + it("repairs fences in consecutive list items and after headings", () => { const consecutiveItems = [ "1. First", diff --git a/frontend/src/lib/services/numericListFenceNesting.ts b/frontend/src/lib/services/numericListFenceNesting.ts index 3f32860f..e8d79c3d 100644 --- a/frontend/src/lib/services/numericListFenceNesting.ts +++ b/frontend/src/lib/services/numericListFenceNesting.ts @@ -264,9 +264,7 @@ export function nestNumericListFences( ); targetIndentation = awaitedContinuationIndentation; maximumSourceIndentation = - awaitedContinuationIndentation + - COMMONMARK_MAX_FENCE_INDENTATION - - bodyIndentation.length; + awaitedContinuationIndentation + COMMONMARK_MAX_FENCE_INDENTATION; fenceState.open(fenceCandidate.marker); awaitedContinuationIndentation = null; nestedLines.push( diff --git a/src/main/java/com/williamcallahan/javachat/service/markdown/InlineListParser.java b/src/main/java/com/williamcallahan/javachat/service/markdown/InlineListParser.java index 04ec4803..83b70b65 100644 --- a/src/main/java/com/williamcallahan/javachat/service/markdown/InlineListParser.java +++ b/src/main/java/com/williamcallahan/javachat/service/markdown/InlineListParser.java @@ -327,10 +327,6 @@ private static ParsedEntry splitNestedList(String rawEntryText) { private static String normalizeEntryLabel(String raw) { String label = raw == null ? "" : raw.trim(); - int colonIndex = label.indexOf(':'); - if (colonIndex > 0) { - label = label.substring(0, colonIndex).trim(); - } while (!label.isEmpty()) { char last = label.charAt(label.length() - 1); if (last == '.' || last == '!' || last == '?') { diff --git a/src/test/java/com/williamcallahan/javachat/service/ComprehensiveListFormattingTest.java b/src/test/java/com/williamcallahan/javachat/service/ComprehensiveListFormattingTest.java index ab78b5fa..85eff41b 100644 --- a/src/test/java/com/williamcallahan/javachat/service/ComprehensiveListFormattingTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/ComprehensiveListFormattingTest.java @@ -254,8 +254,12 @@ void testComplexRealWorldExample() { assertTrue(html.contains("
    "), "Should contain ordered lists"); assertTrue(html.contains("
      "), "Should contain unordered lists"); assertTrue(html.contains("
    • Primitive types
    • "), "Should format main numbered list"); - assertTrue(html.contains("
    • boolean
    • "), "Should format nested letter list"); - assertTrue(html.contains("
    • byte
    • "), "Should continue letter list"); + assertTrue( + html.contains("
    • boolean: true/false
    • "), + "Should preserve nested letter-list details after colons"); + assertTrue( + html.contains("
    • byte: 8-bit
    • "), + "Should preserve subsequent nested letter-list details after colons"); assertTrue(html.contains("
    • Reference types
    • "), "Should continue numbered list"); assertTrue(html.contains("
    • Arrays
    • "), "Should format nested dash list"); assertTrue(html.contains("
    • Classes
    • "), "Should continue dash list"); diff --git a/src/test/java/com/williamcallahan/javachat/service/MarkdownServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/MarkdownServiceTest.java index edd9084d..d3fb0acd 100644 --- a/src/test/java/com/williamcallahan/javachat/service/MarkdownServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/MarkdownServiceTest.java @@ -418,6 +418,21 @@ void testInlineCodeMarkerNotRendered() { assertTrue(html.contains("{{hint: no-card}}"), "Inline code should preserve marker text"); } + @Test + @DisplayName("Ordered and bullet list labels preserve times after colons") + void testInlineListLabelsPreserveTimesAfterColons() { + String orderedMarkdown = "Schedule: 1. Monday: 9:00 AM 2. Tuesday: 2:30 PM"; + String bulletMarkdown = "Schedule: - Monday: 9:00 AM - Tuesday: 2:30 PM"; + + String orderedHtml = markdownService.processStructured(orderedMarkdown).html(); + String bulletHtml = markdownService.processStructured(bulletMarkdown).html(); + + assertTrue(orderedHtml.contains("
    • Monday: 9:00 AM
    • ")); + assertTrue(orderedHtml.contains("
    • Tuesday: 2:30 PM
    • ")); + assertTrue(bulletHtml.contains("
    • Monday: 9:00 AM
    • ")); + assertTrue(bulletHtml.contains("
    • Tuesday: 2:30 PM
    • ")); + } + @Test @DisplayName("Enrichment closing marker preserves trailing content brace") void testEnrichmentClosingMarkerPreservesTrailingBrace() { diff --git a/src/test/java/com/williamcallahan/javachat/service/markdown/InlineListParserTest.java b/src/test/java/com/williamcallahan/javachat/service/markdown/InlineListParserTest.java index c7c0a47f..6091c79d 100644 --- a/src/test/java/com/williamcallahan/javachat/service/markdown/InlineListParserTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/markdown/InlineListParserTest.java @@ -127,8 +127,42 @@ void tryConvert_nestedList_extractsNestedSegment() { assertNotNull(conversion); assertEquals("ol", conversion.primaryListElement().tagName()); assertEquals(2, conversion.primaryListElement().children().size()); - // Nested lists are extracted as additional elements - assertNotNull(conversion.additionalListElements()); + assertEquals("Parent", conversion.primaryListElement().child(0).text()); + assertEquals("Another", conversion.primaryListElement().child(1).text()); + assertEquals(1, conversion.additionalListElements().size()); + assertEquals( + "Child one", + conversion.additionalListElements().getFirst().child(0).text()); + assertEquals( + "Child two", + conversion.additionalListElements().getFirst().child(1).text()); + } + + @Test + void tryConvert_preservesNonListColonText() { + List colonLabels = List.of( + "Monday: 9:00 AM", + "Ratio: 3:1", + "URL: https://example.test/reference", + "Definition: immutable state", + "Explanation: records carry data"); + + for (String colonLabel : colonLabels) { + InlineListParser.Conversion conversion = InlineListParser.tryConvert("1. " + colonLabel + " 2. Follow-up"); + + assertNotNull(conversion, colonLabel); + assertEquals(colonLabel, conversion.primaryListElement().child(0).text(), colonLabel); + } + } + + @Test + void tryConvert_bulletsPreserveNonListColonText() { + InlineListParser.Conversion conversion = InlineListParser.tryConvert("- Monday: 9:00 AM - Tuesday: 2:30 PM"); + + assertNotNull(conversion); + assertEquals("Monday: 9:00 AM", conversion.primaryListElement().child(0).text()); + assertEquals( + "Tuesday: 2:30 PM", conversion.primaryListElement().child(1).text()); } @Test From 79f3a1f73994f486130c7c625a71f7891088e580 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 13:04:48 -0700 Subject: [PATCH 21/29] fix(streaming): enforce visible-content and event boundaries Treat all Unicode format controls as invisible and require complete SSE event framing. - stop stale auto-follow, preserve split decoding, and reject unterminated event payloads --- frontend/src/lib/components/ChatView.test.ts | 4 +- frontend/src/lib/components/LearnView.test.ts | 4 +- .../createScrollAnchor.svelte.test.ts | 46 ++++++++-- .../composables/createScrollAnchor.svelte.ts | 3 +- frontend/src/lib/services/chat.test.ts | 6 +- frontend/src/lib/services/chat.ts | 10 +-- frontend/src/lib/services/sse.test.ts | 61 ++++++++++++- frontend/src/lib/services/sse.ts | 87 +++++++++---------- .../domain/text/UnicodeVisibleContent.java | 13 +-- .../service/OpenAIStreamingServiceTest.java | 4 +- .../javachat/web/ChatStreamRequestTest.java | 12 ++- 11 files changed, 161 insertions(+), 89 deletions(-) diff --git a/frontend/src/lib/components/ChatView.test.ts b/frontend/src/lib/components/ChatView.test.ts index 19f1089e..470320ec 100644 --- a/frontend/src/lib/components/ChatView.test.ts +++ b/frontend/src/lib/components/ChatView.test.ts @@ -298,7 +298,7 @@ describe("ChatView streaming stability", () => { it("treats invisible streamed characters as an empty failed response", async () => { streamChatMock.mockImplementation(async (_sessionId, _message, onChunk) => { - onChunk(" \t\n\u200B\uFEFF\u2060"); + onChunk(" \t\n\u061C\u202E\u2066\u{E0001}"); throw new Error(TERMINAL_STREAM_FAILURE_MESSAGE); }); @@ -312,7 +312,7 @@ describe("ChatView streaming stability", () => { it("keeps the connecting indicator active for an unresolved invisible chunk", async () => { streamChatMock.mockImplementation(async (_sessionId, _message, onChunk) => { - onChunk("\u200B\uFEFF\u2060"); + onChunk("\u061C\u202E\u2066\u{E0001}"); return new Promise(() => {}); }); diff --git a/frontend/src/lib/components/LearnView.test.ts b/frontend/src/lib/components/LearnView.test.ts index 428841ca..1999591d 100644 --- a/frontend/src/lib/components/LearnView.test.ts +++ b/frontend/src/lib/components/LearnView.test.ts @@ -594,7 +594,7 @@ describe("LearnView guided chat streaming stability", () => { it("treats invisible guided chunks as an empty failed response", async () => { streamGuidedChatMock.mockImplementation( async (_sessionId, _lessonSlug, _message, callbacks) => { - callbacks.onChunk(" \t\n\u200B\uFEFF\u2060"); + callbacks.onChunk(" \t\n\u061C\u202E\u2066\u{E0001}"); throw new Error(TERMINAL_GUIDED_STREAM_FAILURE_MESSAGE); }, ); @@ -610,7 +610,7 @@ describe("LearnView guided chat streaming stability", () => { it("keeps guided connecting indicators active for an unresolved invisible chunk", async () => { streamGuidedChatMock.mockImplementation( async (_sessionId, _lessonSlug, _message, callbacks) => { - callbacks.onChunk("\u200B\uFEFF\u2060"); + callbacks.onChunk("\u061C\u202E\u2066\u{E0001}"); return new Promise(() => {}); }, ); diff --git a/frontend/src/lib/composables/createScrollAnchor.svelte.test.ts b/frontend/src/lib/composables/createScrollAnchor.svelte.test.ts index 5d299380..60aa3a08 100644 --- a/frontend/src/lib/composables/createScrollAnchor.svelte.test.ts +++ b/frontend/src/lib/composables/createScrollAnchor.svelte.test.ts @@ -248,14 +248,17 @@ describe("createScrollAnchor final content", () => { setScrollGeometry(scrollContainer, 3_457, 4_157, 200); await scrollAnchor.onContentAdded(); - expect(scrollToSpy).toHaveBeenLastCalledWith({ top: 4_157, behavior: "auto" }); + expect(scrollToSpy).toHaveBeenLastCalledWith({ top: 4_157, behavior: "instant" }); expect(scrollAnchor.unseenCount).toBe(0); expect(scrollAnchor.showIndicator).toBe(false); + setScrollGeometry(scrollContainer, 3_957, 4_157, 200); + scrollAnchor.onUserScroll(); + vi.advanceTimersByTime(1_000); setScrollGeometry(scrollContainer, 3_957, 5_157, 200); await scrollAnchor.onContentAdded(); - expect(scrollToSpy).toHaveBeenLastCalledWith({ top: 5_157, behavior: "auto" }); + expect(scrollToSpy).toHaveBeenLastCalledWith({ top: 5_157, behavior: "instant" }); expect(scrollAnchor.unseenCount).toBe(0); expect(scrollAnchor.showIndicator).toBe(false); scrollAnchor.cleanup(); @@ -285,6 +288,33 @@ describe("createScrollAnchor final content", () => { scrollAnchor.cleanup(); }); + it("stops following after a native scrollbar-only scroll-away", async () => { + vi.useFakeTimers(); + const scrollContainer = document.createElement("div"); + setScrollGeometry(scrollContainer, 800, 3_657, 200); + const scrollToSpy = vi.spyOn(scrollContainer, "scrollTo"); + const scrollAnchor = createScrollAnchor({ indicatorDelayMs: 150 }); + scrollAnchor.attach(scrollContainer); + await scrollAnchor.jumpToBottom(); + scrollToSpy.mockClear(); + + setScrollGeometry(scrollContainer, 3_457, 4_157, 200); + await scrollAnchor.onContentAdded(); + expect(scrollToSpy).toHaveBeenCalledOnce(); + scrollToSpy.mockClear(); + + setScrollGeometry(scrollContainer, 2_000, 5_157, 200); + scrollAnchor.onUserScroll(); + setScrollGeometry(scrollContainer, 2_000, 6_157, 200); + await scrollAnchor.onContentAdded(); + vi.advanceTimersByTime(150); + + expect(scrollToSpy).not.toHaveBeenCalled(); + expect(scrollAnchor.unseenCount).toBe(1); + expect(scrollAnchor.showIndicator).toBe(true); + scrollAnchor.cleanup(); + }); + it.each(USER_SCROLL_INTENT_CASES)( "cancels a pending follow scroll immediately on $name intent", async ({ createScrollIntentEvent }) => { @@ -324,12 +354,12 @@ describe("createScrollAnchor final content", () => { scrollContainer.dispatchEvent(new Event("pointerdown")); setScrollGeometry(scrollContainer, 3_457, 4_157, 200); await scrollAnchor.onContentAdded(); - expect(scrollToSpy).toHaveBeenCalledWith({ top: 4_157, behavior: "auto" }); + expect(scrollToSpy).toHaveBeenCalledWith({ top: 4_157, behavior: "instant" }); scrollToSpy.mockClear(); setScrollGeometry(scrollContainer, 3_957, 4_657, 200); await scrollAnchor.onContentAdded(); - expect(scrollToSpy).toHaveBeenCalledWith({ top: 4_657, behavior: "auto" }); + expect(scrollToSpy).toHaveBeenCalledWith({ top: 4_657, behavior: "instant" }); scrollAnchor.cleanup(); }); @@ -352,7 +382,7 @@ describe("createScrollAnchor final content", () => { await Promise.all([staleDesktopFollowScroll, mobileFollowScroll]); expect(desktopScrollToSpy).not.toHaveBeenCalled(); - expect(mobileScrollToSpy).toHaveBeenCalledWith({ top: 4_000, behavior: "auto" }); + expect(mobileScrollToSpy).toHaveBeenCalledWith({ top: 4_000, behavior: "instant" }); expect(scrollAnchor.unseenCount).toBe(0); expect(scrollAnchor.showIndicator).toBe(false); scrollAnchor.cleanup(); @@ -375,7 +405,7 @@ describe("createScrollAnchor final content", () => { ]); expect(scrollToSpy).toHaveBeenCalledOnce(); - expect(scrollToSpy).toHaveBeenCalledWith({ top: 4_157, behavior: "auto" }); + expect(scrollToSpy).toHaveBeenCalledWith({ top: 4_157, behavior: "instant" }); expect(scrollAnchor.showIndicator).toBe(false); scrollAnchor.cleanup(); }); @@ -399,7 +429,7 @@ describe("createScrollAnchor final content", () => { await lateContentFollow; expect(scrollToSpy).toHaveBeenCalledTimes(2); - expect(scrollToSpy).toHaveBeenLastCalledWith({ top: 4_657, behavior: "auto" }); + expect(scrollToSpy).toHaveBeenLastCalledWith({ top: 4_657, behavior: "instant" }); scrollAnchor.cleanup(); }); @@ -446,7 +476,7 @@ describe("createScrollAnchor final content", () => { setScrollGeometry(scrollContainer, 3_457, 4_157, 200); await scrollAnchor.onContentAdded(); - expect(scrollToSpy).toHaveBeenCalledWith({ top: 4_157, behavior: "auto" }); + expect(scrollToSpy).toHaveBeenCalledWith({ top: 4_157, behavior: "instant" }); expect(scrollAnchor.showIndicator).toBe(false); scrollAnchor.cleanup(); }); diff --git a/frontend/src/lib/composables/createScrollAnchor.svelte.ts b/frontend/src/lib/composables/createScrollAnchor.svelte.ts index 79be09d4..1a190fa5 100644 --- a/frontend/src/lib/composables/createScrollAnchor.svelte.ts +++ b/frontend/src/lib/composables/createScrollAnchor.svelte.ts @@ -384,8 +384,9 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { programmaticScrollActive = true; followedContainer.scrollTo({ top: currentScrollHeight, - behavior: "auto", + behavior: "instant", }); + programmaticScrollActive = false; } while (activeStreamFollowDirty); } diff --git a/frontend/src/lib/services/chat.test.ts b/frontend/src/lib/services/chat.test.ts index 6de8c152..77544745 100644 --- a/frontend/src/lib/services/chat.test.ts +++ b/frontend/src/lib/services/chat.test.ts @@ -125,10 +125,12 @@ describe("streamChat", () => { describe("hasVisibleChatMessageText", () => { it("rejects whitespace and invisible format characters", () => { - expect(hasVisibleChatMessageText(" \t\n\u200B\u200C\u200D\uFEFF\u2060")).toBe(false); + expect( + hasVisibleChatMessageText(" \t\n\u200B\u200C\u200D\uFEFF\u2060\u061C\u202E\u2066\u{E0001}"), + ).toBe(false); }); it("accepts a visible code point among invisible characters", () => { - expect(hasVisibleChatMessageText("\u200BJava 24\u2060")).toBe(true); + expect(hasVisibleChatMessageText("\u202EJava 24\u2066\u{E0001}")).toBe(true); }); }); diff --git a/frontend/src/lib/services/chat.ts b/frontend/src/lib/services/chat.ts index 7ec664cc..0246fb9d 100644 --- a/frontend/src/lib/services/chat.ts +++ b/frontend/src/lib/services/chat.ts @@ -32,20 +32,14 @@ export interface ChatMessage { errorRetryable?: boolean; } -const INVISIBLE_CHAT_FORMAT_CHARACTERS = new Set([ - "\u200B", - "\u200C", - "\u200D", - "\uFEFF", - "\u2060", -]); +const UNICODE_FORMAT_CHARACTER_PATTERN = /\p{Cf}/u; /** Reports whether streamed message text contains a user-visible character. */ export function hasVisibleChatMessageText(messageText: string): boolean { for (const messageCharacter of messageText) { if ( messageCharacter.trim().length > 0 && - !INVISIBLE_CHAT_FORMAT_CHARACTERS.has(messageCharacter) + !UNICODE_FORMAT_CHARACTER_PATTERN.test(messageCharacter) ) { return true; } diff --git a/frontend/src/lib/services/sse.test.ts b/frontend/src/lib/services/sse.test.ts index 6c048957..00822ef7 100644 --- a/frontend/src/lib/services/sse.test.ts +++ b/frontend/src/lib/services/sse.test.ts @@ -378,17 +378,72 @@ describe("streamSse payload validation", () => { vi.restoreAllMocks(); }); - it("dispatches canonical chunk payloads", async () => { + it.each(['event: text\ndata: {"text":"Hello"}\n\n', 'event: text\ndata:{"text":"Hello"}\n\n'])( + "dispatches canonical chunk payloads", + async (sseWireText) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createSseStreamResponse(sseWireText))); + const onText = vi.fn(); + + await streamSse("/api/test/stream", {}, { onText }, "sse.test.ts"); + + expect(onText).toHaveBeenCalledOnce(); + expect(onText).toHaveBeenCalledWith("Hello"); + }, + ); + + it.each(['event: text\ndata: {"text":"Hello"}', 'event: text\ndata: {"text":"Hello"}\n'])( + "rejects an SSE event that ends before a blank line", + async (sseWireText) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createSseStreamResponse(sseWireText))); + const onText = vi.fn(); + const onError = vi.fn(); + + await expect( + streamSse("/api/test/stream", {}, { onText, onError }, "sse.test.ts"), + ).rejects.toThrow("Received an invalid SSE event from the server"); + + expect(onText).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith({ + message: "Received an invalid SSE event from the server", + }); + }, + ); + + it("accepts a terminal DONE line without dispatching content", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createSseStreamResponse("data:[DONE]"))); + const onText = vi.fn(); + const onError = vi.fn(); + + await streamSse("/api/test/stream", {}, { onText, onError }, "sse.test.ts"); + + expect(onText).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + it("preserves CRLF, multiline data, and split UTF-8 decoding", async () => { + const encoder = new TextEncoder(); + const sseBytes = encoder.encode('event: text\r\ndata: {"text":\r\ndata: "héllo"}\r\n\r\n'); + const multibyteStart = sseBytes.indexOf(0xc3); + const sseStreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(sseBytes.slice(0, multibyteStart + 1)); + controller.enqueue(sseBytes.slice(multibyteStart + 1)); + controller.close(); + }, + }); vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue(createSseStreamResponse('event: text\ndata: {"text":"Hello"}\n\n')), + vi + .fn() + .mockResolvedValue(new Response(sseStreamBody, { status: SSE_STREAM_RESPONSE_STATUS })), ); const onText = vi.fn(); await streamSse("/api/test/stream", {}, { onText }, "sse.test.ts"); expect(onText).toHaveBeenCalledOnce(); - expect(onText).toHaveBeenCalledWith("Hello"); + expect(onText).toHaveBeenCalledWith("héllo"); }); it("preserves a valid structured status event", async () => { diff --git a/frontend/src/lib/services/sse.ts b/frontend/src/lib/services/sse.ts index 4a2d7abb..cf75714c 100644 --- a/frontend/src/lib/services/sse.ts +++ b/frontend/src/lib/services/sse.ts @@ -357,6 +357,42 @@ async function consumeSseStream( processEvent(sseEventType, rawSseEventText, callbacks, source); }; + const processSseLine = (decodedSseLine: string) => { + const receivedLine = decodedSseLine.endsWith("\r") + ? decodedSseLine.slice(0, -1) + : decodedSseLine; + + if (receivedLine.startsWith(":")) { + return; + } + + if (receivedLine.startsWith("event:")) { + currentSseEventType = receivedLine.startsWith("event: ") + ? receivedLine.slice(7) + : receivedLine.slice(6); + return; + } + + if (receivedLine.startsWith("data:")) { + const sseEventText = receivedLine.startsWith("data: ") + ? receivedLine.slice(6) + : receivedLine.slice(5); + if (sseEventText === "[DONE]") { + return; + } + if (hasBufferedSseEvent) { + sseEventBuffer += "\n"; + } + sseEventBuffer += sseEventText; + hasBufferedSseEvent = true; + return; + } + + if (receivedLine.trim() === "") { + flushSseEvent(); + } + }; + try { while (true) { const { done: streamEnded, value: byteSegment } = await sseReader @@ -375,15 +411,13 @@ async function consumeSseStream( if (remainingDecodedText) { unprocessedText += remainingDecodedText; } - // Commit any remaining buffered line before flushing event data if (unprocessedText.length > 0) { - sseEventBuffer = sseEventBuffer - ? `${sseEventBuffer}\n${unprocessedText}` - : unprocessedText; - hasBufferedSseEvent = true; + processSseLine(unprocessedText); unprocessedText = ""; } - flushSseEvent(); + if (hasBufferedSseEvent) { + throwInvalidSseEvent(callbacks); + } break; } @@ -393,46 +427,7 @@ async function consumeSseStream( unprocessedText = receivedLines[receivedLines.length - 1]; for (let lineIndex = 0; lineIndex < receivedLines.length - 1; lineIndex++) { - let receivedLine = receivedLines[lineIndex]; - if (receivedLine.endsWith("\r")) { - receivedLine = receivedLine.slice(0, -1); - } - - // Skip SSE comments (keepalive heartbeats) - if (receivedLine.startsWith(":")) { - continue; - } - - // Track event type - if (receivedLine.startsWith("event:")) { - currentSseEventType = receivedLine.startsWith("event: ") - ? receivedLine.slice(7) - : receivedLine.slice(6); - continue; - } - - // Accumulate data within current SSE event - if (receivedLine.startsWith("data:")) { - // Per SSE spec, strip optional space after "data:" prefix - const sseEventText = receivedLine.startsWith("data: ") - ? receivedLine.slice(6) - : receivedLine.slice(5); - - // Skip [DONE] token - if (sseEventText === "[DONE]") { - continue; - } - - // Accumulate within current SSE event - if (hasBufferedSseEvent) { - sseEventBuffer += "\n"; - } - sseEventBuffer += sseEventText; - hasBufferedSseEvent = true; - } else if (receivedLine.trim() === "") { - // Blank line marks end of SSE event - commit accumulated data - flushSseEvent(); - } + processSseLine(receivedLines[lineIndex]); } } } catch (streamError) { diff --git a/src/main/java/com/williamcallahan/javachat/domain/text/UnicodeVisibleContent.java b/src/main/java/com/williamcallahan/javachat/domain/text/UnicodeVisibleContent.java index 07647a4b..56cef033 100644 --- a/src/main/java/com/williamcallahan/javachat/domain/text/UnicodeVisibleContent.java +++ b/src/main/java/com/williamcallahan/javachat/domain/text/UnicodeVisibleContent.java @@ -9,13 +9,6 @@ * Unicode space separators.

      */ public final class UnicodeVisibleContent { - - private static final int ZERO_WIDTH_SPACE = 0x200B; - private static final int ZERO_WIDTH_NON_JOINER = 0x200C; - private static final int ZERO_WIDTH_JOINER = 0x200D; - private static final int ZERO_WIDTH_NO_BREAK_SPACE = 0xFEFF; - private static final int WORD_JOINER = 0x2060; - private UnicodeVisibleContent() {} /** @@ -31,10 +24,6 @@ public static boolean hasVisibleContent(String candidateText) { private static boolean isVisible(int codePoint) { return !Character.isWhitespace(codePoint) && !Character.isSpaceChar(codePoint) - && codePoint != ZERO_WIDTH_SPACE - && codePoint != ZERO_WIDTH_NON_JOINER - && codePoint != ZERO_WIDTH_JOINER - && codePoint != ZERO_WIDTH_NO_BREAK_SPACE - && codePoint != WORD_JOINER; + && Character.getType(codePoint) != Character.FORMAT; } } diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingServiceTest.java index 911d9cbb..95335a1d 100644 --- a/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingServiceTest.java @@ -68,7 +68,7 @@ class OpenAIStreamingServiceTest { private static final String OPENAI_BASE_URL = "https://api.llm-gateway.iocloudhost.net/v1"; private static final long CONFIGURED_PROVIDER_BACKOFF_SECONDS = 600L; private static final int TEST_COMPLETION_OUTPUT_TOKEN_BUDGET = 768; - private static final String INVISIBLE_PROVIDER_DELTA = " \t\u200B"; + private static final String INVISIBLE_PROVIDER_DELTA = " \t\u061C\u202E\u2066\uDB40\uDC01"; private static final Duration INVISIBLE_PROVIDER_DELTA_INTERVAL = Duration.ofSeconds(3); private static final Duration VISIBLE_OUTPUT_DEADLINE_REMAINDER = Duration.ofSeconds(2); private static final Duration MID_RESPONSE_VISIBLE_OUTPUT_PAUSE = Duration.ofSeconds(21); @@ -168,7 +168,7 @@ void emptyTextDeltaBeforeStreamingFailureIsTerminal() { @Test void providerStreamWithoutVisibleTextIsTerminal() { - for (String invisibleText : List.of("", " \t\n\u200B\uFEFF\u2060")) { + for (String invisibleText : List.of("", INVISIBLE_PROVIDER_DELTA)) { RateLimitService rateLimitService = mock(RateLimitService.class); StreamResponse providerStream = mock(); ResponseStreamEvent invisibleTextEvent = mock(ResponseStreamEvent.class); diff --git a/src/test/java/com/williamcallahan/javachat/web/ChatStreamRequestTest.java b/src/test/java/com/williamcallahan/javachat/web/ChatStreamRequestTest.java index 2f085f54..fff131dd 100644 --- a/src/test/java/com/williamcallahan/javachat/web/ChatStreamRequestTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/ChatStreamRequestTest.java @@ -65,9 +65,10 @@ class ChatStreamRequestTest { @Test void retainsValidLatestQuery() { - ChatStreamRequest request = new ChatStreamRequest(SESSION_ID, USER_QUERY); + String queryWithFormatCharacters = "\u202E" + USER_QUERY + "\u2066"; + ChatStreamRequest request = new ChatStreamRequest(SESSION_ID, queryWithFormatCharacters); - assertEquals(USER_QUERY, request.latest()); + assertEquals(queryWithFormatCharacters, request.latest()); } @ParameterizedTest(name = "{0} cannot be a latest query") @@ -108,6 +109,7 @@ private static Stream invalidChatStreamRequestBodies() { "{\"sessionId\":\"request-session\",\"latest\":null}", "{\"sessionId\":\"request-session\",\"latest\":\"\"}", "{\"sessionId\":\"request-session\",\"latest\":\"\u2003\"}", + "{\"sessionId\":\"request-session\",\"latest\":\"\u202E\u2066\"}", "{\"sessionId\":\"request-session\",\"message\":\"legacy query\"}"); } @@ -120,6 +122,10 @@ private static Stream unicodeBlankLatestQueries() { Arguments.of("U+200B ZERO WIDTH SPACE", "\u200B"), Arguments.of("U+200C ZERO WIDTH NON-JOINER", "\u200C"), Arguments.of("U+200D ZERO WIDTH JOINER", "\u200D"), - Arguments.of("U+2060 WORD JOINER", "\u2060")); + Arguments.of("U+2060 WORD JOINER", "\u2060"), + Arguments.of("U+061C ARABIC LETTER MARK", "\u061C"), + Arguments.of("U+202E RIGHT-TO-LEFT OVERRIDE", "\u202E"), + Arguments.of("U+2066 LEFT-TO-RIGHT ISOLATE", "\u2066"), + Arguments.of("U+E0001 LANGUAGE TAG", "\uDB40\uDC01")); } } From 816a7340deef388b1bf2a111322be9106c28006e Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 13:05:24 -0700 Subject: [PATCH 22/29] fix(ingestion): complete batch state and escape repository paths Finish durable marker transitions for stored batches and keep untrusted paths from forging log lines. - restart failed marker-backed inventories and preserve canonical file identities outside logging --- config/spotbugs/spotbugs-exclude.xml | 4 -- .../javachat/cli/GitHubRepoProcessor.java | 12 +++- .../ingestion/IngestionBacklogStatus.java | 16 +++-- .../LocalDocsFileIngestionProcessor.java | 8 ++- .../SourceCodeFileIngestionProcessor.java | 23 ++++--- .../cli/GitHubRepoProcessorIsolationTest.java | 35 ++++++++--- .../ingestion/IngestionBacklogStatusTest.java | 10 +-- ...ocalDocsDirectoryIngestionServiceTest.java | 8 ++- .../LocalDocsFileIngestionProcessorTest.java | 58 +++++++++++++++++ .../SourceCodeFileIngestionProcessorTest.java | 63 ++++++++++++++++++- 10 files changed, 196 insertions(+), 41 deletions(-) diff --git a/config/spotbugs/spotbugs-exclude.xml b/config/spotbugs/spotbugs-exclude.xml index 2a23c9e1..fd34e59a 100644 --- a/config/spotbugs/spotbugs-exclude.xml +++ b/config/spotbugs/spotbugs-exclude.xml @@ -162,10 +162,6 @@ - - - - diff --git a/src/main/java/com/williamcallahan/javachat/cli/GitHubRepoProcessor.java b/src/main/java/com/williamcallahan/javachat/cli/GitHubRepoProcessor.java index b7ef1fed..9f1e3835 100644 --- a/src/main/java/com/williamcallahan/javachat/cli/GitHubRepoProcessor.java +++ b/src/main/java/com/williamcallahan/javachat/cli/GitHubRepoProcessor.java @@ -194,8 +194,10 @@ private IngestionWalkSummary walkAndProcess( case LocalDocsFileOutcome.Failed failed -> { failedCount++; failed.failure() - .ifPresent(failure -> - LOGGER.warn("File failed (phase={}): {}", failure.phase(), failure.filePath())); + .ifPresent(failure -> LOGGER.warn( + "File failed (phase={}): {}", + failure.phase(), + renderPathForLog(failure.filePath()))); } } } @@ -340,7 +342,7 @@ private void logStartBanner(GitHubRepoMetadata repoMetadata, Path repoRoot) { LOGGER.info("GitHub Repository Ingestion"); LOGGER.info(LOG_BANNER_LINE); LOGGER.info("Repository: {}", repoMetadata.repoName()); - LOGGER.info("Path: {}", repoRoot); + LOGGER.info("Path: {}", renderPathForLog(repoRoot.toString())); LOGGER.info("Collection: {}", repoMetadata.collectionName()); if (!repoMetadata.repoUrl().isBlank()) { LOGGER.info("URL: {}", repoMetadata.repoUrl()); @@ -358,6 +360,10 @@ private void logStartBanner(GitHubRepoMetadata repoMetadata, Path repoRoot) { LOGGER.info(""); } + private static String renderPathForLog(String sourcePath) { + return sourcePath.replace("\r", "\\r").replace("\n", "\\n"); + } + private void logSummary(IngestionTotals totals, String collectionName) { if (!LOGGER.isInfoEnabled()) { return; diff --git a/src/main/java/com/williamcallahan/javachat/domain/ingestion/IngestionBacklogStatus.java b/src/main/java/com/williamcallahan/javachat/domain/ingestion/IngestionBacklogStatus.java index ae614d2d..4c4c4e77 100644 --- a/src/main/java/com/williamcallahan/javachat/domain/ingestion/IngestionBacklogStatus.java +++ b/src/main/java/com/williamcallahan/javachat/domain/ingestion/IngestionBacklogStatus.java @@ -149,8 +149,9 @@ public IngestionBacklogStatus finish() { /** * Reclassifies work left in a running checkpoint after its process released ownership. * - *

      Only terminal successes remain inspected. Failed and interrupted files return to pending - * so monitoring and the next run describe retryable work truthfully.

      + *

      A recorded failure restarts the full marker-backed inventory because aggregate counts + * cannot identify a non-prefix failed file. An interruption without a recorded failure retains + * the terminal-success prefix and returns only interrupted files to pending.

      * * @return partial backlog with no files reported as actively owned */ @@ -158,6 +159,10 @@ public IngestionBacklogStatus abandon() { if (lifecycle == Lifecycle.COMPLETE || lifecycle == Lifecycle.NOT_STARTED) { return this; } + if (failedFiles > 0) { + return new IngestionBacklogStatus( + Lifecycle.PARTIAL, eligibleFiles, 0, 0, 0, 0, eligibleFiles, 0, directory); + } int terminalSuccessCount = processedFiles + skippedFiles; return new IngestionBacklogStatus( Lifecycle.PARTIAL, @@ -172,10 +177,11 @@ public IngestionBacklogStatus abandon() { } /** - * Reopens unfinished work while retaining terminal success counts from the prior attempt. + * Reopens unfinished work from the first position the durable state can identify safely. * - *

      Failed and in-progress files return to pending so the next run retries the first unfinished - * position. Completed runs intentionally restart from inventory to detect changed file content.

      + *

      Failed attempts restart the full marker-backed inventory; interruption-only attempts retain + * their terminal-success prefix. Completed runs intentionally restart from inventory to detect + * changed file content.

      */ public IngestionBacklogStatus resume() { if (lifecycle == Lifecycle.COMPLETE || lifecycle == Lifecycle.NOT_STARTED) { diff --git a/src/main/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessor.java b/src/main/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessor.java index 71ac8702..8f4c9427 100644 --- a/src/main/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessor.java +++ b/src/main/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessor.java @@ -105,7 +105,8 @@ public LocalDocsFileOutcome process(Path root, Path file) { * * @param root root ingestion directory * @param files ordered files to process - * @return ordered outcomes through the first failure + * @return ordered outcomes through the first failed batch; every file already stored by a + * combined upsert completes its marker transition before later work stops */ public List processBatch(Path root, List files) { return processBatch(root, files, DocsSourceRegistry.resolveMirroredIngestionIdentities(root, files)); @@ -591,14 +592,15 @@ private boolean flushNewFileBatch( return false; } + boolean batchCompletionFailed = false; for (DocumentProcessingRequest processingRequest : preparedFiles) { LocalDocsFileOutcome outcome = completeDocumentsAfterStorage(processingRequest); outcomes.add(outcome); if (outcome.failure().isPresent()) { - return false; + batchCompletionFailed = true; } } - return true; + return !batchCompletionFailed; } private LocalDocsFileOutcome processDocuments(DocumentProcessingRequest processingRequest) { diff --git a/src/main/java/com/williamcallahan/javachat/service/ingestion/SourceCodeFileIngestionProcessor.java b/src/main/java/com/williamcallahan/javachat/service/ingestion/SourceCodeFileIngestionProcessor.java index 2944d541..7437288d 100644 --- a/src/main/java/com/williamcallahan/javachat/service/ingestion/SourceCodeFileIngestionProcessor.java +++ b/src/main/java/com/williamcallahan/javachat/service/ingestion/SourceCodeFileIngestionProcessor.java @@ -152,13 +152,13 @@ public SourceFileProcessingResult process(RepositoryIngestionContext repositoryC boolean hasExactPointCoverage = unchangedByFingerprint && hasExactStoredPointCoverage(previousFileRecord, canonicalCollectionName, fileContext); if (hasExactPointCoverage) { - log.debug("Skipping unchanged file (already ingested): {}", fileContext.relativePath()); + log.debug("Skipping unchanged file (already ingested): {}", renderPathForLog(fileContext.relativePath())); return new SourceFileProcessingResult(LocalDocsFileOutcome.skippedFile(), fileUrl); } if (unchangedByFingerprint) { log.info( "File marker exists but stored point identities differ; forcing reindex: {}", - fileContext.relativePath()); + renderPathForLog(fileContext.relativePath())); } boolean requiresFullReindex = @@ -187,7 +187,10 @@ private Optional validateFileAttributes(Path sourceFilePat long fileSizeBytes = Files.size(sourceFilePath); if (fileSizeBytes > MAX_FILE_SIZE_BYTES) { log.debug( - "Skipping oversized file ({}B > {}B): {}", fileSizeBytes, MAX_FILE_SIZE_BYTES, sourceFilePath); + "Skipping oversized file ({}B > {}B): {}", + fileSizeBytes, + MAX_FILE_SIZE_BYTES, + renderPathForLog(sourceFilePath.toString())); return Optional.of(LocalDocsFileOutcome.skippedFile()); } } catch (IOException fileAttributeException) { @@ -230,7 +233,7 @@ private Optional readAndFingerprintFile( try { rawFileText = Files.readString(sourceFilePath, StandardCharsets.UTF_8); } catch (MalformedInputException malformedInputException) { - log.debug("Skipping binary file (UTF-8 decode failed): {}", fileContext.relativePath()); + log.debug("Skipping binary file (UTF-8 decode failed): {}", renderPathForLog(fileContext.relativePath())); return Optional.empty(); } catch (IOException readException) { throw new IllegalStateException("Failed reading file after validation: " + sourceFilePath, readException); @@ -238,7 +241,7 @@ private Optional readAndFingerprintFile( String fileText = sanitizeControlCharacters(rawFileText, fileContext.relativePath()); if (fileText.isBlank()) { - log.debug("Skipping empty file: {}", fileContext.relativePath()); + log.debug("Skipping empty file: {}", renderPathForLog(fileContext.relativePath())); return Optional.empty(); } @@ -266,7 +269,7 @@ private static String sanitizeControlCharacters(String rawText, String relativeP log.debug( "Removed {} unsupported control character(s) before embedding: {}", removedControlCharacterCount, - relativePath); + renderPathForLog(relativePath)); } return sanitizedTextBuilder.toString(); } @@ -366,7 +369,7 @@ private LocalDocsFileOutcome resolveEmptyChunkOutcome( String ingestionFingerprint, String collectionName) { if (chunkingOutcome.skippedAllChunks()) { - log.debug("All chunks already ingested: {}", fileContext.relativePath()); + log.debug("All chunks already ingested: {}", renderPathForLog(fileContext.relativePath())); markFileIngested(fileContext, ingestionFingerprint, collectionName, chunkingOutcome.allChunkHashes()); return LocalDocsFileOutcome.skippedFile(); } @@ -384,11 +387,15 @@ private void logProcessingComplete( "Processed {}/{} chunks for {} in {}ms ({})", indexedDocumentCount, totalChunkCount, - relativePath, + renderPathForLog(relativePath), totalDuration, progressTracker.formatPercent()); } + private static String renderPathForLog(String sourcePath) { + return sourcePath.replace("\r", "\\r").replace("\n", "\\n"); + } + private static String buildFileUrl(GitHubRepoMetadata repositoryMetadata, String relativePath) { String revision = repositoryMetadata.repoBranch(); if (revision.isBlank()) { diff --git a/src/test/java/com/williamcallahan/javachat/cli/GitHubRepoProcessorIsolationTest.java b/src/test/java/com/williamcallahan/javachat/cli/GitHubRepoProcessorIsolationTest.java index f9ac2449..148dee67 100644 --- a/src/test/java/com/williamcallahan/javachat/cli/GitHubRepoProcessorIsolationTest.java +++ b/src/test/java/com/williamcallahan/javachat/cli/GitHubRepoProcessorIsolationTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; @@ -13,6 +14,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import ch.qos.logback.classic.Logger; import com.williamcallahan.javachat.domain.ingestion.GitHubRepoMetadata; import com.williamcallahan.javachat.domain.ingestion.GitHubRepositoryIdentity; import com.williamcallahan.javachat.domain.ingestion.IngestionLocalFailure; @@ -23,6 +25,7 @@ import com.williamcallahan.javachat.service.ingestion.IngestedFilePruneService; import com.williamcallahan.javachat.service.ingestion.LocalDocsFileOutcome; import com.williamcallahan.javachat.service.ingestion.SourceCodeFileIngestionProcessor; +import com.williamcallahan.javachat.support.logging.ExpectedLogEvents; import io.qdrant.client.grpc.Common.Filter; import java.io.IOException; import java.nio.file.Files; @@ -31,6 +34,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; +import org.slf4j.LoggerFactory; /** Verifies that direct GitHub CLI execution cannot cross embedding-generation boundaries. */ class GitHubRepoProcessorIsolationTest { @@ -39,6 +43,8 @@ class GitHubRepoProcessorIsolationTest { private static final String REPOSITORY_BRANCH = "main"; private static final String REPOSITORY_COMMIT = "0123456789abcdef0123456789abcdef01234567"; private static final int ELIGIBLE_FILE_COUNT = 50; + private static final Logger GITHUB_REPO_PROCESSOR_LOGGER = + (Logger) LoggerFactory.getLogger(GitHubRepoProcessor.class); @Test void acceptsSharedGenerationCollection() { @@ -112,10 +118,14 @@ void snapshotFailureStopsBeforeRepositoryTraversal(@TempDir Path repositoryRoot) } @Test - void fileFailurePreventsOrphanPruningAndMetadataRefresh(@TempDir Path repositoryRoot) throws IOException { + void fileFailurePreventsOrphanPruningAndMetadataRefresh(@TempDir Path temporaryDirectory) throws IOException { + Path repositoryRoot = temporaryDirectory.resolve("repository\r\nINJECTED"); + Files.createDirectories(repositoryRoot); Path sourceFilePath = repositoryRoot.resolve("Source.java"); Files.writeString(sourceFilePath, "class Source {}"); ProcessorFixture processorFixture = processorFixture(); + IngestionLocalFailure hostileFailure = + new IngestionLocalFailure(sourceFilePath.toString(), "chunking", "malformed source"); when(processorFixture.hybridVectorService().scrollAllUrlsInCollection(COLLECTION_NAME)) .thenReturn(Set.of("https://github.com/openai/java-chat/blob/main/Deleted.java")); when(processorFixture @@ -124,13 +134,22 @@ void fileFailurePreventsOrphanPruningAndMetadataRefresh(@TempDir Path repository any(SourceCodeFileIngestionProcessor.RepositoryIngestionContext.class), any(Path.class))) .thenReturn(new SourceFileProcessingResult( - LocalDocsFileOutcome.failedFile( - new IngestionLocalFailure(sourceFilePath.toString(), "chunking", "malformed source")), - ACTIVE_SOURCE_URL)); - - assertThrows( - GitHubRepoProcessor.GitHubRepoProcessingException.class, - () -> processorFixture.processor().processRepository(repositoryMetadata(repositoryRoot))); + LocalDocsFileOutcome.failedFile(hostileFailure), ACTIVE_SOURCE_URL)); + + try (ExpectedLogEvents repositoryLogs = ExpectedLogEvents.capture(GITHUB_REPO_PROCESSOR_LOGGER)) { + assertThrows( + GitHubRepoProcessor.GitHubRepoProcessingException.class, + () -> processorFixture.processor().processRepository(repositoryMetadata(repositoryRoot))); + + assertTrue(repositoryLogs.events().stream() + .map(loggingEvent -> loggingEvent.getFormattedMessage()) + .allMatch( + formattedMessage -> !formattedMessage.contains("\r") && !formattedMessage.contains("\n"))); + assertTrue(repositoryLogs.events().stream() + .map(loggingEvent -> loggingEvent.getFormattedMessage()) + .anyMatch(formattedMessage -> formattedMessage.contains("repository\\r\\nINJECTED"))); + } + assertTrue(hostileFailure.filePath().contains("\r\n")); verify(processorFixture.hybridVectorService()).scrollAllUrlsInCollection(COLLECTION_NAME); verifyNoMoreInteractions(processorFixture.hybridVectorService()); diff --git a/src/test/java/com/williamcallahan/javachat/domain/ingestion/IngestionBacklogStatusTest.java b/src/test/java/com/williamcallahan/javachat/domain/ingestion/IngestionBacklogStatusTest.java index 8d9c6dfc..884f0c1d 100644 --- a/src/test/java/com/williamcallahan/javachat/domain/ingestion/IngestionBacklogStatusTest.java +++ b/src/test/java/com/williamcallahan/javachat/domain/ingestion/IngestionBacklogStatusTest.java @@ -8,7 +8,7 @@ class IngestionBacklogStatusTest { @Test - void resumeReturnsFailedAndInterruptedFilesToPending() { + void resumeRestartsTheMarkerBackedInventoryAfterFailure() { IngestionBacklogStatus partialBacklog = IngestionBacklogStatus.running("java-21", 4) .startBatch(3) .completeBatch(1, 1, 1) @@ -18,11 +18,11 @@ void resumeReturnsFailedAndInterruptedFilesToPending() { assertEquals(IngestionBacklogStatus.Lifecycle.RUNNING, resumedBacklog.lifecycle()); assertEquals(4, resumedBacklog.eligibleFiles()); - assertEquals(2, resumedBacklog.inspectedFiles()); - assertEquals(1, resumedBacklog.processedFiles()); - assertEquals(1, resumedBacklog.skippedFiles()); + assertEquals(0, resumedBacklog.inspectedFiles()); + assertEquals(0, resumedBacklog.processedFiles()); + assertEquals(0, resumedBacklog.skippedFiles()); assertEquals(0, resumedBacklog.failedFiles()); - assertEquals(2, resumedBacklog.pendingFiles()); + assertEquals(4, resumedBacklog.pendingFiles()); assertEquals(0, resumedBacklog.inProgressFiles()); } } diff --git a/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsDirectoryIngestionServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsDirectoryIngestionServiceTest.java index bfa734a4..5e4733af 100644 --- a/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsDirectoryIngestionServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsDirectoryIngestionServiceTest.java @@ -171,9 +171,11 @@ void retriesFailedPrefixBeforeLaterFilesAcrossRuns(@TempDir Path temporaryDirect org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.anyMap())) .thenReturn( - List.of(LocalDocsFileOutcome.failedFile( - new com.williamcallahan.javachat.domain.ingestion.IngestionLocalFailure( - firstDocumentationFile.toString(), "html-read", "malformed input"))), + List.of( + LocalDocsFileOutcome.failedFile( + new com.williamcallahan.javachat.domain.ingestion.IngestionLocalFailure( + firstDocumentationFile.toString(), "html-read", "malformed input")), + LocalDocsFileOutcome.processedFile()), List.of(LocalDocsFileOutcome.processedFile())); LocalDocsDirectoryIngestionService directoryIngestionService = new LocalDocsDirectoryIngestionService( fileProcessor, ingestionRunStore, configuredDocumentationRoot.toString()); diff --git a/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessorTest.java b/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessorTest.java index 151b6aa9..92d20f38 100644 --- a/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessorTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/ingestion/LocalDocsFileIngestionProcessorTest.java @@ -59,6 +59,8 @@ class LocalDocsFileIngestionProcessorTest { private static final int DOCUMENT_COUNT_SPANNING_TWO_EMBEDDING_BATCHES = LocalDocsFileIngestionProcessor.MAX_EMBEDDING_BATCH_DOCUMENTS + 1; private static final int EXPECTED_EMBEDDING_BATCH_COUNT = 2; + private static final int HALF_EMBEDDING_BATCH_DOCUMENTS = + LocalDocsFileIngestionProcessor.MAX_EMBEDDING_BATCH_DOCUMENTS / 2; private static final String JAVA_API_CLASS_NAME = "StringBuilder"; private static final String JAVA_API_METHOD_SIGNATURE = "append(String text)"; @@ -247,6 +249,62 @@ void shouldWriteNoMarkersWhenCombinedEmbeddingFails(@TempDir Path temporaryDirec verify(ingestionFixture.localStoreService, never()).markHashIngested(anyString(), anyString(), anyString()); } + @Test + void shouldCompleteStoredBatchMarkersBeforeStoppingLaterWork(@TempDir Path temporaryDirectory) throws IOException { + DocumentationSource documentationSource = + DocsSourceRegistry.documentationSources().getFirst(); + Path selectedDocumentationRoot = + temporaryDirectory.resolve("corpus").resolve(documentationSource.relativeMirrorPath()); + Files.createDirectories(selectedDocumentationRoot); + Path firstDocumentationFile = selectedDocumentationRoot.resolve("first.html"); + Path secondDocumentationFile = selectedDocumentationRoot.resolve("second.html"); + Path laterDocumentationFile = selectedDocumentationRoot.resolve("later.html"); + for (Path documentationFile : + List.of(firstDocumentationFile, secondDocumentationFile, laterDocumentationFile)) { + Files.writeString(documentationFile, javaApiHtml(), StandardCharsets.UTF_8); + } + String firstDocumentationUrl = DocsSourceRegistry.resolveMirroredPath( + selectedDocumentationRoot, firstDocumentationFile) + .orElseThrow(); + String laterDocumentationUrl = DocsSourceRegistry.resolveMirroredPath( + selectedDocumentationRoot, laterDocumentationFile) + .orElseThrow(); + LocalDocsIngestionFixture ingestionFixture = new LocalDocsIngestionFixture(); + when(ingestionFixture.hybridVectorService.resolveCollectionName(any())).thenReturn("documentation"); + when(ingestionFixture.chunkProcessingService.processAndStoreChunks( + anyString(), anyString(), anyString(), anyString())) + .thenAnswer(invocation -> { + String sourceUrl = invocation.getArgument(1, String.class); + int documentCount = sourceUrl.equals(laterDocumentationUrl) ? 1 : HALF_EMBEDDING_BATCH_DOCUMENTS; + List indexedDocuments = new ArrayList<>(documentCount); + List chunkHashes = new ArrayList<>(documentCount); + for (int documentIndex = 0; documentIndex < documentCount; documentIndex++) { + indexedDocuments.add(new Document( + sourceUrl + "#" + documentIndex, + "Documentation body " + documentIndex, + new HashMap<>())); + chunkHashes.add(sourceUrl + "-hash-" + documentIndex); + } + return new ChunkProcessingService.ChunkProcessingOutcome( + indexedDocuments, chunkHashes, documentCount, 0); + }); + doThrow(new IllegalStateException("marker write failed")) + .when(ingestionFixture.fileIngestionMarkerStore) + .markFileIngested(eq(firstDocumentationUrl), any(FileIngestionRecord.class)); + + List outcomes = ingestionFixture + .ingestionProcessor() + .processBatch( + selectedDocumentationRoot, + List.of(firstDocumentationFile, secondDocumentationFile, laterDocumentationFile)); + + assertEquals(2, outcomes.size()); + assertEquals( + "marker-transition", outcomes.getFirst().failure().orElseThrow().phase()); + assertTrue(outcomes.getLast().processed()); + assertTrue(outcomes.getLast().failure().isEmpty()); + } + @Test void shouldNotRunLaterExcludedPageTransitionWhenEarlierEmbeddingFails(@TempDir Path temporaryDirectory) throws IOException { diff --git a/src/test/java/com/williamcallahan/javachat/service/ingestion/SourceCodeFileIngestionProcessorTest.java b/src/test/java/com/williamcallahan/javachat/service/ingestion/SourceCodeFileIngestionProcessorTest.java index 94820c8b..abef2930 100644 --- a/src/test/java/com/williamcallahan/javachat/service/ingestion/SourceCodeFileIngestionProcessorTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/ingestion/SourceCodeFileIngestionProcessorTest.java @@ -12,6 +12,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import ch.qos.logback.classic.Logger; import com.williamcallahan.javachat.domain.ingestion.GitHubRepoMetadata; import com.williamcallahan.javachat.domain.ingestion.GitHubRepositoryIdentity; import com.williamcallahan.javachat.domain.ingestion.SourceFileProcessingResult; @@ -24,7 +25,9 @@ import com.williamcallahan.javachat.service.ProgressTracker; import com.williamcallahan.javachat.service.QdrantCollectionRouter; import com.williamcallahan.javachat.service.QdrantPayloadFieldSchema; +import com.williamcallahan.javachat.support.logging.ExpectedLogEvents; import java.io.IOException; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -38,6 +41,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.Mockito; +import org.slf4j.LoggerFactory; import org.springframework.ai.document.Document; /** @@ -46,6 +50,8 @@ class SourceCodeFileIngestionProcessorTest { private static final String PRIOR_COLLECTION_NAME = "prior-collection"; private static final String TARGET_COLLECTION_NAME = "target-collection"; + private static final Logger SOURCE_INGESTION_LOGGER = + (Logger) LoggerFactory.getLogger(SourceCodeFileIngestionProcessor.class); @Test void missingMarkerWithExistingVectorsReplacesUrlDocuments(@TempDir Path temporaryDirectory) throws IOException { @@ -100,6 +106,52 @@ void missingMarkerWithoutExistingVectorsUsesNormalUpsert(@TempDir Path temporary verify(ingestionScenario.hybridVectorService(), never()).countPointsForUrl(anyString(), anyString()); } + @Test + void repositoryPathLogsEscapeLineBreaksWithoutChangingIdentity(@TempDir Path temporaryDirectory) + throws IOException { + SourceIngestionScenario ingestionScenario = + sourceIngestionScenario(temporaryDirectory, "Main\r\nINJECTED.java"); + Document indexedDocument = new Document("point-1", "package demo; class Main {}", new HashMap<>()); + indexedDocument.getMetadata().put(QdrantPayloadFieldSchema.HASH_FIELD, "newhash"); + when(ingestionScenario + .chunkProcessingService() + .processAndStoreChunks( + anyString(), + eq(ingestionScenario.sourceUrl()), + eq("Main\r\nINJECTED.java"), + anyString())) + .thenReturn(new ChunkProcessingService.ChunkProcessingOutcome( + List.of(indexedDocument), List.of("newhash"), 1, 0)); + + SourceFileProcessingResult sourceFileProcessing; + try (ExpectedLogEvents ingestionLogs = ExpectedLogEvents.capture(SOURCE_INGESTION_LOGGER)) { + sourceFileProcessing = ingestionScenario + .ingestionProcessor() + .process(ingestionScenario.repositoryContext(Set.of()), ingestionScenario.sourceFilePath()); + + assertTrue(ingestionLogs.events().stream() + .map(loggingEvent -> loggingEvent.getFormattedMessage()) + .allMatch( + formattedMessage -> !formattedMessage.contains("\r") && !formattedMessage.contains("\n"))); + assertTrue(ingestionLogs.events().stream() + .map(loggingEvent -> loggingEvent.getFormattedMessage()) + .anyMatch(formattedMessage -> formattedMessage.contains("Main\\r\\nINJECTED.java"))); + } + + assertTrue(sourceFileProcessing.outcome().processed()); + assertTrue(sourceFileProcessing.fileUrl().contains("Main%0D%0AINJECTED.java")); + ArgumentCaptor> storedDocumentsCaptor = ArgumentCaptor.captor(); + verify(ingestionScenario.hybridVectorService()) + .upsertToCollection(eq(TARGET_COLLECTION_NAME), storedDocumentsCaptor.capture()); + assertEquals( + "src/Main\r\nINJECTED.java", + storedDocumentsCaptor + .getValue() + .getFirst() + .getMetadata() + .get(QdrantPayloadFieldSchema.FILE_PATH_FIELD)); + } + @Test void changedFileReplacesBeforePruningLocalState(@TempDir Path temporaryDirectory) throws IOException { ChunkProcessingService chunkProcessingService = Mockito.mock(ChunkProcessingService.class); @@ -627,6 +679,11 @@ private static SourceCodeFileIngestionProcessor.RepositoryIngestionContext repos } private static SourceIngestionScenario sourceIngestionScenario(Path temporaryDirectory) throws IOException { + return sourceIngestionScenario(temporaryDirectory, "Main.java"); + } + + private static SourceIngestionScenario sourceIngestionScenario(Path temporaryDirectory, String sourceFileName) + throws IOException { ChunkProcessingService chunkProcessingService = Mockito.mock(ChunkProcessingService.class); HybridVectorService hybridVectorService = Mockito.mock(HybridVectorService.class); LocalStoreService localStoreService = Mockito.mock(LocalStoreService.class); @@ -645,7 +702,7 @@ private static SourceIngestionScenario sourceIngestionScenario(Path temporaryDir progressTracker, ingestedFilePruneService); Path repositoryRoot = temporaryDirectory.resolve("repository"); - Path sourceFilePath = repositoryRoot.resolve("src/Main.java"); + Path sourceFilePath = repositoryRoot.resolve("src").resolve(sourceFileName); Files.createDirectories(Objects.requireNonNull(sourceFilePath.getParent(), "sourceFilePath parent")); Files.writeString(sourceFilePath, "package demo; class Main {}", StandardCharsets.UTF_8); GitHubRepoMetadata repositoryMetadata = new GitHubRepoMetadata( @@ -656,7 +713,9 @@ private static SourceIngestionScenario sourceIngestionScenario(Path temporaryDir "abcdef123456", "MIT", "Example repository"); - String sourceUrl = "https://github.com/openai/java-chat/blob/main/src/Main.java"; + String encodedSourceFileName = + URLEncoder.encode(sourceFileName, StandardCharsets.UTF_8).replace("+", "%20"); + String sourceUrl = "https://github.com/openai/java-chat/blob/main/src/" + encodedSourceFileName; when(contentHasher.sha256(sourceFilePath)).thenReturn("new-fingerprint"); when(fileIngestionMarkerStore.readFileIngestionRecord(sourceUrl)).thenReturn(Optional.empty()); when(progressTracker.formatPercent()).thenReturn("100%"); From ac62ca719d2ceb7375090f32fba22dc8fc85a4b3 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 13:06:04 -0700 Subject: [PATCH 23/29] fix(retrieval): enforce strict relevance and proxy trust Remove non-strict retrieval outcome wrappers so dependency failures stay terminal and route client identity through Tomcat's trusted proxy boundary. - rerank singleton candidates, return direct documents, and verify forwarded-origin handling --- docs/retrieval-pipeline.md | 4 +- .../javachat/service/ChatService.java | 28 +- .../javachat/service/HybridSearchService.java | 79 +--- .../javachat/service/RerankerService.java | 2 +- .../javachat/service/RetrievalService.java | 318 +++++-------- .../javachat/web/ChatController.java | 27 +- src/main/resources/application.properties | 2 +- .../javachat/service/ChatServiceTest.java | 18 +- .../service/HybridSearchServiceTest.java | 118 ++--- .../javachat/service/RerankerServiceTest.java | 27 +- .../service/RetrievalServiceTest.java | 431 ++++++++---------- ...hatControllerRetrievalDiagnosticsTest.java | 2 +- .../ChatControllerStreamingFailureTest.java | 22 +- .../ChatPreparationSseIntegrationTest.java | 4 +- ...ContactForwardedHeaderIntegrationTest.java | 161 +++++++ 15 files changed, 589 insertions(+), 654 deletions(-) create mode 100644 src/test/java/com/williamcallahan/javachat/web/ContactForwardedHeaderIntegrationTest.java diff --git a/docs/retrieval-pipeline.md b/docs/retrieval-pipeline.md index e271870c..62c17108 100644 --- a/docs/retrieval-pipeline.md +++ b/docs/retrieval-pipeline.md @@ -11,7 +11,7 @@ For environment variable reference, see [configuration.md](configuration.md). ``` ChatController.stream() -> ChatService.buildStructuredPromptWithContextOutcome() - -> RetrievalService.retrieveOutcome() + -> RetrievalService.retrieve() |-- 1. QueryVersionExtractor -- version detection + query boosting |-- 2. HybridSearchService -- parallel dense+sparse search with RRF fusion |-- 3. dedupeByHashThenUrl() -- UUID -> SHA-256 hash -> URL dedup @@ -24,7 +24,7 @@ ChatController.stream() | Stage | File | Entry point | |---|---|---| | Version extraction | `util/QueryVersionExtractor.java` | `boostQueryWithVersionContext()` | -| Hybrid search | `service/HybridSearchService.java` | `searchOutcome()` | +| Hybrid search | `service/HybridSearchService.java` | `search()` | | Deduplication | `service/RetrievalService.java` | `dedupeByHashThenUrl()` | | LLM reranking | `service/RerankerService.java` | `rerank()` | | Prompt assembly | `application/prompt/PromptTruncator.java` | `truncate()` | diff --git a/src/main/java/com/williamcallahan/javachat/service/ChatService.java b/src/main/java/com/williamcallahan/javachat/service/ChatService.java index dc7460a1..30e28388 100644 --- a/src/main/java/com/williamcallahan/javachat/service/ChatService.java +++ b/src/main/java/com/williamcallahan/javachat/service/ChatService.java @@ -186,14 +186,13 @@ public StructuredPromptOutcome buildStructuredPromptWithContextOutcome( Consumer retrievalProgressListener, long stageDeadlineNanos) { - RetrievalService.RetrievalOutcome retrievalOutcome = retrieveTokenConstrainedOfficialDocumentation( + List contextDocs = retrieveTokenConstrainedOfficialDocumentation( latestUserMessage, retrievalProgressListener, stageDeadlineNanos); logger.debug( "Using GPT-5.4 retrieval context: {} documents with max {} tokens each", - retrievalOutcome.documents().size(), + contextDocs.size(), ModelConfiguration.RAG_TOKEN_LIMIT_CONSTRAINED); - List contextDocs = retrievalOutcome.documents(); String searchQualityNote = determineSearchQuality(contextDocs); // Build system prompt with search quality context @@ -218,16 +217,16 @@ public StructuredPromptOutcome buildStructuredPromptWithContextOutcome( StructuredPrompt structuredPrompt = new StructuredPrompt(systemSegment, contextSegments, conversationSegments, querySegment); - return new StructuredPromptOutcome(structuredPrompt, retrievalOutcome.notices(), retrievalOutcome.documents()); + return new StructuredPromptOutcome(structuredPrompt, contextDocs); } /** * Retrieves the official documentation context shared by constrained chat prompts and diagnostics. * * @param query learner query - * @return constrained official-document retrieval outcome + * @return constrained official documents */ - public RetrievalService.RetrievalOutcome retrieveTokenConstrainedOfficialDocumentation(String query) { + public List retrieveTokenConstrainedOfficialDocumentation(String query) { return retrieveTokenConstrainedOfficialDocumentation(query, notice -> {}); } @@ -236,9 +235,9 @@ public RetrievalService.RetrievalOutcome retrieveTokenConstrainedOfficialDocumen * * @param query learner query * @param retrievalProgressListener receives live user-facing retrieval progress notices - * @return constrained official-document retrieval outcome + * @return constrained official documents */ - public RetrievalService.RetrievalOutcome retrieveTokenConstrainedOfficialDocumentation( + public List retrieveTokenConstrainedOfficialDocumentation( String query, Consumer retrievalProgressListener) { return retrieveTokenConstrainedOfficialDocumentation( query, @@ -252,13 +251,13 @@ public RetrievalService.RetrievalOutcome retrieveTokenConstrainedOfficialDocumen * @param query learner query * @param retrievalProgressListener receives live user-facing retrieval progress notices * @param stageDeadlineNanos absolute {@link System#nanoTime()} response-preparation deadline - * @return constrained official-document retrieval outcome + * @return constrained official documents */ - public RetrievalService.RetrievalOutcome retrieveTokenConstrainedOfficialDocumentation( + public List retrieveTokenConstrainedOfficialDocumentation( String query, Consumer retrievalProgressListener, long stageDeadlineNanos) { - return retrievalService.retrieveWithLimitOutcome( + return retrievalService.retrieveWithLimit( query, ModelConfiguration.RAG_LIMIT_CONSTRAINED, ModelConfiguration.RAG_TOKEN_LIMIT_CONSTRAINED, @@ -391,18 +390,13 @@ private String determineSearchQuality(List docs) { * Captures a structured prompt with retrieval metadata for intelligent truncation. * * @param structuredPrompt the typed prompt segments - * @param notices retrieval notices for UI diagnostics * @param documents source documents for citation emission */ - public record StructuredPromptOutcome( - StructuredPrompt structuredPrompt, - List notices, - List documents) { + public record StructuredPromptOutcome(StructuredPrompt structuredPrompt, List documents) { public StructuredPromptOutcome { if (structuredPrompt == null) { throw new IllegalArgumentException("Structured prompt cannot be null"); } - notices = notices == null ? List.of() : List.copyOf(notices); documents = documents == null ? List.of() : List.copyOf(documents); } diff --git a/src/main/java/com/williamcallahan/javachat/service/HybridSearchService.java b/src/main/java/com/williamcallahan/javachat/service/HybridSearchService.java index 2ea20998..7d2838d7 100644 --- a/src/main/java/com/williamcallahan/javachat/service/HybridSearchService.java +++ b/src/main/java/com/williamcallahan/javachat/service/HybridSearchService.java @@ -63,46 +63,18 @@ public HybridSearchService( } /** - * Captures one retrieval notice generated during non-strict hybrid fan-out. - * - * @param summary concise summary for UI status events - * @param details detailed retrieval context for diagnostics - */ - public record HybridSearchNotice(String summary, String details) { - public HybridSearchNotice { - if (summary == null || summary.isBlank()) { - throw new IllegalArgumentException("summary cannot be null or blank"); - } - details = details == null ? "" : details; - } - } - - /** - * Captures hybrid search results and optional non-fatal notices. - * - * @param documents retrieved documents - * @param notices retrieval notices for non-strict partial failures - */ - public record SearchOutcome(List documents, List notices) { - public SearchOutcome { - documents = documents == null ? List.of() : List.copyOf(documents); - notices = notices == null ? List.of() : List.copyOf(notices); - } - } - - /** - * Performs hybrid search across all configured collections and returns retrieval notices. + * Performs hybrid search across all configured collections. * * @param query search query text * @param topK maximum number of results to return * @param retrievalConstraint retrieval metadata constraint for server-side filtering * @param stageDeadlineNanos absolute {@link System#nanoTime()} deadline shared by every * retrieval stage hop, so this search can never outlive the caller's stage budget - * @return search outcome containing documents and optional non-fatal notices + * @return ranked documents */ - public SearchOutcome searchOutcome( + public List search( String query, int topK, RetrievalConstraint retrievalConstraint, long stageDeadlineNanos) { - return searchOutcomes(query, topK, List.of(retrievalConstraint), stageDeadlineNanos) + return searchByConstraint(query, topK, List.of(retrievalConstraint), stageDeadlineNanos) .getFirst(); } @@ -120,15 +92,15 @@ public SearchOutcome searchOutcome( * @param retrievalConstraints server-side metadata scopes searched with the shared encoding * @param stageDeadlineNanos absolute {@link System#nanoTime()} deadline shared by every * retrieval stage hop - * @return search outcomes in the same order as the supplied scopes + * @return ranked document lists in the same order as the supplied scopes */ - public List searchOutcomes( + public List> searchByConstraint( String query, int topK, List retrievalConstraints, long stageDeadlineNanos) { Objects.requireNonNull(query, "query"); List requiredRetrievalConstraints = requireRetrievalConstraints(retrievalConstraints); if (query.isBlank() || topK <= 0) { return requiredRetrievalConstraints.stream() - .map(ignoredConstraint -> new SearchOutcome(List.of(), List.of())) + .map(ignoredConstraint -> List.of()) .toList(); } @@ -165,7 +137,7 @@ public List searchOutcomes( queryDispatches.add(new CollectionQueryDispatch( futuresByCollection, remainingQueryTimeout(queryDeadlineNanos), queryDeadlineNanos)); } - return collectSearchOutcomes(queryDispatches, topK); + return collectSearchResults(queryDispatches, topK); } /** @@ -181,11 +153,11 @@ public List searchOutcomes( * @param retrievalConstraint official documentation metadata constraint * @param stageDeadlineNanos absolute {@link System#nanoTime()} deadline shared by every * retrieval stage hop, so citation discovery can never outlive the caller's stage budget - * @return citation search outcome containing documents + * @return citation documents */ - public SearchOutcome searchDocumentationCitationsOutcome( + public List searchDocumentationCitations( String query, int topK, RetrievalConstraint retrievalConstraint, long stageDeadlineNanos) { - return searchDocumentationCitationsOutcomes(query, topK, List.of(retrievalConstraint), stageDeadlineNanos) + return searchDocumentationCitationsByConstraint(query, topK, List.of(retrievalConstraint), stageDeadlineNanos) .getFirst(); } @@ -197,15 +169,15 @@ public SearchOutcome searchDocumentationCitationsOutcome( * @param retrievalConstraints official-documentation scopes searched independently * @param stageDeadlineNanos absolute {@link System#nanoTime()} deadline shared by every * retrieval stage hop - * @return citation outcomes in the same order as the supplied scopes + * @return citation document lists in the same order as the supplied scopes */ - public List searchDocumentationCitationsOutcomes( + public List> searchDocumentationCitationsByConstraint( String query, int topK, List retrievalConstraints, long stageDeadlineNanos) { Objects.requireNonNull(query, "query"); List requiredRetrievalConstraints = requireRetrievalConstraints(retrievalConstraints); if (query.isBlank() || topK <= 0) { return requiredRetrievalConstraints.stream() - .map(ignoredConstraint -> new SearchOutcome(List.of(), List.of())) + .map(ignoredConstraint -> List.of()) .toList(); } @@ -231,7 +203,7 @@ public List searchDocumentationCitationsOutcomes( remainingQueryTimeout(queryDeadlineNanos), queryDeadlineNanos)); } - return collectSearchOutcomes(queryDispatches, topK); + return collectSearchResults(queryDispatches, topK); } String sparseCitationQuery = queryEncoding.sparseCitationQuery(query); @@ -239,7 +211,7 @@ public List searchDocumentationCitationsOutcomes( queryEncoding.sparseVectorEncoder().encode(sparseCitationQuery); if (sparseVector.indices().isEmpty()) { return requiredRetrievalConstraints.stream() - .map(ignoredConstraint -> new SearchOutcome(List.of(), List.of())) + .map(ignoredConstraint -> List.of()) .toList(); } @@ -272,7 +244,7 @@ public List searchDocumentationCitationsOutcomes( queryDispatches.add(new CollectionQueryDispatch( futuresByCollection, remainingQueryTimeout(queryDeadlineNanos), queryDeadlineNanos)); } - return collectSearchOutcomes(queryDispatches, topK); + return collectSearchResults(queryDispatches, topK); } /** @@ -294,10 +266,10 @@ private static Duration remainingStageBudget(long stageDeadlineNanos) { return Duration.ofNanos(Math.max(0L, stageDeadlineNanos - System.nanoTime())); } - private List collectSearchOutcomes(List queryDispatches, int topK) { + private List> collectSearchResults(List queryDispatches, int topK) { try { return queryDispatches.stream() - .map(queryDispatch -> collectSearchOutcome(queryDispatch, topK)) + .map(queryDispatch -> collectSearchResult(queryDispatch, topK)) .toList(); } catch (HybridSearchPartialFailureException searchFailure) { queryDispatches.forEach(HybridSearchService::cancelPendingQueries); @@ -316,7 +288,7 @@ private static List requireRetrievalConstraints( .toList(); } - private SearchOutcome collectSearchOutcome(CollectionQueryDispatch queryDispatch, int topK) { + private List collectSearchResult(CollectionQueryDispatch queryDispatch, int topK) { Map scoredPointsByUuid = new LinkedHashMap<>(); List collectionFailures = new ArrayList<>(); List dependencyFailures = new ArrayList<>(); @@ -338,9 +310,7 @@ private SearchOutcome collectSearchOutcome(CollectionQueryDispatch queryDispatch scoredPointMatch.score(), scoredPointMatch.collectionName())) .toList(); - List retrievalNotices = - collectionFailures.stream().map(HybridSearchService::toNotice).toList(); - return new SearchOutcome(rankedDocuments, retrievalNotices); + return rankedDocuments; } /** @@ -478,13 +448,6 @@ private static String extractPointId(ScoredPoint point) { return String.valueOf(point.getId().getNum()); } - private static HybridSearchNotice toNotice( - HybridSearchPartialFailureException.CollectionSearchFailure collectionSearchFailure) { - String summary = "Partial retrieval failure in collection " + collectionSearchFailure.collectionName(); - String details = collectionSearchFailure.failureType() + ": " + collectionSearchFailure.failureDetails(); - return new HybridSearchNotice(summary, details); - } - private static String sanitizeFailureDetails(String failureDetails) { if (failureDetails == null || failureDetails.isBlank()) { return ""; diff --git a/src/main/java/com/williamcallahan/javachat/service/RerankerService.java b/src/main/java/com/williamcallahan/javachat/service/RerankerService.java index 62b34770..be750870 100644 --- a/src/main/java/com/williamcallahan/javachat/service/RerankerService.java +++ b/src/main/java/com/williamcallahan/javachat/service/RerankerService.java @@ -123,7 +123,7 @@ public List rerank(String query, List documents, int returnK */ public List rerank(String query, List documents, int returnK, long stageDeadlineNanos) { requireRemainingStageBudget(stageDeadlineNanos); - if (documents.size() <= 1) { + if (documents.isEmpty()) { return documents; } diff --git a/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java b/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java index bd37a1e3..b7f7a751 100644 --- a/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java +++ b/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java @@ -106,26 +106,39 @@ public static record RetrievalNotice(String summary, String details) { } } - /** - * Outcome of a retrieval request, including documents and diagnostic notices. - * - *

      Notices are informational only; this service does not swallow dependency failures.

      - * - * @param documents retrieved documents - * @param notices diagnostic notices for UI consumption - */ - public static record RetrievalOutcome(List documents, List notices) { - public RetrievalOutcome { - documents = documents == null ? List.of() : List.copyOf(documents); - notices = notices == null ? List.of() : List.copyOf(notices); - } - } - /** * Retrieves documents for a query using hybrid retrieval and reranking. */ public List retrieve(String query) { - return retrieveOutcome(query).documents(); + if (query == null || query.isBlank()) { + return List.of(); + } + RetrievalConstraint officialDocumentationConstraint = + RetrievalConstraint.forOfficialDocSets(DocsSourceRegistry.officialDocumentationSourceIdentities()); + QueryVersionEvidence queryVersionEvidence = queryVersionEvidence(query, officialDocumentationConstraint); + if (!queryVersionEvidence.docSets().isEmpty()) { + List evidenceVersions = queryVersionEvidence.evidenceVersions(); + RetrievalConstraint retrievalConstraint = new RetrievalConstraint( + evidenceVersions, + "official", + queryVersionEvidence.docType(), + queryVersionEvidence.sourceName(), + queryVersionEvidence.docSets()); + return retrieveDocuments( + query, + retrievalConstraint, + queryVersionEvidence.requestedJavaVersions(), + queryVersionEvidence.evidenceConstraints(), + NO_PROGRESS_LISTENER, + retrievalStageDeadlineNanos()); + } + return retrieveDocuments( + query, + officialDocumentationConstraint, + queryVersionEvidence.requestedJavaVersions(), + List.of(), + NO_PROGRESS_LISTENER, + retrievalStageDeadlineNanos()); } /** @@ -136,7 +149,7 @@ public List retrieve(String query) { * @return retrieved and reranked documents */ public List retrieve(String query, RetrievalConstraint retrievalConstraint) { - return retrieveOutcome(query, retrievalConstraint).documents(); + return retrieve(query, retrievalConstraint, NO_PROGRESS_LISTENER); } /** @@ -170,8 +183,45 @@ public List retrieve( RetrievalConstraint retrievalConstraint, Consumer progressListener, long stageDeadlineNanos) { - return retrieveOutcome(query, retrievalConstraint, progressListener, stageDeadlineNanos) - .documents(); + Objects.requireNonNull(retrievalConstraint, "retrievalConstraint"); + Objects.requireNonNull(progressListener, "progressListener"); + if (query == null || query.isBlank()) { + return List.of(); + } + QueryVersionEvidence queryVersionEvidence = queryVersionEvidence(query, retrievalConstraint); + if (!queryVersionEvidence.docSets().isEmpty()) { + List evidenceVersions = queryVersionEvidence.evidenceVersions(); + if (evidenceVersions.isEmpty() && !retrievalConstraint.docVersions().isEmpty()) { + return List.of(); + } + RetrievalConstraint scopedConstraint = new RetrievalConstraint( + evidenceVersions, + retrievalConstraint.sourceKind(), + queryVersionEvidence.docType().isBlank() + ? retrievalConstraint.docType() + : queryVersionEvidence.docType(), + queryVersionEvidence.sourceName().isBlank() + ? retrievalConstraint.sourceName() + : queryVersionEvidence.sourceName(), + queryVersionEvidence.docSets()); + return retrieveDocuments( + query, + scopedConstraint, + queryVersionEvidence.requestedJavaVersions(), + queryVersionEvidence.evidenceConstraints(), + progressListener, + stageDeadlineNanos); + } + List requestedJavaVersions = queryVersionEvidence.requestedJavaVersions(); + RetrievalConstraint scopedRetrievalConstraint = + defaultJavaApiScope(query, retrievalConstraint, requestedJavaVersions); + return retrieveDocuments( + query, + scopedRetrievalConstraint, + requestedJavaVersions, + List.of(), + progressListener, + stageDeadlineNanos); } /** @@ -246,123 +296,6 @@ public CitationOutcome discoverCitations(String query, RetrievalConstraint retri return new CitationOutcome(limitedCitations, candidateCitationOutcome.failedConversionCount()); } - /** - * Retrieves documents and diagnostic notices for a query. - */ - public RetrievalOutcome retrieveOutcome(String query) { - if (query == null || query.isBlank()) { - return new RetrievalOutcome(List.of(), List.of()); - } - RetrievalConstraint officialDocumentationConstraint = - RetrievalConstraint.forOfficialDocSets(DocsSourceRegistry.officialDocumentationSourceIdentities()); - QueryVersionEvidence queryVersionEvidence = queryVersionEvidence(query, officialDocumentationConstraint); - if (!queryVersionEvidence.docSets().isEmpty()) { - List evidenceVersions = queryVersionEvidence.evidenceVersions(); - RetrievalConstraint retrievalConstraint = new RetrievalConstraint( - evidenceVersions, - "official", - queryVersionEvidence.docType(), - queryVersionEvidence.sourceName(), - queryVersionEvidence.docSets()); - return retrieveOutcome( - query, - retrievalConstraint, - queryVersionEvidence.requestedJavaVersions(), - queryVersionEvidence.evidenceConstraints(), - NO_PROGRESS_LISTENER, - retrievalStageDeadlineNanos()); - } - return retrieveOutcome( - query, - officialDocumentationConstraint, - queryVersionEvidence.requestedJavaVersions(), - List.of(), - NO_PROGRESS_LISTENER, - retrievalStageDeadlineNanos()); - } - - /** - * Retrieves documents and notices within the caller-owned metadata constraint. - * - *

      Feature-specific source scopes are retained while any query-derived Java version is added - * to the same server-side Qdrant filter.

      - * - * @param query retrieval query - * @param retrievalConstraint exact server-side constraint for the retrieval - * @return retrieval outcome with documents and notices - */ - public RetrievalOutcome retrieveOutcome(String query, RetrievalConstraint retrievalConstraint) { - return retrieveOutcome(query, retrievalConstraint, NO_PROGRESS_LISTENER); - } - - /** - * Retrieves documents and notices within the caller-owned constraint, reporting live progress. - * - * @param query retrieval query - * @param retrievalConstraint exact server-side constraint for the retrieval - * @param progressListener receives live user-facing retrieval progress notices - * @return retrieval outcome with documents and notices - */ - public RetrievalOutcome retrieveOutcome( - String query, RetrievalConstraint retrievalConstraint, Consumer progressListener) { - return retrieveOutcome(query, retrievalConstraint, progressListener, retrievalStageDeadlineNanos()); - } - - /** - * Retrieves documents and notices within one caller-owned response-preparation deadline. - * - * @param query retrieval query - * @param retrievalConstraint exact server-side constraint for the retrieval - * @param progressListener receives live user-facing retrieval progress notices - * @param stageDeadlineNanos absolute {@link System#nanoTime()} response-preparation deadline - * @return retrieval outcome with documents and notices - */ - public RetrievalOutcome retrieveOutcome( - String query, - RetrievalConstraint retrievalConstraint, - Consumer progressListener, - long stageDeadlineNanos) { - Objects.requireNonNull(retrievalConstraint, "retrievalConstraint"); - Objects.requireNonNull(progressListener, "progressListener"); - if (query == null || query.isBlank()) { - return new RetrievalOutcome(List.of(), List.of()); - } - QueryVersionEvidence queryVersionEvidence = queryVersionEvidence(query, retrievalConstraint); - if (!queryVersionEvidence.docSets().isEmpty()) { - List evidenceVersions = queryVersionEvidence.evidenceVersions(); - if (evidenceVersions.isEmpty() && !retrievalConstraint.docVersions().isEmpty()) { - return new RetrievalOutcome(List.of(), List.of()); - } - RetrievalConstraint scopedConstraint = new RetrievalConstraint( - evidenceVersions, - retrievalConstraint.sourceKind(), - queryVersionEvidence.docType().isBlank() - ? retrievalConstraint.docType() - : queryVersionEvidence.docType(), - queryVersionEvidence.sourceName().isBlank() - ? retrievalConstraint.sourceName() - : queryVersionEvidence.sourceName(), - queryVersionEvidence.docSets()); - return retrieveOutcome( - query, - scopedConstraint, - queryVersionEvidence.requestedJavaVersions(), - queryVersionEvidence.evidenceConstraints(), - progressListener, - stageDeadlineNanos); - } - List requestedJavaVersions = queryVersionEvidence.requestedJavaVersions(); - RetrievalConstraint scopedRetrievalConstraint = - defaultJavaApiScope(query, retrievalConstraint, requestedJavaVersions); - return retrieveOutcome( - query, - scopedRetrievalConstraint, - requestedJavaVersions, - List.of(), - progressListener, - stageDeadlineNanos); - } - /** * Runs the retrieval stage under one deadline shared by every dependency hop. * @@ -372,7 +305,7 @@ public RetrievalOutcome retrieveOutcome( * Each hop applies the tighter of its remaining stage time and its own configured cap, so the * hop budgets can never sum past the outer preparation deadline.

      */ - private RetrievalOutcome retrieveOutcome( + private List retrieveDocuments( String query, RetrievalConstraint retrievalConstraint, List requestedVersions, @@ -380,14 +313,14 @@ private RetrievalOutcome retrieveOutcome( Consumer progressListener, long stageDeadlineNanos) { progressListener.accept(new RetrievalNotice(RETRIEVAL_SEARCH_STATUS_SUMMARY, RETRIEVAL_SEARCH_STATUS_DETAILS)); - CandidateRetrieval candidateRetrieval = retrieveCandidates( + List candidateDocuments = retrieveCandidates( query, retrievalConstraint, requestedVersions, evidenceConstraints, stageDeadlineNanos); int returnDocumentLimit = Math.max(appProperties.getRag().getSearchReturnK(), evidenceConstraints.size()); List promptDocuments; if (requiresJavaMemberEvidence(query, retrievalConstraint)) { List javaMemberDocuments = - CitationCandidateRanker.selectPromptContextForCitationQuery(query, candidateRetrieval.documents()); + CitationCandidateRanker.selectPromptContextForCitationQuery(query, candidateDocuments); promptDocuments = evidenceConstraints.isEmpty() ? javaMemberDocuments.stream().limit(returnDocumentLimit).toList() : retainEvidenceSourceCoverage( @@ -395,11 +328,11 @@ private RetrievalOutcome retrieveOutcome( } else { progressListener.accept( new RetrievalNotice(RETRIEVAL_RERANK_STATUS_SUMMARY, RETRIEVAL_RERANK_STATUS_DETAILS)); - List reranked = rerankerService.rerank( - query, candidateRetrieval.documents(), returnDocumentLimit, stageDeadlineNanos); + List reranked = + rerankerService.rerank(query, candidateDocuments, returnDocumentLimit, stageDeadlineNanos); requireRemainingStageBudget(stageDeadlineNanos); promptDocuments = retainEvidenceSourceCoverage( - reranked, candidateRetrieval.documents(), evidenceConstraints, returnDocumentLimit); + reranked, candidateDocuments, evidenceConstraints, returnDocumentLimit); } if (!promptDocuments.isEmpty()) { @@ -411,10 +344,10 @@ private RetrievalOutcome retrieveOutcome( log.debug("First doc metadata size: {}", metadataSize); log.debug("First doc content preview length: {}", previewLength); } - return new RetrievalOutcome(promptDocuments, candidateRetrieval.notices()); + return promptDocuments; } - private CandidateRetrieval retrieveCandidates( + private List retrieveCandidates( String query, RetrievalConstraint retrievalConstraint, List requestedVersions, @@ -423,28 +356,24 @@ private CandidateRetrieval retrieveCandidates( String boostedQuery = QueryVersionExtractor.boostQueryWithVersionContext(query, requestedVersions); int baseTopK = Math.max(1, appProperties.getRag().getSearchTopK()); List retrievedDocuments = new ArrayList<>(); - List retrievalNotices = new ArrayList<>(); if (requiresJavaMemberEvidence(query, retrievalConstraint)) { List citationCandidates = searchCitationCandidates( query, baseTopK, retrievalConstraint, evidenceConstraints, stageDeadlineNanos); List javaMemberDocuments = CitationCandidateRanker.selectPromptContextForCitationQuery( query, CitationCandidateRanker.orderForCitationQuery(query, citationCandidates)); - return new CandidateRetrieval(deduplicateBySourceAndContentIdentity(javaMemberDocuments), List.of()); + return deduplicateBySourceAndContentIdentity(javaMemberDocuments); } if (evidenceConstraints.isEmpty()) { - appendSearchOutcome( - hybridSearchService.searchOutcome(boostedQuery, baseTopK, retrievalConstraint, stageDeadlineNanos), - retrievedDocuments, - retrievalNotices); + retrievedDocuments.addAll( + hybridSearchService.search(boostedQuery, baseTopK, retrievalConstraint, stageDeadlineNanos)); } else { - List evidenceSearchOutcomes = - hybridSearchService.searchOutcomes(boostedQuery, baseTopK, evidenceConstraints, stageDeadlineNanos); - for (HybridSearchService.SearchOutcome evidenceSearchOutcome : evidenceSearchOutcomes) { - appendSearchOutcome(evidenceSearchOutcome, retrievedDocuments, retrievalNotices); + List> evidenceSearchDocuments = hybridSearchService.searchByConstraint( + boostedQuery, baseTopK, evidenceConstraints, stageDeadlineNanos); + for (List evidenceDocuments : evidenceSearchDocuments) { + retrievedDocuments.addAll(evidenceDocuments); } } - List deduplicatedCandidates = deduplicateBySourceAndContentIdentity(retrievedDocuments); - return new CandidateRetrieval(deduplicatedCandidates, retrievalNotices); + return deduplicateBySourceAndContentIdentity(retrievedDocuments); } private static boolean requiresExactJavaOverloadEvidence(String query, RetrievalConstraint retrievalConstraint) { @@ -491,10 +420,10 @@ private RetrievalConstraint defaultJavaApiScope( /** * Retrieve documents with custom limits for token-constrained models. */ - public RetrievalOutcome retrieveWithLimitOutcome(String query, int maxDocuments, int maxTokensPerDocument) { + public List retrieveWithLimit(String query, int maxDocuments, int maxTokensPerDocument) { QueryVersionEvidence queryVersionEvidence = queryVersionEvidence(query, RetrievalConstraint.none()); - return limitRetrievalOutcome( - retrieveOutcome(query), maxDocuments, maxTokensPerDocument, queryVersionEvidence.evidenceConstraints()); + return limitDocuments( + retrieve(query), maxDocuments, maxTokensPerDocument, queryVersionEvidence.evidenceConstraints()); } /** @@ -504,12 +433,11 @@ public RetrievalOutcome retrieveWithLimitOutcome(String query, int maxDocuments, * @param maxDocuments baseline maximum before required version evidence expands coverage * @param maxTokensPerDocument maximum estimated tokens retained per document * @param retrievalConstraint exact server-side constraint for the retrieval - * @return constrained, truncated retrieval outcome + * @return constrained, truncated documents */ - public RetrievalOutcome retrieveWithLimitOutcome( + public List retrieveWithLimit( String query, int maxDocuments, int maxTokensPerDocument, RetrievalConstraint retrievalConstraint) { - return retrieveWithLimitOutcome( - query, maxDocuments, maxTokensPerDocument, retrievalConstraint, NO_PROGRESS_LISTENER); + return retrieveWithLimit(query, maxDocuments, maxTokensPerDocument, retrievalConstraint, NO_PROGRESS_LISTENER); } /** @@ -520,15 +448,15 @@ public RetrievalOutcome retrieveWithLimitOutcome( * @param maxTokensPerDocument maximum estimated tokens retained per document * @param retrievalConstraint exact server-side constraint for the retrieval * @param progressListener receives live user-facing retrieval progress notices - * @return constrained, truncated retrieval outcome + * @return constrained, truncated documents */ - public RetrievalOutcome retrieveWithLimitOutcome( + public List retrieveWithLimit( String query, int maxDocuments, int maxTokensPerDocument, RetrievalConstraint retrievalConstraint, Consumer progressListener) { - return retrieveWithLimitOutcome( + return retrieveWithLimit( query, maxDocuments, maxTokensPerDocument, @@ -546,9 +474,9 @@ public RetrievalOutcome retrieveWithLimitOutcome( * @param retrievalConstraint exact server-side constraint for the retrieval * @param progressListener receives live user-facing retrieval progress notices * @param stageDeadlineNanos absolute {@link System#nanoTime()} response-preparation deadline - * @return constrained, truncated retrieval outcome + * @return constrained, truncated documents */ - public RetrievalOutcome retrieveWithLimitOutcome( + public List retrieveWithLimit( String query, int maxDocuments, int maxTokensPerDocument, @@ -556,21 +484,20 @@ public RetrievalOutcome retrieveWithLimitOutcome( Consumer progressListener, long stageDeadlineNanos) { QueryVersionEvidence queryVersionEvidence = queryVersionEvidence(query, retrievalConstraint); - return limitRetrievalOutcome( - retrieveOutcome(query, retrievalConstraint, progressListener, stageDeadlineNanos), + return limitDocuments( + retrieve(query, retrievalConstraint, progressListener, stageDeadlineNanos), maxDocuments, maxTokensPerDocument, queryVersionEvidence.evidenceConstraints()); } - private RetrievalOutcome limitRetrievalOutcome( - RetrievalOutcome outcome, + private List limitDocuments( + List documents, int maxDocuments, int maxTokensPerDocument, List evidenceConstraints) { - List documents = outcome.documents(); if (documents.isEmpty()) { - return outcome; + return documents; } int finalDocumentLimit = Math.max(Math.max(1, maxDocuments), evidenceConstraints.size()); List coveredDocuments = evidenceConstraints.isEmpty() @@ -579,23 +506,7 @@ private RetrievalOutcome limitRetrievalOutcome( List truncatedDocuments = coveredDocuments.stream() .map(document -> truncateDocumentToTokenLimit(document, maxTokensPerDocument)) .toList(); - return new RetrievalOutcome(truncatedDocuments, outcome.notices()); - } - - /** - * Retrieves documents while capping document count and per-document token budget. - */ - public List retrieveWithLimit(String query, int maxDocuments, int maxTokensPerDocument) { - return retrieveWithLimitOutcome(query, maxDocuments, maxTokensPerDocument) - .documents(); - } - - /** Holds hybrid-ranked candidates before the operation-specific final ordering step. */ - private record CandidateRetrieval(List documents, List notices) { - private CandidateRetrieval { - documents = documents == null ? List.of() : List.copyOf(documents); - notices = notices == null ? List.of() : List.copyOf(notices); - } + return truncatedDocuments; } private List deduplicateBySourceAndContentIdentity(List documents) { @@ -650,16 +561,6 @@ private static void requireRemainingStageBudget(long stageDeadlineNanos) { } } - private static void appendSearchOutcome( - HybridSearchService.SearchOutcome searchOutcome, - List retrievedDocuments, - List retrievalNotices) { - retrievedDocuments.addAll(searchOutcome.documents()); - searchOutcome.notices().stream() - .map(searchNotice -> new RetrievalNotice(searchNotice.summary(), searchNotice.details())) - .forEach(retrievalNotices::add); - } - private List searchCitationCandidates( String query, int citationCandidateLimit, @@ -667,17 +568,14 @@ private List searchCitationCandidates( List evidenceConstraints, long stageDeadlineNanos) { if (evidenceConstraints.isEmpty()) { - return hybridSearchService - .searchDocumentationCitationsOutcome( - query, citationCandidateLimit, retrievalConstraint, stageDeadlineNanos) - .documents(); + return hybridSearchService.searchDocumentationCitations( + query, citationCandidateLimit, retrievalConstraint, stageDeadlineNanos); } - List evidenceCitationOutcomes = - hybridSearchService.searchDocumentationCitationsOutcomes( - query, citationCandidateLimit, evidenceConstraints, stageDeadlineNanos); + List> evidenceCitationDocuments = hybridSearchService.searchDocumentationCitationsByConstraint( + query, citationCandidateLimit, evidenceConstraints, stageDeadlineNanos); List citationCandidates = new ArrayList<>(); - for (HybridSearchService.SearchOutcome evidenceCitationOutcome : evidenceCitationOutcomes) { - citationCandidates.addAll(evidenceCitationOutcome.documents()); + for (List evidenceDocuments : evidenceCitationDocuments) { + citationCandidates.addAll(evidenceDocuments); } return deduplicateBySourceAndContentIdentity(citationCandidates); } diff --git a/src/main/java/com/williamcallahan/javachat/web/ChatController.java b/src/main/java/com/williamcallahan/javachat/web/ChatController.java index 29b30c61..1fb33185 100644 --- a/src/main/java/com/williamcallahan/javachat/web/ChatController.java +++ b/src/main/java/com/williamcallahan/javachat/web/ChatController.java @@ -32,6 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.document.Document; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -198,12 +199,9 @@ public Flux> stream( chunkCount.incrementAndGet(); }); - // Surface retrieval and citation-conversion notices before response - // content. - Flux> statusEvents = Flux.fromIterable(promptOutcome.notices()) - .map(notice -> sseSupport.statusEvent(notice.summary(), notice.details())); - statusEvents = statusEvents.concatWith(sseSupport.citationPartialFailureStatusFlux( - citationOutcome.failedConversionCount())); + Flux> statusEvents = + sseSupport.citationPartialFailureStatusFlux( + citationOutcome.failedConversionCount()); // Wrap chunks in JSON to preserve whitespace Flux> dataEvents = dataStream.map(sseSupport::textEvent); @@ -283,20 +281,11 @@ public Flux> stream( */ @GetMapping("/diagnostics/retrieval") public RetrievalDiagnosticsResponse retrievalDiagnostics(@RequestParam("q") String query) { - RetrievalService.RetrievalOutcome retrievalOutcome = - chatService.retrieveTokenConstrainedOfficialDocumentation(query); + List retrievalDocuments = chatService.retrieveTokenConstrainedOfficialDocumentation(query); // Normalize URLs the same way as citations so we never emit file:// links - List citations = retrievalService - .toCitationsForQuery(query, retrievalOutcome.documents()) - .citations(); - if (retrievalOutcome.notices().isEmpty()) { - return RetrievalDiagnosticsResponse.success(citations); - } - String noticeDetails = retrievalOutcome.notices().stream() - .map(notice -> notice.summary() + ": " + notice.details()) - .reduce((first, second) -> first + "; " + second) - .orElse("Retrieval warnings present"); - return new RetrievalDiagnosticsResponse(citations, noticeDetails); + List citations = + retrievalService.toCitationsForQuery(query, retrievalDocuments).citations(); + return RetrievalDiagnosticsResponse.success(citations); } /** diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index da40a694..8b1c4d06 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,7 +5,7 @@ spring.profiles.active=${SPRING_PROFILE:prod} # HTTP server (restricted to 8085-8090 by PortInitializer) server.port=${PORT:8085} -server.forward-headers-strategy=framework +server.forward-headers-strategy=native # Canonical public base URL used for SEO responses (sitemap.xml, robots.txt, OpenGraph/canonical tags) app.public-base-url=${PUBLIC_BASE_URL:https://javachat.ai} diff --git a/src/test/java/com/williamcallahan/javachat/service/ChatServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/ChatServiceTest.java index 2be28168..8f5ad36f 100644 --- a/src/test/java/com/williamcallahan/javachat/service/ChatServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/ChatServiceTest.java @@ -49,8 +49,8 @@ void citationsUseSparseDiscoveryConstrainedByEveryCanonicalOfficialSourceIdentit assertEquals(List.of(expectedCitation), citations); verify(retrievalService, never()).retrieve(anyString()); verify(retrievalService, never()).retrieve(anyString(), any(RetrievalConstraint.class)); - verify(retrievalService, never()).retrieveOutcome(anyString()); - verify(retrievalService, never()).retrieveOutcome(anyString(), any(RetrievalConstraint.class)); + verify(retrievalService, never()).retrieve(anyString()); + verify(retrievalService, never()).retrieve(anyString(), any(RetrievalConstraint.class)); } @Test @@ -77,14 +77,14 @@ void versionedStructuredPromptUsesReducedOfficialContextForTheDefaultGpt54Model( SystemPromptConfig systemPromptConfig = mock(SystemPromptConfig.class); OpenAIStreamingService streamingService = mock(OpenAIStreamingService.class); when(systemPromptConfig.getCoreSystemPrompt()).thenReturn("You are a Java tutor."); - when(retrievalService.retrieveWithLimitOutcome( + when(retrievalService.retrieveWithLimit( eq(VERSIONED_CONTEXT_QUERY), eq(ModelConfiguration.RAG_LIMIT_CONSTRAINED), eq(ModelConfiguration.RAG_TOKEN_LIMIT_CONSTRAINED), any(RetrievalConstraint.class), any(), anyLong())) - .thenReturn(new RetrievalService.RetrievalOutcome(List.of(), List.of())); + .thenReturn(List.of()); ChatService chatService = new ChatService(streamingService, retrievalService, systemPromptConfig, new AppProperties()); @@ -92,7 +92,7 @@ void versionedStructuredPromptUsesReducedOfficialContextForTheDefaultGpt54Model( ArgumentCaptor constraintCaptor = ArgumentCaptor.forClass(RetrievalConstraint.class); verify(retrievalService) - .retrieveWithLimitOutcome( + .retrieveWithLimit( eq(VERSIONED_CONTEXT_QUERY), eq(ModelConfiguration.RAG_LIMIT_CONSTRAINED), eq(ModelConfiguration.RAG_TOKEN_LIMIT_CONSTRAINED), @@ -118,14 +118,14 @@ void structuredPromptDoesNotInventVersionForUnversionedRetainedContext() { "https://docs.spring.io/spring-framework/reference/data-access.html") .metadata(QdrantPayloadFieldSchema.SOURCE_NAME_FIELD, "Spring Framework Reference") .build(); - when(retrievalService.retrieveWithLimitOutcome( + when(retrievalService.retrieveWithLimit( eq("Spring 7.0.7 transaction behavior"), eq(ModelConfiguration.RAG_LIMIT_CONSTRAINED), eq(ModelConfiguration.RAG_TOKEN_LIMIT_CONSTRAINED), any(RetrievalConstraint.class), any(), anyLong())) - .thenReturn(new RetrievalService.RetrievalOutcome(List.of(springReference), List.of())); + .thenReturn(List.of(springReference)); ChatService chatService = new ChatService( mock(OpenAIStreamingService.class), retrievalService, systemPromptConfig, new AppProperties()); @@ -152,14 +152,14 @@ void structuredPromptLabelsAdjacentSameFamilyDependencyEvidence() { .metadata(QdrantPayloadFieldSchema.DOC_SET_FIELD, "hikaricp/7.0.2/api") .metadata(QdrantPayloadFieldSchema.DOC_VERSION_FIELD, "7.0.2") .build(); - when(retrievalService.retrieveWithLimitOutcome( + when(retrievalService.retrieveWithLimit( eq("HikariCP 7.0.5 connection timeout"), eq(ModelConfiguration.RAG_LIMIT_CONSTRAINED), eq(ModelConfiguration.RAG_TOKEN_LIMIT_CONSTRAINED), any(RetrievalConstraint.class), any(), anyLong())) - .thenReturn(new RetrievalService.RetrievalOutcome(List.of(hikaricpDocumentation), List.of())); + .thenReturn(List.of(hikaricpDocumentation)); ChatService chatService = new ChatService( mock(OpenAIStreamingService.class), retrievalService, systemPromptConfig, new AppProperties()); diff --git a/src/test/java/com/williamcallahan/javachat/service/HybridSearchServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/HybridSearchServiceTest.java index 193c64ba..bb71bbe8 100644 --- a/src/test/java/com/williamcallahan/javachat/service/HybridSearchServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/HybridSearchServiceTest.java @@ -55,6 +55,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; +import org.springframework.ai.document.Document; /** * Verifies hybrid and sparse citation retrieval behavior at the direct Qdrant request boundary. @@ -127,7 +128,7 @@ void appliesServerFilterToHybridPrefetchAndDispatchesEachQueryWithTheConfiguredD RetrievalConstraint retrievalConstraint = RetrievalConstraint.forDocVersions(List.of(REPRESENTED_JAVA_API_SOURCE.javaRelease())); - hybridSearchService.searchOutcome(HYBRID_QUERY, 5, retrievalConstraint, stageDeadlineNanos()); + hybridSearchService.search(HYBRID_QUERY, 5, retrievalConstraint, stageDeadlineNanos()); assertEquals(4, capturedQueries.size()); assertTrue(capturedQueryTimeouts.stream() @@ -165,7 +166,7 @@ void primaryHybridSearchDoesNotTreatProjectTypeSyntaxAsAnOfficialJavadocConstrai .when(qdrantClient) .queryAsync(notNull(), notNull()); - buildSearchService().searchOutcome(EXACT_JAVA_API_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos()); + buildSearchService().search(EXACT_JAVA_API_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos()); assertEquals(4, capturedQueries.size()); for (QueryPoints capturedQuery : capturedQueries) { @@ -194,9 +195,8 @@ void multipleVersionScopesShareOneEncodingAndRetainIndependentResultBudgets() { RetrievalConstraint officialDocumentationConstraint = RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); - List versionOutcomes = buildSearchServiceWithGitHubDiscovery( - gitHubCollectionDiscovery) - .searchOutcomes( + List> versionOutcomes = buildSearchServiceWithGitHubDiscovery(gitHubCollectionDiscovery) + .searchByConstraint( HYBRID_QUERY, 5, List.of( @@ -205,8 +205,8 @@ void multipleVersionScopesShareOneEncodingAndRetainIndependentResultBudgets() { stageDeadlineNanos()); assertEquals(2, versionOutcomes.size()); - assertEquals(1, versionOutcomes.get(0).documents().size()); - assertEquals(1, versionOutcomes.get(1).documents().size()); + assertEquals(1, versionOutcomes.get(0).size()); + assertEquals(1, versionOutcomes.get(1).size()); assertEquals(4, capturedQueries.size()); assertTrue(capturedQueries.subList(0, 2).stream() .allMatch(queryRequest -> queryRequest.getLimit() == 5 @@ -249,12 +249,11 @@ void concurrentSearchesDispatchTheirQdrantFanOutsIndependently() HybridSearchService hybridSearchService = buildSearchService(); try (ExecutorService searchExecutor = Executors.newVirtualThreadPerTaskExecutor()) { - CompletableFuture firstSearch = CompletableFuture.supplyAsync( - () -> hybridSearchService.searchOutcome( - HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos()), + CompletableFuture> firstSearch = CompletableFuture.supplyAsync( + () -> hybridSearchService.search(HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos()), searchExecutor); - CompletableFuture secondSearch = CompletableFuture.supplyAsync( - () -> hybridSearchService.searchOutcome( + CompletableFuture> secondSearch = CompletableFuture.supplyAsync( + () -> hybridSearchService.search( SECOND_HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos()), searchExecutor); try { @@ -264,9 +263,8 @@ void concurrentSearchesDispatchTheirQdrantFanOutsIndependently() heldQueryFutures.forEach(heldQueryFuture -> heldQueryFuture.set(List.of(scoredPoint()))); - assertEquals(1, firstSearch.get(1, TimeUnit.SECONDS).documents().size()); - assertEquals( - 1, secondSearch.get(1, TimeUnit.SECONDS).documents().size()); + assertEquals(1, firstSearch.get(1, TimeUnit.SECONDS).size()); + assertEquals(1, secondSearch.get(1, TimeUnit.SECONDS).size()); } finally { heldQueryFutures.forEach(heldQueryFuture -> heldQueryFuture.set(List.of(scoredPoint()))); } @@ -294,10 +292,10 @@ void nearlyExhaustedStageDeadlineTightensEmbeddingAndQdrantBudgetsBelowTheirConf .queryAsync(notNull(), notNull()); long nearlyExhaustedStageDeadlineNanos = System.nanoTime() + NEARLY_EXHAUSTED_STAGE_BUDGET.toNanos(); - HybridSearchService.SearchOutcome nearlyExhaustedStageOutcome = buildSearchService() - .searchOutcome(HYBRID_QUERY, 5, RetrievalConstraint.none(), nearlyExhaustedStageDeadlineNanos); + List nearlyExhaustedStageOutcome = buildSearchService() + .search(HYBRID_QUERY, 5, RetrievalConstraint.none(), nearlyExhaustedStageDeadlineNanos); - assertEquals(1, nearlyExhaustedStageOutcome.documents().size()); + assertEquals(1, nearlyExhaustedStageOutcome.size()); assertEquals(1, capturedEmbeddingBudgets.size()); Duration capturedEmbeddingBudget = capturedEmbeddingBudgets.getFirst(); assertFalse(capturedEmbeddingBudget.isNegative()); @@ -324,7 +322,7 @@ void collectionTimeoutReportsEffectiveStageBudgetInsteadOfConfiguredFullTimeout( HybridSearchPartialFailureException timedOutSearchFailure; try (ExpectedLogEvents expectedLogEvents = ExpectedLogEvents.capture(HYBRID_SEARCH_LOGGER)) { timedOutSearchFailure = assertThrows(HybridSearchPartialFailureException.class, () -> buildSearchService() - .searchOutcome(HYBRID_QUERY, 5, RetrievalConstraint.none(), nearlyExhaustedStageDeadlineNanos)); + .search(HYBRID_QUERY, 5, RetrievalConstraint.none(), nearlyExhaustedStageDeadlineNanos)); assertEquals( appProperties.getQdrant().getCollections().all().size(), @@ -362,9 +360,8 @@ void citationSearchUsesOnlyTheSparseOfficialDocumentationRequestAndDurationOverl RetrievalConstraint officialDocumentationConstraint = RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); - HybridSearchService.SearchOutcome citationSearchOutcome = - hybridSearchService.searchDocumentationCitationsOutcome( - CITATION_QUERY, 3, officialDocumentationConstraint, stageDeadlineNanos()); + List citationSearchOutcome = hybridSearchService.searchDocumentationCitations( + CITATION_QUERY, 3, officialDocumentationConstraint, stageDeadlineNanos()); assertEquals(2, capturedQueries.size()); assertEquals(2, capturedQueryTimeouts.size()); @@ -396,10 +393,10 @@ void citationSearchUsesOnlyTheSparseOfficialDocumentationRequestAndDurationOverl assertTrue(officialFilter.contains(QdrantPayloadFieldSchema.DOC_SET_FIELD)); assertTrue(officialFilter.contains(REPRESENTED_JAVA_API_SOURCE.relativeMirrorPath())); } - assertEquals(1, citationSearchOutcome.documents().size()); + assertEquals(1, citationSearchOutcome.size()); assertEquals( appProperties.getQdrant().getCollections().getDocs(), - citationSearchOutcome.documents().getFirst().getMetadata().get("collection")); + citationSearchOutcome.getFirst().getMetadata().get("collection")); verifyNoInteractions(embeddingClient); verify(gitHubCollectionDiscovery, never()).getDiscoveredCollections(); verify(qdrantClient, never()).queryAsync(any(QueryPoints.class)); @@ -420,12 +417,11 @@ void exactCitationSearchScrollsByPayloadFilterWithoutSparseScoring() { .scrollAsync(notNull(), notNull()); HybridSearchService hybridSearchService = buildSearchService(); - HybridSearchService.SearchOutcome exactCitationOutcome = - hybridSearchService.searchDocumentationCitationsOutcome( - VERSIONED_SELECTOR_CITATION_QUERY, - 3, - RetrievalConstraint.forDocVersions(List.of(REPRESENTED_JAVA_API_SOURCE.javaRelease())), - stageDeadlineNanos()); + List exactCitationOutcome = hybridSearchService.searchDocumentationCitations( + VERSIONED_SELECTOR_CITATION_QUERY, + 3, + RetrievalConstraint.forDocVersions(List.of(REPRESENTED_JAVA_API_SOURCE.javaRelease())), + stageDeadlineNanos()); assertEquals(1, capturedScrolls.size()); assertEquals(1, capturedScrollTimeouts.size()); @@ -442,10 +438,10 @@ void exactCitationSearchScrollsByPayloadFilterWithoutSparseScoring() { assertTrue(versionFilter.contains("List.html")); assertTrue(versionFilter.contains(QdrantPayloadFieldSchema.ANCHOR_FIELD)); assertTrue(versionFilter.contains("of(E,E)")); - assertEquals(1, exactCitationOutcome.documents().size()); + assertEquals(1, exactCitationOutcome.size()); assertEquals( appProperties.getQdrant().getCollections().getDocs(), - exactCitationOutcome.documents().getFirst().getMetadata().get("collection")); + exactCitationOutcome.getFirst().getMetadata().get("collection")); verifyNoInteractions(sparseEncoder); verifyNoInteractions(embeddingClient); verify(qdrantClient, never()).queryAsync(notNull(), notNull()); @@ -464,12 +460,11 @@ void exactThirdPartySignatureRemainsOnSparseOfficialDocumentationSearch() { .when(qdrantClient) .queryAsync(notNull(), notNull()); - HybridSearchService.SearchOutcome citationOutcome = buildSearchService() - .searchDocumentationCitationsOutcome( - thirdPartyQuery, 3, RetrievalConstraint.none(), stageDeadlineNanos()); + List citationOutcome = buildSearchService() + .searchDocumentationCitations(thirdPartyQuery, 3, RetrievalConstraint.none(), stageDeadlineNanos()); assertEquals(2, capturedQueries.size()); - assertEquals(1, citationOutcome.documents().size()); + assertEquals(1, citationOutcome.size()); verify(sparseEncoder).encode(thirdPartyQuery + " SpringApplication run"); verify(qdrantClient, never()).scrollAsync(notNull(), notNull()); verifyNoInteractions(embeddingClient); @@ -487,8 +482,8 @@ void runtimeValueCitationQueryRemainsSparse() { .when(qdrantClient) .queryAsync(notNull(), notNull()); - HybridSearchService.SearchOutcome citationOutcome = buildSearchService() - .searchDocumentationCitationsOutcome( + List citationOutcome = buildSearchService() + .searchDocumentationCitations( RUNTIME_VALUE_JAVA_API_QUERY, 3, RetrievalConstraint.none(), stageDeadlineNanos()); assertEquals(2, capturedQueries.size()); @@ -500,7 +495,7 @@ void runtimeValueCitationQueryRemainsSparse() { .contains(QdrantPayloadFieldSchema.JAVA_API_TYPE_PAGE_FIELD))); assertTrue(capturedQueries.stream() .allMatch(queryRequest -> queryRequest.getFilter().toString().contains("List.html"))); - assertEquals(1, citationOutcome.documents().size()); + assertEquals(1, citationOutcome.size()); verify(sparseEncoder).encode("List of"); verify(qdrantClient, never()).scrollAsync(notNull(), notNull()); verifyNoInteractions(embeddingClient); @@ -520,14 +515,13 @@ void explicitJavaMemberCitationSearchExcludesUnrelatedPromptTermsFromSparseEncod .when(qdrantClient) .queryAsync(notNull(), notNull()); - HybridSearchService.SearchOutcome citationOutcome = buildSearchService() - .searchDocumentationCitationsOutcome( - styledExampleQuery, 3, RetrievalConstraint.none(), stageDeadlineNanos()); + List citationOutcome = buildSearchService() + .searchDocumentationCitations(styledExampleQuery, 3, RetrievalConstraint.none(), stageDeadlineNanos()); assertEquals(2, capturedQueries.size()); assertTrue(capturedQueries.stream() .allMatch(queryRequest -> queryRequest.getFilter().toString().contains("String.html"))); - assertEquals(1, citationOutcome.documents().size()); + assertEquals(1, citationOutcome.size()); verify(sparseEncoder).encode("String formatted"); verify(qdrantClient, never()).scrollAsync(notNull(), notNull()); verifyNoInteractions(embeddingClient); @@ -542,7 +536,7 @@ void exactCitationSearchFailsStrictlyWhenFilteredScrollFails() { HybridSearchPartialFailureException citationSearchFailure; try (ExpectedLogEvents expectedLogEvents = ExpectedLogEvents.capture(HYBRID_SEARCH_LOGGER)) { citationSearchFailure = assertThrows(HybridSearchPartialFailureException.class, () -> buildSearchService() - .searchDocumentationCitationsOutcome( + .searchDocumentationCitations( EXACT_JAVA_API_QUERY, 3, RetrievalConstraint.none(), stageDeadlineNanos())); assertEquals(1, expectedLogEvents.events().size()); @@ -563,7 +557,7 @@ void exactCitationScrollTimeoutCancelsOriginalQdrantFuture() { when(qdrantClient.scrollAsync(notNull(), notNull())).thenReturn(stalledScrollFuture); assertThrows(HybridSearchPartialFailureException.class, () -> buildSearchService() - .searchDocumentationCitationsOutcome( + .searchDocumentationCitations( EXACT_JAVA_API_QUERY, 3, RetrievalConstraint.none(), stageDeadlineNanos())); assertTrue(stalledScrollFuture.isCancelled()); @@ -584,7 +578,7 @@ void citationSearchRemainsStrictWhenHybridPartialFailuresAreConfiguredAsNonFatal try (ExpectedLogEvents expectedLogEvents = ExpectedLogEvents.capture(HYBRID_SEARCH_LOGGER)) { citationSearchFailure = assertThrows( HybridSearchPartialFailureException.class, - () -> hybridSearchService.searchDocumentationCitationsOutcome( + () -> hybridSearchService.searchDocumentationCitations( CITATION_QUERY, 3, officialDocumentationConstraint, stageDeadlineNanos())); assertEquals(2, expectedLogEvents.events().size()); @@ -626,7 +620,7 @@ void sharesOneDispatchDeadlineAcrossMultipleStalledHybridQueries() { SHARED_DEADLINE_ASSERTION_LIMIT, () -> assertThrows( HybridSearchPartialFailureException.class, - () -> hybridSearchService.searchOutcome( + () -> hybridSearchService.search( HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos()))); assertEquals(4, expectedLogEvents.events().size()); @@ -659,7 +653,7 @@ void exhaustedDispatchBudgetFailsUndispatchedCollectionsImmediately() { HybridSearchPartialFailureException searchFailure = assertThrows(HybridSearchPartialFailureException.class, () -> buildSearchService() - .searchOutcome(HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos())); + .search(HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos())); assertEquals(1, dispatchedQueryCount.get()); assertFalse(searchFailure.collectionFailures().isEmpty()); @@ -690,11 +684,11 @@ void interruptionAfterFanOutCancelsEveryPendingQuery() throws InterruptedExcepti AtomicBoolean interruptStatusPreserved = new AtomicBoolean(); try (ExecutorService searchExecutor = Executors.newSingleThreadExecutor()) { - CompletableFuture interruptedSearch = CompletableFuture.supplyAsync( + CompletableFuture> interruptedSearch = CompletableFuture.supplyAsync( () -> { searchThread.set(Thread.currentThread()); try { - return hybridSearchService.searchOutcome( + return hybridSearchService.search( HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos()); } finally { interruptStatusPreserved.set(Thread.currentThread().isInterrupted()); @@ -726,23 +720,7 @@ void throwsWhenAnyCollectionFailsInStrictMode() { try (ExpectedLogEvents expectedLogEvents = ExpectedLogEvents.capture(HYBRID_SEARCH_LOGGER)) { assertThrows( HybridSearchPartialFailureException.class, - () -> hybridSearchService.searchOutcome( - "collections health", 5, retrievalConstraint, stageDeadlineNanos())); - assertCollectionFailureWarning(expectedLogEvents); - } - } - - @Test - void collectionFailureIsAlwaysTerminal() { - stubPartialFailureQueryResponses("collections health"); - - HybridSearchService hybridSearchService = buildSearchService(); - RetrievalConstraint retrievalConstraint = RetrievalConstraint.none(); - - try (ExpectedLogEvents expectedLogEvents = ExpectedLogEvents.capture(HYBRID_SEARCH_LOGGER)) { - assertThrows( - HybridSearchPartialFailureException.class, - () -> hybridSearchService.searchOutcome( + () -> hybridSearchService.search( "collections health", 5, retrievalConstraint, stageDeadlineNanos())); assertCollectionFailureWarning(expectedLogEvents); } @@ -764,7 +742,7 @@ void unavailableGrpcFailureRemainsTypedAndRetryable() { HybridSearchPartialFailureException searchFailure = assertThrows(HybridSearchPartialFailureException.class, () -> buildSearchService() - .searchOutcome(HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos())); + .search(HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos())); assertTrue(searchFailure.isRetryable()); assertSame(unavailableFailure, searchFailure.getCause()); @@ -789,7 +767,7 @@ void permissionDeniedGrpcFailureRemainsTypedAndPermanent() { HybridSearchPartialFailureException searchFailure = assertThrows(HybridSearchPartialFailureException.class, () -> buildSearchService() - .searchOutcome(HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos())); + .search(HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos())); assertFalse(searchFailure.isRetryable()); assertSame(permissionDeniedFailure, searchFailure.getCause()); @@ -814,7 +792,7 @@ void deadlineExceededGrpcFailureBecomesTimeoutCauseAndRetainsGrpcFailure() { HybridSearchPartialFailureException searchFailure = assertThrows(HybridSearchPartialFailureException.class, () -> buildSearchService() - .searchOutcome(HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos())); + .search(HYBRID_QUERY, 5, RetrievalConstraint.none(), stageDeadlineNanos())); TimeoutException timeoutFailure = assertInstanceOf(TimeoutException.class, searchFailure.getCause()); assertSame(deadlineExceededFailure, timeoutFailure.getCause()); diff --git a/src/test/java/com/williamcallahan/javachat/service/RerankerServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/RerankerServiceTest.java index 89ae0f58..2cf41a93 100644 --- a/src/test/java/com/williamcallahan/javachat/service/RerankerServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/RerankerServiceTest.java @@ -139,7 +139,7 @@ void rerankReturnsEmptySelectionWhenNoDocumentIsRelevant() { .thenReturn(Mono.just("{\"order\":[]}")); RerankerService rerankerService = createRerankerService(streamingService); - List sourceDocuments = List.of(new Document("first"), new Document("second")); + List sourceDocuments = List.of(new Document("irrelevant")); List selectedDocuments = rerankerService.rerank( "query", sourceDocuments, 5, stageDeadlineNanos(REMAINING_STAGE_BUDGET_TEST_TIMEOUT)); @@ -147,6 +147,31 @@ void rerankReturnsEmptySelectionWhenNoDocumentIsRelevant() { assertTrue(selectedDocuments.isEmpty()); } + @Test + void rerankRetainsOneRelevantDocument() { + OpenAIStreamingService streamingService = mock(OpenAIStreamingService.class); + when(streamingService.completeJsonObject( + anyString(), + eq(TEST_RERANKER_TEMPERATURE), + eq(TEST_RERANKER_OUTPUT_TOKEN_BUDGET), + eq(TEST_RERANKER_TIMEOUT))) + .thenReturn(Mono.just("{\"order\":[0]}")); + + RerankerService rerankerService = createRerankerService(streamingService); + List sourceDocuments = List.of(new Document("relevant")); + + List selectedDocuments = rerankerService.rerank( + "query", sourceDocuments, 5, stageDeadlineNanos(REMAINING_STAGE_BUDGET_TEST_TIMEOUT)); + + assertEquals(sourceDocuments, selectedDocuments); + verify(streamingService) + .completeJsonObject( + anyString(), + eq(TEST_RERANKER_TEMPERATURE), + eq(TEST_RERANKER_OUTPUT_TOKEN_BUDGET), + eq(TEST_RERANKER_TIMEOUT)); + } + @Test void rerankRejectsDuplicateInvalidAndMalformedOrderings() { OpenAIStreamingService streamingService = mock(OpenAIStreamingService.class); diff --git a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java index f299e142..c01d4b30 100644 --- a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java @@ -78,14 +78,14 @@ void genericBroadOfficialScopeUsesDefaultJavaApiSourceAndRetainsEveryNonJavaSour RetrievalConstraint guidedConstraint = RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); RetrievalConstraint expectedScopedConstraint = defaultJavaApiBroadOfficialConstraint(); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(List.of(), List.of())); + when(hybridSearchService.search(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) + .thenReturn(List.of()); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(List.of()); - retrievalService.retrieveOutcome("Java strings", guidedConstraint); + retrievalService.retrieve("Java strings", guidedConstraint); - verify(hybridSearchService).searchOutcome(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong()); + verify(hybridSearchService).search(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong()); assertEquals(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES, guidedConstraint.docSet()); assertEquals( List.of(DEFAULT_JAVA_API_DOCUMENTATION_SOURCE_IDENTITY), @@ -123,17 +123,14 @@ void bareJavaMemberUsesDeterministicSparseEvidenceWithoutReranking() { "java.lang", "String.html", "formatted(java.lang.Object...)"); - when(hybridSearchService.searchDocumentationCitationsOutcome( + when(hybridSearchService.searchDocumentationCitations( eq(memberQuery), eq(10), eq(expectedScopedConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome( - List.of(staticFormatDocument, formattedDocument), List.of())); + .thenReturn(List.of(staticFormatDocument, formattedDocument)); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome(memberQuery, officialDocumentationConstraint); + List retrievalOutcome = retrievalService.retrieve(memberQuery, officialDocumentationConstraint); - assertEquals(List.of(formattedDocument), retrievalOutcome.documents()); - verify(hybridSearchService, never()) - .searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + assertEquals(List.of(formattedDocument), retrievalOutcome); + verify(hybridSearchService, never()).search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @@ -163,19 +160,17 @@ void versionedBareJavaMemberUsesTheRequestedReleaseEvidence() { .metadata(QdrantPayloadFieldSchema.JAVA_API_TYPE_PAGE_FIELD, "String.html") .metadata(QdrantPayloadFieldSchema.ANCHOR_FIELD, "formatted(java.lang.Object...)") .build(); - when(hybridSearchService.searchDocumentationCitationsOutcomes( + when(hybridSearchService.searchDocumentationCitationsByConstraint( eq(memberQuery), eq(10), eq(List.of(java25Constraint)), anyLong())) - .thenReturn(List.of(new HybridSearchService.SearchOutcome(List.of(formattedDocument), List.of()))); + .thenReturn(List.of(List.of(formattedDocument))); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome(memberQuery, officialDocumentationConstraint); + List retrievalOutcome = retrievalService.retrieve(memberQuery, officialDocumentationConstraint); verify(hybridSearchService) - .searchDocumentationCitationsOutcomes( + .searchDocumentationCitationsByConstraint( eq(memberQuery), eq(10), eq(List.of(java25Constraint)), anyLong()); - assertEquals(List.of(formattedDocument), retrievalOutcome.documents()); - verify(hybridSearchService, never()) - .searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + assertEquals(List.of(formattedDocument), retrievalOutcome); + verify(hybridSearchService, never()).search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @@ -189,18 +184,18 @@ void otherOfficialApiMembersRemainOnGenericHybridRetrieval() { RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); RetrievalConstraint expectedScopedConstraint = defaultJavaApiBroadOfficialConstraint(); Document springDocument = versionedDocument("spring-application-run", "", "spring-run-hash"); - when(hybridSearchService.searchOutcome( + when(hybridSearchService.search( eq("Explain SpringApplication.run"), anyInt(), eq(expectedScopedConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(List.of(springDocument), List.of())); + .thenReturn(List.of(springDocument)); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(List.of(springDocument)); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome("Explain SpringApplication.run", officialDocumentationConstraint); + List retrievalOutcome = + retrievalService.retrieve("Explain SpringApplication.run", officialDocumentationConstraint); - assertEquals(List.of(springDocument), retrievalOutcome.documents()); + assertEquals(List.of(springDocument), retrievalOutcome); verify(hybridSearchService, never()) - .searchDocumentationCitationsOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + .searchDocumentationCitations(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); } @Test @@ -222,13 +217,13 @@ void retrievalReportsSearchThenRerankProgressInOrder() { RetrievalConstraint guidedConstraint = RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); RetrievalConstraint expectedScopedConstraint = defaultJavaApiBroadOfficialConstraint(); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(List.of(), List.of())); + when(hybridSearchService.search(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) + .thenReturn(List.of()); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(List.of()); List progressSummaries = new ArrayList<>(); - retrievalService.retrieveOutcome( + retrievalService.retrieve( "Java records", guidedConstraint, progressNotice -> progressSummaries.add(progressNotice.summary())); assertEquals(List.of("Searching the Java documentation index", "Reviewing the top matches"), progressSummaries); @@ -242,17 +237,17 @@ void nonJavaSourceScopeKeepsVersionMentionAsSemanticQueryTextWithoutVersionFilte hybridSearchService, new AppProperties(), rerankerService, mock(DocumentFactory.class)); RetrievalConstraint kotlinConstraint = RetrievalConstraint.forOfficialDocSets(List.of("kotlin")); Document kotlinDocument = versionedDocument("kotlin-interop", "", "kotlin-hash"); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), same(kotlinConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(List.of(kotlinDocument), List.of())); + when(hybridSearchService.search(anyString(), anyInt(), same(kotlinConstraint), anyLong())) + .thenReturn(List.of(kotlinDocument)); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(List.of(kotlinDocument)); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome("How does Kotlin interoperate with Java 21?", kotlinConstraint); + List retrievalOutcome = + retrievalService.retrieve("How does Kotlin interoperate with Java 21?", kotlinConstraint); - assertEquals(List.of(kotlinDocument), retrievalOutcome.documents()); + assertEquals(List.of(kotlinDocument), retrievalOutcome); verify(hybridSearchService) - .searchOutcome( + .search( eq( "JDK 21 Java SE 21 Java 21 release documentation: How does Kotlin interoperate with Java 21?"), anyInt(), @@ -273,16 +268,16 @@ void versionedConstrainedRetrievalCombinesOfficialScopeAndQueryVersionForHybridS String versionedQuery = "Java " + REPRESENTED_JAVA_API_SOURCE.javaRelease() + " collections"; Document versionedDocument = versionedDocument("represented-version", REPRESENTED_JAVA_API_SOURCE.javaRelease(), "represented-hash"); - when(hybridSearchService.searchOutcomes( + when(hybridSearchService.searchByConstraint( anyString(), anyInt(), eq(List.of(expectedCombinedConstraint)), anyLong())) - .thenReturn(List.of(new HybridSearchService.SearchOutcome(List.of(versionedDocument), List.of()))); + .thenReturn(List.of(List.of(versionedDocument))); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(List.of(versionedDocument)); - retrievalService.retrieveOutcome(versionedQuery, officialDocumentationConstraint); + retrievalService.retrieve(versionedQuery, officialDocumentationConstraint); verify(hybridSearchService) - .searchOutcomes(anyString(), anyInt(), eq(List.of(expectedCombinedConstraint)), anyLong()); + .searchByConstraint(anyString(), anyInt(), eq(List.of(expectedCombinedConstraint)), anyLong()); assertEquals(List.of(REPRESENTED_JAVA_API_SOURCE.javaRelease()), expectedCombinedConstraint.docVersions()); assertEquals("official", expectedCombinedConstraint.sourceKind()); assertEquals(List.of(REPRESENTED_JAVA_API_SOURCE.relativeMirrorPath()), expectedCombinedConstraint.docSet()); @@ -310,9 +305,9 @@ void citationDiscoveryLimitsAfterFinalUrlAndAnchorDeduplication() { citationCandidateDocument( "third-string-chunk", "Third String chunk", "third-hash", repeatedCitationUrl), citationCandidateDocument("list-chunk", "List chunk", "list-hash", uniqueCitationUrl)); - when(hybridSearchService.searchDocumentationCitationsOutcome( + when(hybridSearchService.searchDocumentationCitations( anyString(), eq(4), eq(expectedScopedConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(citationCandidates, List.of())); + .thenReturn(citationCandidates); RetrievalService.CitationOutcome citationOutcome = retrievalService.discoverCitations("Java strings", guidedConstraint); @@ -329,9 +324,8 @@ void citationDiscoveryLimitsAfterFinalUrlAndAnchorDeduplication() { .toList()); assertEquals(0, citationOutcome.failedConversionCount()); verify(hybridSearchService) - .searchDocumentationCitationsOutcome(anyString(), eq(4), eq(expectedScopedConstraint), anyLong()); - verify(hybridSearchService, never()) - .searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + .searchDocumentationCitations(anyString(), eq(4), eq(expectedScopedConstraint), anyLong()); + verify(hybridSearchService, never()).search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @@ -355,9 +349,9 @@ void citationDiscoveryReranksATypePageBeyondTheFirstThreeCandidatesBeforeFinalLi apiDocumentationCitationCandidate("integer", "A utility of() method", "java.lang", "Integer.html"), apiDocumentationMemberCitationCandidate( "list", "static List of(E element)", "java.util", "List.html", "of(E)")); - when(hybridSearchService.searchDocumentationCitationsOutcome( + when(hybridSearchService.searchDocumentationCitations( eq(citationQuery), eq(4), eq(expectedScopedConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(qdrantCandidates, List.of())); + .thenReturn(qdrantCandidates); RetrievalService.CitationOutcome citationOutcome = retrievalService.discoverCitations(citationQuery, officialDocumentationConstraint); @@ -382,14 +376,14 @@ void versionedCitationDiscoveryCombinesOfficialScopeAndQueryVersionBeforeDispatc String citationQuery = "Java " + REPRESENTED_JAVA_API_SOURCE.javaRelease() + " collections"; Document versionedCitation = versionedCitationDocument( "represented-version-citation", REPRESENTED_JAVA_API_SOURCE.javaRelease(), "represented-hash"); - when(hybridSearchService.searchDocumentationCitationsOutcomes( + when(hybridSearchService.searchDocumentationCitationsByConstraint( eq(citationQuery), anyInt(), eq(List.of(expectedCombinedConstraint)), anyLong())) - .thenReturn(List.of(new HybridSearchService.SearchOutcome(List.of(versionedCitation), List.of()))); + .thenReturn(List.of(List.of(versionedCitation))); retrievalService.discoverCitations(citationQuery, officialDocumentationConstraint); verify(hybridSearchService) - .searchDocumentationCitationsOutcomes( + .searchDocumentationCitationsByConstraint( eq(citationQuery), anyInt(), eq(List.of(expectedCombinedConstraint)), anyLong()); verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @@ -409,24 +403,22 @@ void multiVersionRetrievalSearchesEveryReleaseAndRetainsCoverageAfterReranking() Document java21Document = versionedDocument("java-21", "21", "shared-content-hash"); Document secondJava21Document = versionedDocument("java-21-secondary", "21", "secondary-hash"); Document java26Document = versionedDocument("java-26", "26", "shared-content-hash"); - when(hybridSearchService.searchOutcomes( + when(hybridSearchService.searchByConstraint( anyString(), eq(10), eq(List.of(java21Constraint, java26Constraint)), anyLong())) - .thenReturn(List.of( - new HybridSearchService.SearchOutcome(List.of(java21Document, secondJava21Document), List.of()), - new HybridSearchService.SearchOutcome(List.of(java26Document), List.of()))); + .thenReturn(List.of(List.of(java21Document, secondJava21Document), List.of(java26Document))); when(rerankerService.rerank(anyString(), anyList(), eq(2), anyLong())) .thenReturn(List.of(java21Document, secondJava21Document)); - RetrievalService.RetrievalOutcome retrievalOutcome = retrievalService.retrieveOutcome( - "Compare Java 21 and Java 26 collections", officialDocumentationConstraint); + List retrievalOutcome = + retrievalService.retrieve("Compare Java 21 and Java 26 collections", officialDocumentationConstraint); assertEquals( List.of("21", "26"), - retrievalOutcome.documents().stream() + retrievalOutcome.stream() .map(document -> document.getMetadata().get(QdrantPayloadFieldSchema.DOC_VERSION_FIELD)) .toList()); verify(hybridSearchService) - .searchOutcomes(anyString(), eq(10), eq(List.of(java21Constraint, java26Constraint)), anyLong()); + .searchByConstraint(anyString(), eq(10), eq(List.of(java21Constraint, java26Constraint)), anyLong()); assertEquals(List.of("java/java21-complete"), java21Constraint.docSet()); assertEquals(List.of("java/java26-complete"), java26Constraint.docSet()); } @@ -443,24 +435,22 @@ void missingJavaReleaseSearchesNearestOlderAndNewerDocumentation() { RetrievalConstraint java25Constraint = javaEvidenceConstraint(officialDocumentationConstraint, "25"); Document java21Document = versionedDocument("java-21", "21", "hash-21"); Document java25Document = versionedDocument("java-25", "25", "hash-25"); - when(hybridSearchService.searchOutcomes( + when(hybridSearchService.searchByConstraint( anyString(), eq(10), eq(List.of(java21Constraint, java25Constraint)), anyLong())) - .thenReturn(List.of( - new HybridSearchService.SearchOutcome(List.of(java21Document), List.of()), - new HybridSearchService.SearchOutcome(List.of(java25Document), List.of()))); + .thenReturn(List.of(List.of(java21Document), List.of(java25Document))); when(rerankerService.rerank(anyString(), anyList(), eq(2), anyLong())) .thenReturn(List.of(java21Document, java25Document)); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome("How does this work in Java 22?", officialDocumentationConstraint); + List retrievalOutcome = + retrievalService.retrieve("How does this work in Java 22?", officialDocumentationConstraint); assertEquals( List.of("21", "25"), - retrievalOutcome.documents().stream() + retrievalOutcome.stream() .map(document -> document.getMetadata().get(QdrantPayloadFieldSchema.DOC_VERSION_FIELD)) .toList()); verify(hybridSearchService) - .searchOutcomes(anyString(), eq(10), eq(List.of(java21Constraint, java25Constraint)), anyLong()); + .searchByConstraint(anyString(), eq(10), eq(List.of(java21Constraint, java25Constraint)), anyLong()); } @Test @@ -477,39 +467,35 @@ void missingDependencyVersionSearchesNearestSameFamilyVersions() { documentationEvidenceConstraint("hikaricp/7.1.0/api", "api-docs", "7.1.0"); Document hikaricp702Document = versionedDocument("hikaricp-702", "7.0.2", "hash-702", "hikaricp/7.0.2/api"); Document hikaricp710Document = versionedDocument("hikaricp-710", "7.1.0", "hash-710", "hikaricp/7.1.0/api"); - when(hybridSearchService.searchOutcomes( + when(hybridSearchService.searchByConstraint( anyString(), eq(10), eq(List.of(hikaricp702Constraint, hikaricp710Constraint)), anyLong())) - .thenReturn(List.of( - new HybridSearchService.SearchOutcome(List.of(hikaricp702Document), List.of()), - new HybridSearchService.SearchOutcome(List.of(hikaricp710Document), List.of()))); - when(hybridSearchService.searchDocumentationCitationsOutcomes( + .thenReturn(List.of(List.of(hikaricp702Document), List.of(hikaricp710Document))); + when(hybridSearchService.searchDocumentationCitationsByConstraint( anyString(), eq(10), eq(List.of(hikaricp702Constraint, hikaricp710Constraint)), anyLong())) - .thenReturn(List.of( - new HybridSearchService.SearchOutcome(List.of(hikaricp702Document), List.of()), - new HybridSearchService.SearchOutcome(List.of(hikaricp710Document), List.of()))); + .thenReturn(List.of(List.of(hikaricp702Document), List.of(hikaricp710Document))); when(rerankerService.rerank(anyString(), anyList(), eq(2), anyLong())) .thenReturn(List.of(hikaricp702Document, hikaricp710Document)); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome("HikariCP 7.0.5 connection pooling", officialDocumentationConstraint); - RetrievalService.RetrievalOutcome limitedOutcome = retrievalService.retrieveWithLimitOutcome( + List retrievalOutcome = + retrievalService.retrieve("HikariCP 7.0.5 connection pooling", officialDocumentationConstraint); + List limitedOutcome = retrievalService.retrieveWithLimit( "HikariCP 7.0.5 connection pooling", 2, 1_000, officialDocumentationConstraint); RetrievalService.CitationOutcome citationOutcome = retrievalService.discoverCitations( "HikariCP 7.0.5 connection pooling", officialDocumentationConstraint); assertEquals( List.of("7.0.2", "7.1.0"), - retrievalOutcome.documents().stream() + retrievalOutcome.stream() .map(document -> document.getMetadata().get(QdrantPayloadFieldSchema.DOC_VERSION_FIELD)) .toList()); assertEquals( List.of("7.0.2", "7.1.0"), - limitedOutcome.documents().stream() + limitedOutcome.stream() .map(document -> document.getMetadata().get(QdrantPayloadFieldSchema.DOC_VERSION_FIELD)) .toList()); assertEquals(2, citationOutcome.citations().size()); verify(hybridSearchService, org.mockito.Mockito.times(2)) - .searchOutcomes( + .searchByConstraint( anyString(), eq(10), eq(List.of(hikaricp702Constraint, hikaricp710Constraint)), anyLong()); } @@ -532,16 +518,16 @@ void queryWithJavaAndDependencyGapsRetainsBothSourceFamilies() { versionedDocument("hikaricp-710", "7.1.0", "hash-710", "hikaricp/7.1.0/api"), versionedDocument("java-21", "21", "hash-21"), versionedDocument("java-25", "25", "hash-25")); - when(hybridSearchService.searchOutcomes(anyString(), eq(10), eq(evidenceConstraints), anyLong())) + when(hybridSearchService.searchByConstraint(anyString(), eq(10), eq(evidenceConstraints), anyLong())) .thenReturn(evidenceDocuments.stream() - .map(document -> new HybridSearchService.SearchOutcome(List.of(document), List.of())) + .map(document -> List.of(document)) .toList()); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(evidenceDocuments); - RetrievalService.RetrievalOutcome retrievalOutcome = retrievalService.retrieveOutcome( - "Does HikariCP 7.0.5 work with Java 22?", officialDocumentationConstraint); - RetrievalService.RetrievalOutcome limitedOutcome = retrievalService.retrieveWithLimitOutcome( + List retrievalOutcome = + retrievalService.retrieve("Does HikariCP 7.0.5 work with Java 22?", officialDocumentationConstraint); + List limitedOutcome = retrievalService.retrieveWithLimit( "Does HikariCP 7.0.5 work with Java 22?", ModelConfiguration.RAG_LIMIT_CONSTRAINED, 1_000, @@ -549,16 +535,16 @@ void queryWithJavaAndDependencyGapsRetainsBothSourceFamilies() { assertEquals( evidenceVersions, - retrievalOutcome.documents().stream() + retrievalOutcome.stream() .map(document -> document.getMetadata().get(QdrantPayloadFieldSchema.DOC_VERSION_FIELD)) .toList()); assertEquals( evidenceVersions, - limitedOutcome.documents().stream() + limitedOutcome.stream() .map(document -> document.getMetadata().get(QdrantPayloadFieldSchema.DOC_VERSION_FIELD)) .toList()); verify(hybridSearchService, org.mockito.Mockito.times(2)) - .searchOutcomes(anyString(), eq(10), eq(evidenceConstraints), anyLong()); + .searchByConstraint(anyString(), eq(10), eq(evidenceConstraints), anyLong()); } @Test @@ -579,14 +565,14 @@ void multipleDependencyGapsRetainEveryDistinctEvidenceSource() { versionedDocument("hikaricp-710", "7.1.0", "hash-710", "hikaricp/7.1.0/api"), versionedDocument("spring-ai-112", "1.1.2", "hash-ai-112", "spring-ai-api-stable"), versionedDocument("spring-ai-118", "1.1.8", "hash-ai-118", "spring-ai-reference")); - when(hybridSearchService.searchOutcomes(anyString(), eq(10), eq(evidenceConstraints), anyLong())) + when(hybridSearchService.searchByConstraint(anyString(), eq(10), eq(evidenceConstraints), anyLong())) .thenReturn(evidenceDocuments.stream() - .map(document -> new HybridSearchService.SearchOutcome(List.of(document), List.of())) + .map(document -> List.of(document)) .toList()); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(evidenceDocuments.subList(0, ModelConfiguration.RAG_LIMIT_CONSTRAINED)); - RetrievalService.RetrievalOutcome retrievalOutcome = retrievalService.retrieveWithLimitOutcome( + List retrievalOutcome = retrievalService.retrieveWithLimit( "Compare HikariCP 7.0.5 with Spring AI 1.1.5", ModelConfiguration.RAG_LIMIT_CONSTRAINED, 1_000, @@ -598,7 +584,7 @@ void multipleDependencyGapsRetainEveryDistinctEvidenceSource() { "hikaricp/7.1.0/api@7.1.0", "spring-ai-api-stable@1.1.2", "spring-ai-reference@1.1.8"), - retrievalOutcome.documents().stream() + retrievalOutcome.stream() .map(document -> document.getMetadata().get(QdrantPayloadFieldSchema.DOC_SET_FIELD) + "@" + document.getMetadata().get(QdrantPayloadFieldSchema.DOC_VERSION_FIELD)) .toList()); @@ -612,17 +598,17 @@ void versionedDependencyMentionDoesNotEscapeCallerOwnedSourceScope() { hybridSearchService, new AppProperties(), rerankerService, mock(DocumentFactory.class)); RetrievalConstraint kotlinConstraint = RetrievalConstraint.forOfficialDocSets(List.of("kotlin")); Document kotlinDocument = versionedDocument("kotlin", "", "kotlin-hash"); - when(hybridSearchService.searchOutcome(anyString(), eq(10), same(kotlinConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(List.of(kotlinDocument), List.of())); + when(hybridSearchService.search(anyString(), eq(10), same(kotlinConstraint), anyLong())) + .thenReturn(List.of(kotlinDocument)); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(List.of(kotlinDocument)); - RetrievalService.RetrievalOutcome retrievalOutcome = retrievalService.retrieveOutcome( - "How does this Kotlin lesson compare with HikariCP 7.0.5?", kotlinConstraint); + List retrievalOutcome = + retrievalService.retrieve("How does this Kotlin lesson compare with HikariCP 7.0.5?", kotlinConstraint); - assertEquals(List.of(kotlinDocument), retrievalOutcome.documents()); - verify(hybridSearchService).searchOutcome(anyString(), eq(10), eq(kotlinConstraint), anyLong()); - verify(hybridSearchService, never()).searchOutcomes(anyString(), anyInt(), anyList(), anyLong()); + assertEquals(List.of(kotlinDocument), retrievalOutcome); + verify(hybridSearchService).search(anyString(), eq(10), eq(kotlinConstraint), anyLong()); + verify(hybridSearchService, never()).searchByConstraint(anyString(), anyInt(), anyList(), anyLong()); } @Test @@ -634,22 +620,23 @@ void versionedDependencyGapDoesNotWidenCallerOwnedVersionScope() { RetrievalConstraint hikaricp702Constraint = documentationEvidenceConstraint("hikaricp/7.0.2/api", "api-docs", "7.0.2"); Document hikaricp702Document = versionedDocument("hikaricp-702", "7.0.2", "hash-702", "hikaricp/7.0.2/api"); - when(hybridSearchService.searchOutcomes(anyString(), eq(10), eq(List.of(hikaricp702Constraint)), anyLong())) - .thenReturn(List.of(new HybridSearchService.SearchOutcome(List.of(hikaricp702Document), List.of()))); - when(hybridSearchService.searchDocumentationCitationsOutcomes( + when(hybridSearchService.searchByConstraint(anyString(), eq(10), eq(List.of(hikaricp702Constraint)), anyLong())) + .thenReturn(List.of(List.of(hikaricp702Document))); + when(hybridSearchService.searchDocumentationCitationsByConstraint( anyString(), eq(10), eq(List.of(hikaricp702Constraint)), anyLong())) - .thenReturn(List.of(new HybridSearchService.SearchOutcome(List.of(hikaricp702Document), List.of()))); + .thenReturn(List.of(List.of(hikaricp702Document))); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(List.of(hikaricp702Document)); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome("HikariCP 7.0.5 connection pooling", hikaricp702Constraint); + List retrievalOutcome = + retrievalService.retrieve("HikariCP 7.0.5 connection pooling", hikaricp702Constraint); RetrievalService.CitationOutcome citationOutcome = retrievalService.discoverCitations("HikariCP 7.0.5 connection pooling", hikaricp702Constraint); - assertEquals(List.of(hikaricp702Document), retrievalOutcome.documents()); + assertEquals(List.of(hikaricp702Document), retrievalOutcome); assertEquals(1, citationOutcome.citations().size()); - verify(hybridSearchService).searchOutcomes(anyString(), eq(10), eq(List.of(hikaricp702Constraint)), anyLong()); + verify(hybridSearchService) + .searchByConstraint(anyString(), eq(10), eq(List.of(hikaricp702Constraint)), anyLong()); } @Test @@ -660,15 +647,15 @@ void disjointCallerVersionScopeReturnsNoAdjacentEvidenceInsteadOfBroadening() { RetrievalConstraint missingExactConstraint = new RetrievalConstraint( List.of("7.0.5"), "official", "", "", List.of("hikaricp/7.0.2/api", "hikaricp/7.1.0/api")); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome("HikariCP 7.0.5 connection pooling", missingExactConstraint); + List retrievalOutcome = + retrievalService.retrieve("HikariCP 7.0.5 connection pooling", missingExactConstraint); RetrievalService.CitationOutcome citationOutcome = retrievalService.discoverCitations("HikariCP 7.0.5 connection pooling", missingExactConstraint); - assertEquals(List.of(), retrievalOutcome.documents()); + assertEquals(List.of(), retrievalOutcome); assertEquals(List.of(), citationOutcome.citations()); - verify(hybridSearchService, never()).searchOutcome(anyString(), anyInt(), any(), anyLong()); - verify(hybridSearchService, never()).searchOutcomes(anyString(), anyInt(), anyList(), anyLong()); + verify(hybridSearchService, never()).search(anyString(), anyInt(), any(), anyLong()); + verify(hybridSearchService, never()).searchByConstraint(anyString(), anyInt(), anyList(), anyLong()); } @Test @@ -684,21 +671,17 @@ void missingJavaReleaseUsesAdjacentEvidenceForExactMembersAndCitations() { String missingReleaseQuery = "How does Java 22 java.util.List.of(E, E) work?"; Document java21ExactOverload = exactListOfOverloadDocument("java-21-exact", "21", "exact-hash-21"); Document java25ExactOverload = exactListOfOverloadDocument("java-25-exact", "25", "exact-hash-25"); - when(hybridSearchService.searchDocumentationCitationsOutcomes( + when(hybridSearchService.searchDocumentationCitationsByConstraint( eq(missingReleaseQuery), eq(10), eq(List.of(java21Constraint, java25Constraint)), anyLong())) - .thenReturn(List.of( - new HybridSearchService.SearchOutcome(List.of(java21ExactOverload), List.of()), - new HybridSearchService.SearchOutcome(List.of(java25ExactOverload), List.of()))) - .thenReturn(List.of( - new HybridSearchService.SearchOutcome(List.of(java21ExactOverload), List.of()), - new HybridSearchService.SearchOutcome(List.of(java25ExactOverload), List.of()))); + .thenReturn(List.of(List.of(java21ExactOverload), List.of(java25ExactOverload))) + .thenReturn(List.of(List.of(java21ExactOverload), List.of(java25ExactOverload))); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome(missingReleaseQuery, officialDocumentationConstraint); + List retrievalOutcome = + retrievalService.retrieve(missingReleaseQuery, officialDocumentationConstraint); RetrievalService.CitationOutcome citationOutcome = retrievalService.discoverCitations(missingReleaseQuery, officialDocumentationConstraint); - assertEquals(List.of(java21ExactOverload, java25ExactOverload), retrievalOutcome.documents()); + assertEquals(List.of(java21ExactOverload, java25ExactOverload), retrievalOutcome); assertEquals( List.of( "https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html", @@ -706,8 +689,7 @@ void missingJavaReleaseUsesAdjacentEvidenceForExactMembersAndCitations() { citationOutcome.citations().stream() .map(citation -> citation.getUrl()) .toList()); - verify(hybridSearchService, never()) - .searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + verify(hybridSearchService, never()).search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @@ -727,21 +709,19 @@ void exactMultiVersionOverloadUsesAuthoritativeDocumentsFromEveryRequestedReleas "Compare Java 21 and Java 26 for java.util.List.of(E, E). Use evidence from both releases."; Document java21ExactOverload = exactListOfOverloadDocument("java-21-exact", "21", "exact-hash-21"); Document java26ExactOverload = exactListOfOverloadDocument("java-26-exact", "26", "exact-hash-26"); - when(hybridSearchService.searchDocumentationCitationsOutcomes( + when(hybridSearchService.searchDocumentationCitationsByConstraint( eq(exactComparisonQuery), eq(10), eq(List.of(java21Constraint, java26Constraint)), anyLong())) - .thenReturn(List.of( - new HybridSearchService.SearchOutcome(List.of(java21ExactOverload), List.of()), - new HybridSearchService.SearchOutcome(List.of(java26ExactOverload), List.of()))); + .thenReturn(List.of(List.of(java21ExactOverload), List.of(java26ExactOverload))); - RetrievalService.RetrievalOutcome retrievalOutcome = retrievalService.retrieveWithLimitOutcome( - exactComparisonQuery, 3, 1_000, officialDocumentationConstraint); + List retrievalOutcome = + retrievalService.retrieveWithLimit(exactComparisonQuery, 3, 1_000, officialDocumentationConstraint); RetrievalService.CitationOutcome citationOutcome = - retrievalService.toCitationsForQuery(exactComparisonQuery, retrievalOutcome.documents()); + retrievalService.toCitationsForQuery(exactComparisonQuery, retrievalOutcome); - assertEquals(List.of(java21ExactOverload, java26ExactOverload), retrievalOutcome.documents()); + assertEquals(List.of(java21ExactOverload, java26ExactOverload), retrievalOutcome); assertEquals( List.of("21", "26"), - retrievalOutcome.documents().stream() + retrievalOutcome.stream() .map(document -> document.getMetadata().get(QdrantPayloadFieldSchema.DOC_VERSION_FIELD)) .toList()); assertEquals( @@ -757,10 +737,9 @@ void exactMultiVersionOverloadUsesAuthoritativeDocumentsFromEveryRequestedReleas .map(citation -> citation.getUrl()) .toList()); verify(hybridSearchService) - .searchDocumentationCitationsOutcomes( + .searchDocumentationCitationsByConstraint( eq(exactComparisonQuery), eq(10), eq(List.of(java21Constraint, java26Constraint)), anyLong()); - verify(hybridSearchService, never()) - .searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + verify(hybridSearchService, never()).search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @@ -778,14 +757,12 @@ void multiVersionExactCitationDiscoveryReturnsAuthoritativeOverloadFromEveryRequ JAVA_21_EXACT_LIST_DOCUMENT_ID, JAVA_21_RELEASE, JAVA_21_EXACT_LIST_CONTENT_HASH); Document java26ExactOverload = exactListOfOverloadDocument( JAVA_26_EXACT_LIST_DOCUMENT_ID, JAVA_26_RELEASE, JAVA_26_EXACT_LIST_CONTENT_HASH); - when(hybridSearchService.searchDocumentationCitationsOutcomes( + when(hybridSearchService.searchDocumentationCitationsByConstraint( eq(EXACT_LIST_OF_QUERY), eq(DOCUMENTATION_CITATION_CANDIDATE_LIMIT), eq(List.of(java21Constraint, java26Constraint)), anyLong())) - .thenReturn(List.of( - new HybridSearchService.SearchOutcome(List.of(java21ExactOverload), List.of()), - new HybridSearchService.SearchOutcome(List.of(java26ExactOverload), List.of()))); + .thenReturn(List.of(List.of(java21ExactOverload), List.of(java26ExactOverload))); RetrievalService.CitationOutcome citationOutcome = retrievalService.discoverCitations(EXACT_LIST_OF_QUERY, officialDocumentationConstraint); @@ -802,13 +779,12 @@ void multiVersionExactCitationDiscoveryReturnsAuthoritativeOverloadFromEveryRequ .toList()); assertEquals(0, citationOutcome.failedConversionCount()); verify(hybridSearchService) - .searchDocumentationCitationsOutcomes( + .searchDocumentationCitationsByConstraint( eq(EXACT_LIST_OF_QUERY), eq(DOCUMENTATION_CITATION_CANDIDATE_LIMIT), eq(List.of(java21Constraint, java26Constraint)), anyLong()); - verify(hybridSearchService, never()) - .searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + verify(hybridSearchService, never()).search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @@ -821,17 +797,16 @@ void exactJavaSyntaxInNonJavaScopeDoesNotDispatchJavaApiCitationRetrieval() { RetrievalConstraint kotlinConstraint = RetrievalConstraint.forOfficialDocSets(List.of("kotlin")); String kotlinQuery = "How can Kotlin call java.util.List.of(E, E)?"; Document kotlinDocument = versionedDocument("kotlin-list-call", "", "kotlin-list-hash"); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), same(kotlinConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(List.of(kotlinDocument), List.of())); + when(hybridSearchService.search(anyString(), anyInt(), same(kotlinConstraint), anyLong())) + .thenReturn(List.of(kotlinDocument)); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(List.of(kotlinDocument)); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome(kotlinQuery, kotlinConstraint); + List retrievalOutcome = retrievalService.retrieve(kotlinQuery, kotlinConstraint); - assertEquals(List.of(kotlinDocument), retrievalOutcome.documents()); + assertEquals(List.of(kotlinDocument), retrievalOutcome); verify(hybridSearchService, never()) - .searchDocumentationCitationsOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + .searchDocumentationCitations(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); } @Test @@ -845,21 +820,20 @@ void virtualThreadStartChainUsesItsCanonicalBuilderDeclaration() { RetrievalConstraint java21Constraint = javaEvidenceConstraint(officialDocumentationConstraint, "21"); String chainedInvocationQuery = "Using Java 21, explain Thread.ofVirtual().start(Runnable)."; Document virtualThreadDocument = exactThreadBuilderStartDocument(); - when(hybridSearchService.searchDocumentationCitationsOutcomes( + when(hybridSearchService.searchDocumentationCitationsByConstraint( eq(chainedInvocationQuery), anyInt(), eq(List.of(java21Constraint)), anyLong())) - .thenReturn(List.of(new HybridSearchService.SearchOutcome(List.of(virtualThreadDocument), List.of()))); + .thenReturn(List.of(List.of(virtualThreadDocument))); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome(chainedInvocationQuery, officialDocumentationConstraint); + List retrievalOutcome = + retrievalService.retrieve(chainedInvocationQuery, officialDocumentationConstraint); RetrievalService.CitationOutcome citationOutcome = - retrievalService.toCitationsForQuery(chainedInvocationQuery, retrievalOutcome.documents()); + retrievalService.toCitationsForQuery(chainedInvocationQuery, retrievalOutcome); - assertEquals(List.of(virtualThreadDocument), retrievalOutcome.documents()); + assertEquals(List.of(virtualThreadDocument), retrievalOutcome); assertEquals( "start(java.lang.Runnable)", citationOutcome.citations().getFirst().getAnchor()); - verify(hybridSearchService, never()) - .searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + verify(hybridSearchService, never()).search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @@ -872,17 +846,16 @@ void unscopedExactLookingSyntaxStaysOnPrimaryHybridRetrieval() { String projectQuery = "Explain Widget.of(E, E)"; RetrievalConstraint unconstrained = RetrievalConstraint.none(); Document projectDocument = versionedDocument("project-widget", "", "project-widget-hash"); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), same(unconstrained), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(List.of(projectDocument), List.of())); + when(hybridSearchService.search(anyString(), anyInt(), same(unconstrained), anyLong())) + .thenReturn(List.of(projectDocument)); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(List.of(projectDocument)); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome(projectQuery, unconstrained); + List retrievalOutcome = retrievalService.retrieve(projectQuery, unconstrained); - assertEquals(List.of(projectDocument), retrievalOutcome.documents()); + assertEquals(List.of(projectDocument), retrievalOutcome); verify(hybridSearchService, never()) - .searchDocumentationCitationsOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + .searchDocumentationCitations(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); } @Test @@ -900,16 +873,14 @@ void unversionedExactOverloadRespectsConfiguredReturnLimit() { exactListOfOverloadDocument("java-21-exact", "21", "exact-hash-21"), exactListOfOverloadDocument("java-26-exact", "26", "exact-hash-26"), exactListOfOverloadDocument("java-25-exact", "25", "exact-hash-25")); - when(hybridSearchService.searchDocumentationCitationsOutcome( + when(hybridSearchService.searchDocumentationCitations( eq(exactQuery), anyInt(), same(officialDocumentationConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(exactOverloadDocuments, List.of())); + .thenReturn(exactOverloadDocuments); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome(exactQuery, officialDocumentationConstraint); + List retrievalOutcome = retrievalService.retrieve(exactQuery, officialDocumentationConstraint); - assertEquals(exactOverloadDocuments.subList(0, 2), retrievalOutcome.documents()); - verify(hybridSearchService, never()) - .searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + assertEquals(exactOverloadDocuments.subList(0, 2), retrievalOutcome); + verify(hybridSearchService, never()).search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @@ -924,24 +895,20 @@ void exactMultiVersionOverloadUsesAvailableEvidenceWhenOneReleaseHasNoHit() { RetrievalConstraint java21Constraint = javaEvidenceConstraint(officialDocumentationConstraint, "21"); RetrievalConstraint java26Constraint = javaEvidenceConstraint(officialDocumentationConstraint, "26"); String exactComparisonQuery = "Compare Java 21 and Java 26 for java.util.List.of(E, E)."; - when(hybridSearchService.searchDocumentationCitationsOutcomes( + when(hybridSearchService.searchDocumentationCitationsByConstraint( eq(exactComparisonQuery), eq(10), eq(List.of(java21Constraint, java26Constraint)), anyLong())) .thenReturn(List.of( - new HybridSearchService.SearchOutcome( - List.of(exactListOfOverloadDocument("java-21-exact", "21", "exact-hash-21")), - List.of()), - new HybridSearchService.SearchOutcome(List.of(), List.of()))); + List.of(exactListOfOverloadDocument("java-21-exact", "21", "exact-hash-21")), List.of())); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome(exactComparisonQuery, officialDocumentationConstraint); + List retrievalOutcome = + retrievalService.retrieve(exactComparisonQuery, officialDocumentationConstraint); assertEquals( List.of("21"), - retrievalOutcome.documents().stream() + retrievalOutcome.stream() .map(document -> document.getMetadata().get(QdrantPayloadFieldSchema.DOC_VERSION_FIELD)) .toList()); - verify(hybridSearchService, never()) - .searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + verify(hybridSearchService, never()).search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @@ -964,22 +931,20 @@ void constrainedDocumentLimitRetainsEveryRequestedRelease() { versionedDocument("java-21-d", "21", "hash-21-d"), versionedDocument("java-21-e", "21", "hash-21-e")); Document java26Document = versionedDocument("java-26", "26", "hash-26"); - when(hybridSearchService.searchOutcomes( + when(hybridSearchService.searchByConstraint( anyString(), eq(10), eq(List.of(java21Constraint, java26Constraint)), anyLong())) - .thenReturn(List.of( - new HybridSearchService.SearchOutcome(java21Documents, List.of()), - new HybridSearchService.SearchOutcome(List.of(java26Document), List.of()))); + .thenReturn(List.of(java21Documents, List.of(java26Document))); List rerankedDocuments = new java.util.ArrayList<>(java21Documents); rerankedDocuments.add(java26Document); when(rerankerService.rerank(anyString(), anyList(), eq(6), anyLong())).thenReturn(rerankedDocuments); - RetrievalService.RetrievalOutcome limitedOutcome = retrievalService.retrieveWithLimitOutcome( + List limitedOutcome = retrievalService.retrieveWithLimit( "Compare Java 21 and Java 26 collections", 3, 1_000, officialDocumentationConstraint); - assertEquals(3, limitedOutcome.documents().size()); + assertEquals(3, limitedOutcome.size()); assertEquals( List.of("21", "21", "26"), - limitedOutcome.documents().stream() + limitedOutcome.stream() .map(document -> document.getMetadata().get(QdrantPayloadFieldSchema.DOC_VERSION_FIELD)) .toList()); } @@ -1002,14 +967,14 @@ void constrainedDocumentLimitStillAppliesWithoutARequestedRelease() { versionedDocument("document-d", "", "hash-d"), versionedDocument("document-e", "", "hash-e"), versionedDocument("document-f", "", "hash-f")); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(retrievedDocuments, List.of())); + when(hybridSearchService.search(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) + .thenReturn(retrievedDocuments); when(rerankerService.rerank(anyString(), anyList(), eq(6), anyLong())).thenReturn(retrievedDocuments); - RetrievalService.RetrievalOutcome limitedOutcome = retrievalService.retrieveWithLimitOutcome( - "Explain Java strings", 3, 1_000, officialDocumentationConstraint); + List limitedOutcome = + retrievalService.retrieveWithLimit("Explain Java strings", 3, 1_000, officialDocumentationConstraint); - assertEquals(3, limitedOutcome.documents().size()); + assertEquals(3, limitedOutcome.size()); } @Test @@ -1029,7 +994,7 @@ void zeroCitationLimitReturnsWithoutDispatchingAQuery() { assertTrue(citationOutcome.citations().isEmpty()); assertEquals(0, citationOutcome.failedConversionCount()); verify(hybridSearchService, never()) - .searchDocumentationCitationsOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + .searchDocumentationCitations(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); verify(rerankerService, never()).rerank(anyString(), anyList(), anyInt(), anyLong()); } @@ -1045,7 +1010,7 @@ void citationDiscoveryPropagatesHybridFailuresWithoutRerankerFallback() { HybridSearchPartialFailureException.CollectionSearchFailure collectionFailure = new HybridSearchPartialFailureException.CollectionSearchFailure( "java-docs", "Timeout", "5s", HybridSearchPartialFailureException.FailureDisposition.TRANSIENT); - when(hybridSearchService.searchDocumentationCitationsOutcome( + when(hybridSearchService.searchDocumentationCitations( anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) .thenThrow(new HybridSearchPartialFailureException("collection failure", List.of(collectionFailure))); @@ -1075,17 +1040,16 @@ private static void assertGenericOfficialMemberQuery(String learnerQuery) { RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); RetrievalConstraint expectedScopedConstraint = defaultJavaApiBroadOfficialConstraint(); Document genericDocument = versionedDocument("generic-member", "", "generic-member-hash"); - when(hybridSearchService.searchOutcome(eq(learnerQuery), anyInt(), eq(expectedScopedConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(List.of(genericDocument), List.of())); + when(hybridSearchService.search(eq(learnerQuery), anyInt(), eq(expectedScopedConstraint), anyLong())) + .thenReturn(List.of(genericDocument)); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenReturn(List.of(genericDocument)); - RetrievalService.RetrievalOutcome retrievalOutcome = - retrievalService.retrieveOutcome(learnerQuery, officialDocumentationConstraint); + List retrievalOutcome = retrievalService.retrieve(learnerQuery, officialDocumentationConstraint); - assertEquals(List.of(genericDocument), retrievalOutcome.documents()); + assertEquals(List.of(genericDocument), retrievalOutcome); verify(hybridSearchService, never()) - .searchDocumentationCitationsOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); + .searchDocumentationCitations(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong()); } private static RetrievalConstraint defaultJavaApiBroadOfficialConstraint() { @@ -1219,37 +1183,6 @@ private static String javaApiPageUrl(String packageName, String pageFilename) { + pageFilename; } - @Test - void propagatesHybridSearchNotices() { - HybridSearchService hybridSearchService = mock(HybridSearchService.class); - RerankerService rerankerService = mock(RerankerService.class); - DocumentFactory documentFactory = mock(DocumentFactory.class); - AppProperties appProperties = new AppProperties(); - RetrievalService retrievalService = - new RetrievalService(hybridSearchService, appProperties, rerankerService, documentFactory); - - Document candidateDocument = - Document.builder().id("candidate-1").text("Stream tutorial").build(); - candidateDocument - .getMetadata() - .put(QdrantPayloadFieldSchema.URL_FIELD, "https://docs.example.com/java/streams"); - candidateDocument.getMetadata().put(QdrantPayloadFieldSchema.HASH_FIELD, "hash-1"); - - HybridSearchService.HybridSearchNotice searchNotice = - new HybridSearchService.HybridSearchNotice("Partial retrieval failure", "Timeout: java-docs"); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(List.of(candidateDocument), List.of(searchNotice))); - when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) - .thenReturn(List.of(candidateDocument)); - - RetrievalService.RetrievalOutcome retrievalOutcome = retrievalService.retrieveOutcome("Java stream basics"); - - assertEquals(1, retrievalOutcome.documents().size()); - assertEquals(1, retrievalOutcome.notices().size()); - assertEquals( - "Partial retrieval failure", retrievalOutcome.notices().get(0).summary()); - } - @Test void preservesDistinctSamePageChunksForRerankingAndRetainsDistinctAnchoredCitations() { HybridSearchService hybridSearchService = mock(HybridSearchService.class); @@ -1296,20 +1229,18 @@ void preservesDistinctSamePageChunksForRerankingAndRetainsDistinctAnchoredCitati firstJavadocChunk, secondJavadocChunkWithDistinctHash, sameContentHashWithDifferentUrl); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(retrievalCandidates, List.of())); + when(hybridSearchService.search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong())) + .thenReturn(retrievalCandidates); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenAnswer(rerankerInvocation -> { List deduplicatedCandidates = rerankerInvocation.getArgument(1); return deduplicatedCandidates; }); - RetrievalService.RetrievalOutcome retrievalOutcome = retrievalService.retrieveOutcome("Java string basics"); - RetrievalService.CitationOutcome citationOutcome = retrievalService.toCitations(retrievalOutcome.documents()); + List retrievalOutcome = retrievalService.retrieve("Java string basics"); + RetrievalService.CitationOutcome citationOutcome = retrievalService.toCitations(retrievalOutcome); - assertEquals( - List.of(urlOnlyDocument, firstJavadocChunk, secondJavadocChunkWithDistinctHash), - retrievalOutcome.documents()); + assertEquals(List.of(urlOnlyDocument, firstJavadocChunk, secondJavadocChunkWithDistinctHash), retrievalOutcome); assertEquals(3, citationOutcome.citations().size()); assertEquals(stringJavadocUrl, citationOutcome.citations().get(1).getUrl()); assertEquals("First Javadoc chunk", citationOutcome.citations().get(1).getSnippet()); @@ -1340,19 +1271,19 @@ void keepsDistinctUnmappedLocalDocumentsAndRedactsTheirCitations() { .metadata(QdrantPayloadFieldSchema.URL_FIELD, secondUnmappedLocalUrl) .build(); List retrievalCandidates = List.of(firstUnmappedLocalDocument, secondUnmappedLocalDocument); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(retrievalCandidates, List.of())); + when(hybridSearchService.search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong())) + .thenReturn(retrievalCandidates); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenAnswer(rerankerInvocation -> { List deduplicatedCandidates = rerankerInvocation.getArgument(1); return deduplicatedCandidates; }); - RetrievalService.RetrievalOutcome retrievalOutcome = retrievalService.retrieveOutcome("Local documentation"); - RetrievalService.CitationOutcome citationOutcome = retrievalService.toCitations(retrievalOutcome.documents()); + List retrievalOutcome = retrievalService.retrieve("Local documentation"); + RetrievalService.CitationOutcome citationOutcome = retrievalService.toCitations(retrievalOutcome); String redactedLocalCitationUrl = DocsSourceRegistry.normalizeDocUrl(firstUnmappedLocalUrl); - assertEquals(retrievalCandidates, retrievalOutcome.documents()); + assertEquals(retrievalCandidates, retrievalOutcome); assertEquals(2, citationOutcome.citations().size()); assertEquals( List.of(redactedLocalCitationUrl, redactedLocalCitationUrl), @@ -1377,12 +1308,10 @@ void propagatesStrictHybridSearchFailure() { HybridSearchPartialFailureException.CollectionSearchFailure collectionFailure = new HybridSearchPartialFailureException.CollectionSearchFailure( "java-docs", "Timeout", "5s", HybridSearchPartialFailureException.FailureDisposition.TRANSIENT); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong())) + when(hybridSearchService.search(anyString(), anyInt(), any(RetrievalConstraint.class), anyLong())) .thenThrow(new HybridSearchPartialFailureException("collection failure", List.of(collectionFailure))); - assertThrows( - HybridSearchPartialFailureException.class, - () -> retrievalService.retrieveOutcome("Java stream basics")); + assertThrows(HybridSearchPartialFailureException.class, () -> retrievalService.retrieve("Java stream basics")); } @Test @@ -1397,10 +1326,10 @@ void sharesOneStageDeadlineAcrossSearchAndRerankHops() { Document stageDeadlineDocument = versionedDocument("stage-deadline", "", "stage-deadline-hash"); long testStartNanos = System.nanoTime(); AtomicLong capturedStageDeadlineNanos = new AtomicLong(); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) + when(hybridSearchService.search(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) .thenAnswer(searchInvocation -> { capturedStageDeadlineNanos.set(searchInvocation.getArgument(3)); - return new HybridSearchService.SearchOutcome(List.of(stageDeadlineDocument), List.of()); + return List.of(stageDeadlineDocument); }); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenAnswer(rerankerInvocation -> { @@ -1408,7 +1337,7 @@ void sharesOneStageDeadlineAcrossSearchAndRerankHops() { return List.of(stageDeadlineDocument); }); - retrievalService.retrieveOutcome("Java records", guidedConstraint); + retrievalService.retrieve("Java records", guidedConstraint); long stageBudgetElapsedNanos = capturedStageDeadlineNanos.get() - testStartNanos; assertTrue(stageBudgetElapsedNanos > 0); @@ -1429,8 +1358,8 @@ void rejectsRerankerOutcomeThatReturnsAfterTheCallerDeadline() { RetrievalConstraint.forOfficialDocSets(OFFICIAL_DOCUMENTATION_SOURCE_IDENTITIES); RetrievalConstraint expectedScopedConstraint = defaultJavaApiBroadOfficialConstraint(); Document deadlineDocument = versionedDocument("deadline-expired", "", "deadline-expired-hash"); - when(hybridSearchService.searchOutcome(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) - .thenReturn(new HybridSearchService.SearchOutcome(List.of(deadlineDocument), List.of())); + when(hybridSearchService.search(anyString(), anyInt(), eq(expectedScopedConstraint), anyLong())) + .thenReturn(List.of(deadlineDocument)); when(rerankerService.rerank(anyString(), anyList(), anyInt(), anyLong())) .thenAnswer(rerankerInvocation -> { long callerDeadlineNanos = rerankerInvocation.getArgument(3); diff --git a/src/test/java/com/williamcallahan/javachat/web/ChatControllerRetrievalDiagnosticsTest.java b/src/test/java/com/williamcallahan/javachat/web/ChatControllerRetrievalDiagnosticsTest.java index c0c5a2b7..13fd1426 100644 --- a/src/test/java/com/williamcallahan/javachat/web/ChatControllerRetrievalDiagnosticsTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/ChatControllerRetrievalDiagnosticsTest.java @@ -32,7 +32,7 @@ void retrievalDiagnosticsUsesOfficialDocumentationPromptContext() { RetrievalService retrievalService = mock(RetrievalService.class); Document officialPromptDocument = mock(Document.class); when(chatService.retrieveTokenConstrainedOfficialDocumentation(EXACT_OVERLOAD_QUERY)) - .thenReturn(new RetrievalService.RetrievalOutcome(List.of(officialPromptDocument), List.of())); + .thenReturn(List.of(officialPromptDocument)); Citation exactOverloadCitation = new Citation(JAVA_21_LIST_URL, "List (Java SE 21 & JDK 21)", EXACT_OVERLOAD_ANCHOR, "Exact overload"); when(retrievalService.toCitationsForQuery(EXACT_OVERLOAD_QUERY, List.of(officialPromptDocument))) diff --git a/src/test/java/com/williamcallahan/javachat/web/ChatControllerStreamingFailureTest.java b/src/test/java/com/williamcallahan/javachat/web/ChatControllerStreamingFailureTest.java index 406b88b9..5681f9cc 100644 --- a/src/test/java/com/williamcallahan/javachat/web/ChatControllerStreamingFailureTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/ChatControllerStreamingFailureTest.java @@ -421,8 +421,8 @@ void streamSubscriptionReturnsAfterPreparationStatusWhileRetrievalIsBlocked() } }); when(chatService.buildStructuredPromptWithContextOutcome(anyList(), eq(USER_QUERY), any(), anyLong())) - .thenReturn(new ChatService.StructuredPromptOutcome( - StructuredPrompt.fromRawPrompt("test", 1), List.of(), List.of())); + .thenReturn( + new ChatService.StructuredPromptOutcome(StructuredPrompt.fromRawPrompt("test", 1), List.of())); when(streamingService.streamResponse(any(StructuredPrompt.class), anyDouble())) .thenReturn(Mono.never()); @@ -600,9 +600,7 @@ void chatStreamCitesTheExactDocumentsUsedForPromptContextWithoutSecondDiscovery( "Creates an unmodifiable list."); when(chatMemoryService.getHistory(SESSION_ID)).thenReturn(List.of()); ChatService.StructuredPromptOutcome promptOutcome = new ChatService.StructuredPromptOutcome( - StructuredPrompt.fromRawPrompt("test", 1), - List.of(), - List.of(officialPromptDocument, truncatedPromptDocument)); + StructuredPrompt.fromRawPrompt("test", 1), List.of(officialPromptDocument, truncatedPromptDocument)); when(chatService.buildStructuredPromptWithContextOutcome(anyList(), eq(USER_QUERY), any(), anyLong())) .thenReturn(promptOutcome); when(chatService.citationOutcomeForRetainedContext( @@ -660,7 +658,7 @@ void chatStreamEmitsExactOverloadSelectionFromPromptContext() throws JsonProcess "Creates an unmodifiable list containing two elements."); when(chatMemoryService.getHistory(SESSION_ID)).thenReturn(List.of()); ChatService.StructuredPromptOutcome promptOutcome = new ChatService.StructuredPromptOutcome( - StructuredPrompt.fromRawPrompt("test", 1), List.of(), List.of(broadPromptDocument)); + StructuredPrompt.fromRawPrompt("test", 1), List.of(broadPromptDocument)); when(chatService.buildStructuredPromptWithContextOutcome(anyList(), eq(exactOverloadQuery), any(), anyLong())) .thenReturn(promptOutcome); when(chatService.citationOutcomeForRetainedContext( @@ -727,8 +725,8 @@ void chatStreamSurfacesNonzeroCitationFailuresBeforeCitationEvent() throws JsonP new AppProperties()); when(chatMemoryService.getHistory(SESSION_ID)).thenReturn(List.of()); when(chatService.buildStructuredPromptWithContextOutcome(anyList(), eq(USER_QUERY), any(), anyLong())) - .thenReturn(new ChatService.StructuredPromptOutcome( - StructuredPrompt.fromRawPrompt("test", 1), List.of(), List.of())); + .thenReturn( + new ChatService.StructuredPromptOutcome(StructuredPrompt.fromRawPrompt("test", 1), List.of())); when(streamingService.canAttemptRequest()).thenReturn(true); when(streamingService.isAvailable()).thenReturn(true); when(chatService.citationOutcomeForRetainedContext( @@ -790,8 +788,8 @@ void streamBufferOverflowDoesNotPersistPartialChatAnswer() throws JsonProcessing when(chatMemoryService.getHistory(SESSION_ID)).thenReturn(List.of()); when(chatService.buildStructuredPromptWithContextOutcome(anyList(), eq(USER_QUERY), any(), anyLong())) - .thenReturn(new ChatService.StructuredPromptOutcome( - StructuredPrompt.fromRawPrompt("test", 1), List.of(), List.of())); + .thenReturn( + new ChatService.StructuredPromptOutcome(StructuredPrompt.fromRawPrompt("test", 1), List.of())); when(streamingService.canAttemptRequest()).thenReturn(true); when(streamingService.isAvailable()).thenReturn(true); when(chatService.citationOutcomeForRetainedContext( @@ -842,8 +840,8 @@ private List> streamFailure( when(chatMemoryService.getHistory(SESSION_ID)).thenReturn(List.of()); when(chatService.buildStructuredPromptWithContextOutcome(anyList(), eq(USER_QUERY), any(), anyLong())) - .thenReturn(new ChatService.StructuredPromptOutcome( - StructuredPrompt.fromRawPrompt("test", 1), List.of(), List.of())); + .thenReturn( + new ChatService.StructuredPromptOutcome(StructuredPrompt.fromRawPrompt("test", 1), List.of())); when(streamingService.canAttemptRequest()).thenReturn(true); when(streamingService.isAvailable()).thenReturn(true); when(chatService.citationOutcomeForRetainedContext( diff --git a/src/test/java/com/williamcallahan/javachat/web/ChatPreparationSseIntegrationTest.java b/src/test/java/com/williamcallahan/javachat/web/ChatPreparationSseIntegrationTest.java index 130a4d4d..298254bb 100644 --- a/src/test/java/com/williamcallahan/javachat/web/ChatPreparationSseIntegrationTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/ChatPreparationSseIntegrationTest.java @@ -125,8 +125,8 @@ void flushesPreparationEventWhileRetrievalRemainsBlocked() throws JsonProcessing } }); when(chatService.buildStructuredPromptWithContextOutcome(anyList(), eq(USER_QUERY), any(), anyLong())) - .thenReturn(new ChatService.StructuredPromptOutcome( - StructuredPrompt.fromRawPrompt("test", 1), List.of(), List.of())); + .thenReturn( + new ChatService.StructuredPromptOutcome(StructuredPrompt.fromRawPrompt("test", 1), List.of())); when(chatService.citationOutcomeForRetainedContext( eq(USER_QUERY), any(ChatService.StructuredPromptOutcome.class), anyList())) .thenReturn(new RetrievalService.CitationOutcome(List.of(), 0)); diff --git a/src/test/java/com/williamcallahan/javachat/web/ContactForwardedHeaderIntegrationTest.java b/src/test/java/com/williamcallahan/javachat/web/ContactForwardedHeaderIntegrationTest.java new file mode 100644 index 00000000..0558ee65 --- /dev/null +++ b/src/test/java/com/williamcallahan/javachat/web/ContactForwardedHeaderIntegrationTest.java @@ -0,0 +1,161 @@ +package com.williamcallahan.javachat.web; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +import com.williamcallahan.javachat.service.EmbeddingClient; +import io.qdrant.client.QdrantClient; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Verifies contact rate limiting uses Tomcat's trusted forwarded-header boundary. */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Import(ContactForwardedHeaderIntegrationTest.RequestOriginConfiguration.class) +class ContactForwardedHeaderIntegrationTest { + private static final String CSRF_REFRESH_ENDPOINT = "/api/security/csrf"; + private static final String CONTACT_ENDPOINT = "/api/contact"; + private static final String REQUEST_ORIGIN_ENDPOINT = "/test/request-origin"; + private static final String CSRF_COOKIE_NAME = "XSRF-TOKEN"; + private static final String CSRF_HEADER_NAME = "X-XSRF-TOKEN"; + private static final String VERIFIED_CLIENT_ADDRESS = "198.51.100.20"; + private static final int ACCEPTED_SUBMISSION_LIMIT = 3; + private static final int FORWARDED_SERVER_PORT = 8443; + private static final long LEGITIMATE_RENDER_AGE_MILLIS = 10_000L; + + @Autowired + WebTestClient webTestClient; + + @MockitoBean + JavaMailSender javaMailSender; + + @MockitoBean(answers = Answers.RETURNS_MOCKS) + EmbeddingClient embeddingClient; + + @MockitoBean + QdrantClient qdrantClient; + + @BeforeEach + void stubMimeMessageCreation() { + when(javaMailSender.createMimeMessage()).thenAnswer(invocation -> new MimeMessage((Session) null)); + } + + @Test + void farLeftSpoofRotationCannotSelectNewRateLimitBuckets() { + ResponseCookie csrfCookie = requestCsrfCookie(); + for (int submissionIndex = 0; submissionIndex < ACCEPTED_SUBMISSION_LIMIT; submissionIndex++) { + submitContact( + csrfCookie, + "203.0.113." + submissionIndex + ", " + VERIFIED_CLIENT_ADDRESS, + "Message " + submissionIndex) + .expectStatus() + .isAccepted(); + } + + submitContact(csrfCookie, "203.0.113.99, " + VERIFIED_CLIENT_ADDRESS, "Rate limited") + .expectStatus() + .isEqualTo(HttpStatus.TOO_MANY_REQUESTS); + submitContact(csrfCookie, "203.0.113.99, 198.51.100.21", "Independent client") + .expectStatus() + .isAccepted(); + } + + @Test + void nativeForwardingPreservesVerifiedOriginFields() { + webTestClient + .get() + .uri(REQUEST_ORIGIN_ENDPOINT) + .header("X-Forwarded-For", VERIFIED_CLIENT_ADDRESS) + .header("X-Forwarded-Proto", "https, https") + .header("X-Forwarded-Port", Integer.toString(FORWARDED_SERVER_PORT)) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.remoteAddress") + .isEqualTo(VERIFIED_CLIENT_ADDRESS) + .jsonPath("$.scheme") + .isEqualTo("https") + .jsonPath("$.secure") + .isEqualTo(true) + .jsonPath("$.serverPort") + .isEqualTo(FORWARDED_SERVER_PORT); + } + + private WebTestClient.ResponseSpec submitContact(ResponseCookie csrfCookie, String forwardedFor, String message) { + return webTestClient + .post() + .uri(CONTACT_ENDPOINT) + .cookie(CSRF_COOKIE_NAME, csrfCookie.getValue()) + .header(CSRF_HEADER_NAME, csrfCookie.getValue()) + .header("X-Forwarded-For", forwardedFor) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(submissionJson(message)) + .exchange(); + } + + private ResponseCookie requestCsrfCookie() { + EntityExchangeResult csrfExchange = webTestClient + .get() + .uri(CSRF_REFRESH_ENDPOINT) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .returnResult(); + ResponseCookie csrfCookie = csrfExchange.getResponseCookies().getFirst(CSRF_COOKIE_NAME); + assertNotNull(csrfCookie); + return csrfCookie; + } + + private static String submissionJson(String message) { + return "{\"name\":\"Proxy Test\",\"email\":\"proxy@example.test\",\"message\":\"%s\",\"website\":\"\",\"renderedAt\":%d}" + .formatted(message, System.currentTimeMillis() - LEGITIMATE_RENDER_AGE_MILLIS); + } + + /** Registers the request-origin probe only inside this embedded-server test. */ + @TestConfiguration(proxyBeanMethods = false) + static class RequestOriginConfiguration { + @Bean + RequestOriginController requestOriginController() { + return new RequestOriginController(); + } + } + + /** Exposes servlet request fields after the real Tomcat valve runs. */ + @RestController + static class RequestOriginController { + @GetMapping(REQUEST_ORIGIN_ENDPOINT) + RequestOrigin requestOrigin(HttpServletRequest servletRequest) { + return new RequestOrigin( + servletRequest.getRemoteAddr(), + servletRequest.getScheme(), + servletRequest.isSecure(), + servletRequest.getServerPort()); + } + } + + /** Captures the origin fields presented to application controllers. */ + record RequestOrigin(String remoteAddress, String scheme, boolean secure, int serverPort) {} +} From 7a3dc9962d8c348aeb2634408380b2ddd04fd06e Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 19:26:41 -0700 Subject: [PATCH 24/29] fix(chat): make source-gap notes deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt-only source-gap guidance omitted the note nondeterministically when the model had no retained documentation context. Append the shared note exactly once at the SSE boundary based on retained provider context so ordinary chat, guided lessons, the web client, and the CLI receive the same provenance signal. - stop asking the model to author the source-availability label - append the shared general-knowledge note for empty retained context - preserve complete current, historical, and legacy source labels across stream chunks Validation receipts on final code bytes: - `make test`: 1,134/1,134 tests passed on the substantive append path - targeted `SystemPromptConfigTest` and `SseSupportTest`: 22/22 passed after final matcher refinement - `make lint`: passed on final bytes - `git diff --check`: passed ADVERSARIAL: standard — independent reviewers plus root reconciliation — 7 refuted/fixed, 0 retained, 0 added — dispositions: 7 fixed, 0 filed, 0 user-decision --- .../javachat/config/SystemPromptConfig.java | 2 +- .../javachat/web/ChatController.java | 12 ++-- .../web/GuidedLearningController.java | 7 ++- .../javachat/web/SseSupport.java | 55 +++++++++++++++++++ .../config/SystemPromptConfigTest.java | 3 +- .../javachat/web/SseSupportTest.java | 44 +++++++++++++++ 6 files changed, 113 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/config/SystemPromptConfig.java b/src/main/java/com/williamcallahan/javachat/config/SystemPromptConfig.java index ae0d0d9e..a1a13a30 100644 --- a/src/main/java/com/williamcallahan/javachat/config/SystemPromptConfig.java +++ b/src/main/java/com/williamcallahan/javachat/config/SystemPromptConfig.java @@ -31,7 +31,7 @@ public class SystemPromptConfig { static final String GENERATED_CONTROL_FLOW_CLAUSE = "Preserve the behavior described by every generated code example. In per-candidate processing, record exactly one terminal outcome for each candidate. Rejected, deferred, and failed branches must exit or otherwise prevent fall-through to success, and computed outcomes must never be ignored. For Kotlin `runCatching`, account explicitly for the value returned by `getOrElse` before recording success."; static final String SOURCE_FIDELITY_CLAUSE = - "Treat a material factual claim as retrieval-grounded when a provided [CTX n] SOURCE RECORD covers the claimed library or source family at the requested major version, or when it is explicitly labeled as adjacent same-family evidence for a missing requested version. Name that record's exact version inline with the claim, for example `per Jackson 2.22.2`, on every grounded claim; when it differs from the version the reader asked about, name both. Never substitute another dependency or related project, and never merge SOURCE RECORDS from different major versions into one claim: attribute each major separately and state how they differ. When an exact requested version is unavailable, answer using provided nearest lower and higher records from the same source family, name the requested and evidence versions, and explain any documented difference across that span; outside the indexed range, use the nearest available same-family version. When a SOURCE RECORD covering the span between the requested and retrieved versions documents a behavior change, state that change and the release that introduced it. Use clearly labeled general knowledge for the uncovered version delta. Never refuse an in-scope question solely because exact-version documentation is absent: missing evidence changes provenance, not answer availability. If no SOURCE RECORD covers the claimed dependency or source family at the requested major or an adjacent same-family version, answer the question as well as possible using general knowledge. Then write `Note: Matching source documents for were not available to JavaChat, so this answer uses the model's general knowledge.` Never imply that the Sources list verifies the general-knowledge answer."; + "Treat a material factual claim as retrieval-grounded when a provided [CTX n] SOURCE RECORD covers the claimed library or source family at the requested major version, or when it is explicitly labeled as adjacent same-family evidence for a missing requested version. Name that record's exact version inline with the claim, for example `per Jackson 2.22.2`, on every grounded claim; when it differs from the version the reader asked about, name both. Never substitute another dependency or related project, and never merge SOURCE RECORDS from different major versions into one claim: attribute each major separately and state how they differ. When an exact requested version is unavailable, answer using provided nearest lower and higher records from the same source family, name the requested and evidence versions, and explain any documented difference across that span; outside the indexed range, use the nearest available same-family version. When a SOURCE RECORD covering the span between the requested and retrieved versions documents a behavior change, state that change and the release that introduced it. Use clearly labeled general knowledge for the uncovered version delta. Never refuse an in-scope question solely because exact-version documentation is absent: missing evidence changes provenance, not answer availability. If no SOURCE RECORD covers the claimed dependency or source family at the requested major or an adjacent same-family version, answer the question as well as possible using general knowledge. Do not write a source-availability label; the client appends that note after the answer. Never imply that the Sources list verifies the general-knowledge answer."; private static final String MARKER_USAGE_PROMPT = """ - {{hint:Text here}} (Helpful Hints) - {{background:Text here}} (Background Context) diff --git a/src/main/java/com/williamcallahan/javachat/web/ChatController.java b/src/main/java/com/williamcallahan/javachat/web/ChatController.java index 29b30c61..4d4a23b5 100644 --- a/src/main/java/com/williamcallahan/javachat/web/ChatController.java +++ b/src/main/java/com/williamcallahan/javachat/web/ChatController.java @@ -192,11 +192,13 @@ public Flux> stream( // Stream with structure-aware truncation - preserves semantic // boundaries - Flux dataStream = - sseSupport.prepareDataStream(streamingResult.textChunks(), chunk -> { - fullResponse.append(chunk); - chunkCount.incrementAndGet(); - }); + Flux answerChunks = sseSupport.appendSourceAvailabilityNote( + streamingResult.textChunks(), + !streamingResult.contextDocumentIds().isEmpty()); + Flux dataStream = sseSupport.prepareDataStream(answerChunks, chunk -> { + fullResponse.append(chunk); + chunkCount.incrementAndGet(); + }); // Surface retrieval and citation-conversion notices before response // content. diff --git a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java index 3565b608..326da279 100644 --- a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java +++ b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java @@ -351,8 +351,11 @@ private Flux> streamGuidedResponse( ServerSentEvent providerEvent = sseSupport.providerEvent(streamingResult.providerDisplayName()); - Flux dataStream = sseSupport.prepareDataStream( - streamingResult.textChunks(), fullResponse::append); + Flux answerChunks = sseSupport.appendSourceAvailabilityNote( + streamingResult.textChunks(), + !streamingResult.contextDocumentIds().isEmpty()); + Flux dataStream = + sseSupport.prepareDataStream(answerChunks, fullResponse::append); Flux> dataEvents = dataStream.map(sseSupport::textEvent); Flux> citationEvent = diff --git a/src/main/java/com/williamcallahan/javachat/web/SseSupport.java b/src/main/java/com/williamcallahan/javachat/web/SseSupport.java index e277703c..c16921ec 100644 --- a/src/main/java/com/williamcallahan/javachat/web/SseSupport.java +++ b/src/main/java/com/williamcallahan/javachat/web/SseSupport.java @@ -16,6 +16,7 @@ import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; import org.slf4j.Logger; @@ -38,6 +39,13 @@ @Component public class SseSupport { private static final Logger log = LoggerFactory.getLogger(SseSupport.class); + private static final String SOURCE_AVAILABILITY_NOTE = + "Note: Matching source documents were not available to JavaChat for this question, so this answer uses the model's general knowledge."; + private static final String PREVIOUS_SOURCE_AVAILABILITY_SUFFIX = + "were not available to JavaChat, so this answer uses the model's general knowledge."; + private static final String LEGACY_SOURCE_AVAILABILITY_START = "Source unavailable:"; + private static final String LEGACY_SOURCE_AVAILABILITY_PARAGRAPH = "\n\n" + LEGACY_SOURCE_AVAILABILITY_START; + static final String GENERAL_KNOWLEDGE_SOURCE_NOTE = "\n\n" + SOURCE_AVAILABILITY_NOTE; private static final String RESPONSE_PREPARATION_MESSAGE = "Preparing your response"; private static final String RESPONSE_PREPARATION_DETAILS = "Finding relevant Java documentation."; @@ -138,6 +146,53 @@ public Flux prepareDataStream(Flux source, Consumer chun .doOnNext(chunk -> chunkConsumer.accept(chunk)); } + /** + * Appends the shared source-availability note when the provider retained no source documents. + * + * @param answerChunks model answer chunks + * @param sourceDocumentsAvailable whether provider context retained at least one source document + * @return answer chunks with the deterministic general-knowledge note when needed + */ + Flux appendSourceAvailabilityNote(Flux answerChunks, boolean sourceDocumentsAvailable) { + Objects.requireNonNull(answerChunks, "answerChunks"); + if (sourceDocumentsAvailable) { + return answerChunks; + } + return Flux.defer(() -> { + AtomicBoolean sourceAvailabilityStated = new AtomicBoolean(); + StringBuilder sourceLabelTail = new StringBuilder(); + StringBuilder answerStart = new StringBuilder(); + int sourceLabelTailLength = Math.max( + Math.max(SOURCE_AVAILABILITY_NOTE.length(), PREVIOUS_SOURCE_AVAILABILITY_SUFFIX.length()), + LEGACY_SOURCE_AVAILABILITY_PARAGRAPH.length()) + - 1; + return answerChunks + .doOnNext(chunk -> { + if (chunk.isEmpty()) { + return; + } + if (!sourceAvailabilityStated.get()) { + if (answerStart.length() < LEGACY_SOURCE_AVAILABILITY_START.length()) { + int remainingStartLength = + LEGACY_SOURCE_AVAILABILITY_START.length() - answerStart.length(); + answerStart.append(chunk, 0, Math.min(chunk.length(), remainingStartLength)); + } + sourceLabelTail.append(chunk); + sourceAvailabilityStated.set(answerStart.toString().equals(LEGACY_SOURCE_AVAILABILITY_START) + || sourceLabelTail.indexOf(SOURCE_AVAILABILITY_NOTE) >= 0 + || sourceLabelTail.indexOf(PREVIOUS_SOURCE_AVAILABILITY_SUFFIX) >= 0 + || sourceLabelTail.indexOf(LEGACY_SOURCE_AVAILABILITY_PARAGRAPH) >= 0); + if (sourceLabelTail.length() > sourceLabelTailLength) { + sourceLabelTail.delete(0, sourceLabelTail.length() - sourceLabelTailLength); + } + } + }) + .concatWith(Flux.defer(() -> !sourceLabelTail.isEmpty() && !sourceAvailabilityStated.get() + ? Flux.just(GENERAL_KNOWLEDGE_SOURCE_NOTE) + : Flux.empty())); + }); + } + /** * Serializes an object to JSON for SSE data payloads. * diff --git a/src/test/java/com/williamcallahan/javachat/config/SystemPromptConfigTest.java b/src/test/java/com/williamcallahan/javachat/config/SystemPromptConfigTest.java index 97392010..162417ed 100644 --- a/src/test/java/com/williamcallahan/javachat/config/SystemPromptConfigTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/SystemPromptConfigTest.java @@ -81,10 +81,9 @@ void shouldAnswerUnsupportedClaimsWithClearlyLabeledGeneralKnowledge() { () -> assertTrue(corePrompt.contains("Never substitute another dependency or related project")), () -> assertTrue(corePrompt.contains("missing evidence changes provenance, not answer availability")), () -> assertTrue(corePrompt.contains("Never refuse an in-scope question")), - () -> assertTrue(corePrompt.contains( - "Matching source documents for were not available to JavaChat")), () -> assertTrue( corePrompt.contains("answer the question as well as possible using general knowledge")), + () -> assertTrue(corePrompt.contains("Do not write a source-availability label")), () -> assertTrue(corePrompt.contains("Never imply that the Sources list verifies")), () -> assertFalse(corePrompt.contains("Source unavailable:"))); } diff --git a/src/test/java/com/williamcallahan/javachat/web/SseSupportTest.java b/src/test/java/com/williamcallahan/javachat/web/SseSupportTest.java index 241f9675..2bf966ea 100644 --- a/src/test/java/com/williamcallahan/javachat/web/SseSupportTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/SseSupportTest.java @@ -69,6 +69,50 @@ void prepareDataStreamPublishesEveryChunkOnce() { assertEquals(expectedContent, consumedContent, "Chunk consumer should observe full stream content"); } + @Test + void sourceAvailabilityNoteDependsOnRetainedProviderContext() { + SseSupport sseSupport = createSseSupport(); + Flux answerChunks = Flux.just("The answer."); + + StepVerifier.create(sseSupport.appendSourceAvailabilityNote(answerChunks, false)) + .expectNext("The answer.", SseSupport.GENERAL_KNOWLEDGE_SOURCE_NOTE) + .verifyComplete(); + StepVerifier.create(sseSupport.appendSourceAvailabilityNote(answerChunks, true)) + .expectNext("The answer.") + .verifyComplete(); + StepVerifier.create(sseSupport.appendSourceAvailabilityNote( + Flux.just( + "Answer.\n\nNote: Matching source documents were not available to JavaChat for ", + "this question, so this answer uses the model's general knowledge."), + false)) + .expectNext( + "Answer.\n\nNote: Matching source documents were not available to JavaChat for ", + "this question, so this answer uses the model's general knowledge.") + .verifyComplete(); + StepVerifier.create(sseSupport.appendSourceAvailabilityNote( + Flux.just( + "Answer.\n\nNote: Matching source documents for Rust 1.99 were not available ", + "to JavaChat, so this answer uses the model's general knowledge."), + false)) + .expectNext( + "Answer.\n\nNote: Matching source documents for Rust 1.99 were not available ", + "to JavaChat, so this answer uses the model's general knowledge.") + .verifyComplete(); + StepVerifier.create(sseSupport.appendSourceAvailabilityNote( + Flux.just("Source unav", "ailable: Rust 1.99\n\nAnswer."), false)) + .expectNext("Source unav", "ailable: Rust 1.99\n\nAnswer.") + .verifyComplete(); + StepVerifier.create(sseSupport.appendSourceAvailabilityNote( + Flux.just("The UI may say `Note: Matching source documents were not available to JavaChat`."), + false)) + .expectNext( + "The UI may say `Note: Matching source documents were not available to JavaChat`.", + SseSupport.GENERAL_KNOWLEDGE_SOURCE_NOTE) + .verifyComplete(); + StepVerifier.create(sseSupport.appendSourceAvailabilityNote(Flux.empty(), false)) + .verifyComplete(); + } + @Test void prepareDataStreamTerminatesWithOverflowInsteadOfDroppingCoalescedChunks() { SseSupport sseSupport = createSseSupport(); From bdc7b282b63b28188f5b99f4586aa5110b661f66 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Tue, 1 Sep 2026 20:04:47 -0700 Subject: [PATCH 25/29] fix(lint): name Clicky initializer cleanup precisely The previous private method name triggered the zero-legacy lint rule even though the method removes inline scripts rather than preserving old code. - Rename the method to describe the inline initializer it removes --- .../williamcallahan/javachat/web/ClickyAnalyticsInjector.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java b/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java index a3ca10a9..12471158 100644 --- a/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java +++ b/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java @@ -46,7 +46,7 @@ public ClickyAnalyticsInjector(AppProperties appProperties) { * @param document the Jsoup document whose {@code } will be modified in place */ public void applyTo(Document document) { - removeLegacyInitializers(document); + removeInlineInitializers(document); Element existingClickyLoader = document.head().selectFirst("script[src=\"" + CLICKY_SCRIPT_URL + "\"]"); if (!clickyEnabled) { @@ -68,7 +68,7 @@ public void applyTo(Document document) { .attr("src", CLICKY_SCRIPT_URL); } - private void removeLegacyInitializers(Document document) { + private void removeInlineInitializers(Document document) { document.head().select("script").forEach(scriptTag -> { if (scriptTag.html().contains("clicky_site_ids")) { scriptTag.remove(); From 36a16edd64d9b8f8cbde5260c76a06a840f07291 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Thu, 10 Sep 2026 12:27:40 -0700 Subject: [PATCH 26/29] docs(skills): shorten JavaChat selection description State the documentation questions that need retrieved JavaChat citations within the 250-character description limit. --- skills/javachat/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/javachat/SKILL.md b/skills/javachat/SKILL.md index 65c633da..9fab6692 100644 --- a/skills/javachat/SKILL.md +++ b/skills/javachat/SKILL.md @@ -1,6 +1,6 @@ --- name: javachat -description: "Answer software and platform documentation questions with the JavaChat CLI and retrieved citations. Use for Java, Kotlin, Docker, Docker Swarm, Dokploy, Clerk, Spring, indexed GitHub repositories, or any question that should be checked against JavaChat's current knowledge inventory." +description: "Use the JavaChat CLI to answer software and platform documentation questions with retrieved citations. Use it when an answer depends on a package version, indexed repository, command syntax, or deployment behavior." --- # JavaChat documentation From 2fa7f694fe3e40b13130fbac545d1b49acab6024 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 2 Oct 2026 11:16:29 -0700 Subject: [PATCH 27/29] ci: publish multi-platform image and deploy its digest to Dokploy Dokploy Git builds emit only the build host's architecture, and production moves to an ARM64 host. Each push to dev or main now builds linux/amd64 and linux/arm64 natively, joins them under one index, and points the environment's Dokploy application at that digest. --- .github/workflows/build.yml | 127 ++++++++++++++++++++++++++++++++ AGENTS.md | 4 +- scripts/deploy_dokploy_image.sh | 86 +++++++++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100755 scripts/deploy_dokploy_image.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 911833b4..24433c33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,6 +15,10 @@ on: permissions: contents: read +env: + IMAGE_REGISTRY: registry.haiku.host + IMAGE_REPOSITORY: registry.haiku.host/java-chat/java-chat + jobs: writer-lease-portability: runs-on: macos-15 @@ -309,3 +313,126 @@ jobs: --project-name java-chat-ci \ --file infra/docker-compose-qdrant.yml \ down --volumes + + # One multi-platform image per environment: each platform builds natively, + # pushes by digest, and the manifest job joins the digests under one index. + image: + if: github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'main') + needs: + - docker-smoke + environment: ${{ github.ref_name == 'main' && 'production' || 'development' }} + strategy: + fail-fast: true + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to the image registry + uses: docker/login-action@v4 + with: + registry: ${{ env.IMAGE_REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push platform image by digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + platforms: ${{ matrix.platform }} + build-args: | + SOURCE_COMMIT=${{ github.sha }} + VITE_CLERK_PUBLISHABLE_KEY=${{ vars.VITE_CLERK_PUBLISHABLE_KEY }} + outputs: type=image,name=${{ env.IMAGE_REPOSITORY }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ github.ref_name }}-${{ matrix.runner }} + cache-to: type=gha,mode=max,scope=${{ github.ref_name }}-${{ matrix.runner }} + + - name: Record platform digest + env: + PLATFORM_DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p "${RUNNER_TEMP}/digests" + touch "${RUNNER_TEMP}/digests/${PLATFORM_DIGEST#sha256:}" + + - name: Upload platform digest + uses: actions/upload-artifact@v6 + with: + name: image-digest-${{ matrix.runner }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + deploy: + needs: + - image + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: + name: ${{ github.ref_name == 'main' && 'production' || 'development' }} + url: ${{ github.ref_name == 'main' && 'https://javachat.ai' || 'https://dev.javachat.ai' }} + concurrency: + group: dokploy-${{ github.ref_name }} + cancel-in-progress: false + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Download platform digests + uses: actions/download-artifact@v8 + with: + path: ${{ runner.temp }}/digests + pattern: image-digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to the image registry + uses: docker/login-action@v4 + with: + registry: ${{ env.IMAGE_REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Publish multi-platform image index + id: index + working-directory: ${{ runner.temp }}/digests + env: + IMAGE_TAG: ${{ env.IMAGE_REPOSITORY }}:${{ github.ref_name }}-${{ github.sha }} + run: | + set -euo pipefail + platform_images=() + for platform_digest in *; do + platform_images+=("${IMAGE_REPOSITORY}@sha256:${platform_digest}") + done + docker buildx imagetools create --tag "$IMAGE_TAG" "${platform_images[@]}" + docker buildx imagetools inspect "$IMAGE_TAG" + index_digest="$(docker buildx imagetools inspect "$IMAGE_TAG" --format '{{json .Manifest}}' | jq --exit-status --raw-output .digest)" + printf 'reference=%s@%s\n' "$IMAGE_REPOSITORY" "$index_digest" >>"$GITHUB_OUTPUT" + + - name: Deploy image digest to Dokploy + env: + DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }} + DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} + DOKPLOY_APPLICATION_ID: ${{ vars.DOKPLOY_APPLICATION_ID }} + IMAGE_REFERENCE: ${{ steps.index.outputs.reference }} + SOURCE_COMMIT: ${{ github.sha }} + PUBLIC_URL: ${{ github.ref_name == 'main' && 'https://javachat.ai' || 'https://dev.javachat.ai' }} + run: scripts/deploy_dokploy_image.sh diff --git a/AGENTS.md b/AGENTS.md index 6d7ea322..656d4b4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,10 +78,10 @@ alwaysApply: true ## [DP1] Deployment Ownership - [DP1a] **Dokploy Is The Sole Deployment Owner**: Deploy Java Chat only through the existing Dokploy applications. Never create or retain a Coolify application, service, container, webhook, or deploy hook for this repository. Never invoke Coolify to deploy, start, or restart Java Chat; use its API only for read-only absence verification and the exact stale-resource retirement required by [DP1d]. -- [DP1b] **Branch Routing**: Configure Dokploy push auto-deploy so `dev` owns `dev.javachat.ai` and `main` owns `javachat.ai` plus `www.javachat.ai`. Never route production deployment from `prod` or another branch. +- [DP1b] **Branch Routing**: `.github/workflows/build.yml` publishes one linux/amd64 + linux/arm64 image per push to `dev` or `main` and deploys its index digest to the Dokploy application named by the GitHub environment variable `DOKPLOY_APPLICATION_ID` (`development` for `dev` → `dev.javachat.ai`; `production` for `main` → `javachat.ai` plus `www.javachat.ai`). Both applications use `sourceType: docker`; never return them to Dokploy Git builds, which emit a single architecture. Never route production deployment from `prod` or another branch. - [DP1c] **No Push-To-Coolify Path**: Never add or retain a GitHub Actions workflow, repository webhook, Git hook, script, secret, variable, or external integration that sends a repository push to Coolify. - [DP1d] **Retire Stale Owners**: Treat any Java Chat Coolify resource as an incident-grade stale deployment owner. Stop and delete its exact application and containers after proving the matching Dokploy application is healthy and receiving the intended domain. -- [DP1e] **Release Verification**: Before completing a release, prove tracked GitHub Actions, Git hooks, and scripts contain zero Coolify deployment paths; the GitHub repository has zero Coolify-targeting webhooks and zero Coolify-named Actions secrets, variables, or integrations; Coolify has zero Java Chat applications or containers; both Dokploy applications use push auto-deploy on their required branches and their latest deployment receipts name the released commits; and `https://dev.javachat.ai/actuator/health`, `https://javachat.ai/actuator/health`, and `https://www.javachat.ai/actuator/health` each return JSON field `status` equal to `UP` through the matching Dokploy application. +- [DP1e] **Release Verification**: Before completing a release, prove tracked GitHub Actions, Git hooks, and scripts contain zero Coolify deployment paths; the GitHub repository has zero Coolify-targeting webhooks and zero Coolify-named Actions secrets, variables, or integrations; Coolify has zero Java Chat applications or containers; both Dokploy applications run the CI-published image digest for their branch and `/actuator/info` `deployment.commit` names the released commit; and `https://dev.javachat.ai/actuator/health`, `https://javachat.ai/actuator/health`, and `https://www.javachat.ai/actuator/health` each return JSON field `status` equal to `UP` through the matching Dokploy application. ## [CC1] Clean Code & DDD (Mandatory) diff --git a/scripts/deploy_dokploy_image.sh b/scripts/deploy_dokploy_image.sh new file mode 100755 index 00000000..907f2f65 --- /dev/null +++ b/scripts/deploy_dokploy_image.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Points the environment's Dokploy application at one published image digest, +# deploys it, and waits until the public endpoint serves the deployed commit. +# CI runs this after publishing the multi-platform image [DP1b]. +set -euo pipefail + +: "${DOKPLOY_URL:?}" "${DOKPLOY_API_KEY:?}" "${DOKPLOY_APPLICATION_ID:?}" +: "${IMAGE_REFERENCE:?}" "${SOURCE_COMMIT:?}" "${PUBLIC_URL:?}" + +if [[ ! "$IMAGE_REFERENCE" =~ @sha256:([a-f0-9]{64})$ ]]; then + printf 'IMAGE_REFERENCE must end in an exact @sha256 digest: %s\n' "$IMAGE_REFERENCE" >&2 + exit 1 +fi +image_digest="${BASH_REMATCH[1]}" +dokploy_api="${DOKPLOY_URL%/}/api" +# Dokploy resolves its ${DOKPLOY_SOURCE_REVISION} label placeholder only for Git +# sources, so an image deployment carries the commit in the label itself. +revision_label="otel.service.version" + +dokploy_get() { + curl --fail-with-body --silent --show-error --connect-timeout 10 --max-time 30 \ + --header "x-api-key: ${DOKPLOY_API_KEY}" "${dokploy_api}/$1" +} + +dokploy_post() { + curl --fail-with-body --silent --show-error --connect-timeout 10 --max-time 30 \ + --header "x-api-key: ${DOKPLOY_API_KEY}" --header 'content-type: application/json' \ + --data "$2" "${dokploy_api}/$1" +} + +application="$(dokploy_get "application.one?applicationId=${DOKPLOY_APPLICATION_ID}")" +current_labels="$(jq --compact-output '.labelsSwarm // {}' <<<"$application")" +target_labels="$(jq --compact-output --arg key "$revision_label" --arg commit "$SOURCE_COMMIT" \ + '.[$key] = $commit' <<<"$current_labels")" + +if [[ "$(jq --raw-output .sourceType <<<"$application")" != docker ]]; then + # A Git-built application has no image to compare against; switch its source. + update_request="$(jq --null-input --compact-output \ + --arg applicationId "$DOKPLOY_APPLICATION_ID" --arg dockerImage "$IMAGE_REFERENCE" \ + --argjson labelsSwarm "$target_labels" \ + '{applicationId: $applicationId, sourceType: "docker", dockerImage: $dockerImage, labelsSwarm: $labelsSwarm}')" +else + update_request="$(jq --null-input --compact-output \ + --arg applicationId "$DOKPLOY_APPLICATION_ID" --arg dockerImage "$IMAGE_REFERENCE" \ + --arg expectedDockerImage "$(jq --raw-output .dockerImage <<<"$application")" \ + --argjson expectedLabelsSwarm "$current_labels" --argjson labelsSwarm "$target_labels" \ + '{applicationId: $applicationId, expectedDockerImage: $expectedDockerImage, + expectedLabelsSwarm: $expectedLabelsSwarm, dockerImage: $dockerImage, labelsSwarm: $labelsSwarm}')" +fi +dokploy_post application.update "$update_request" >/dev/null + +deploy_request="$(jq --null-input --compact-output \ + --arg applicationId "$DOKPLOY_APPLICATION_ID" --arg expectedDockerImage "$IMAGE_REFERENCE" \ + --argjson expectedLabelsSwarm "$target_labels" --arg idempotencyKey "$image_digest" \ + --arg title "CI deploy ${SOURCE_COMMIT:0:12}" \ + '{applicationId: $applicationId, expectedDockerImage: $expectedDockerImage, + expectedLabelsSwarm: $expectedLabelsSwarm, idempotencyKey: $idempotencyKey, title: $title}')" +deployment_id="$(dokploy_post application.deploy "$deploy_request" | jq --exit-status --raw-output .deploymentId)" +printf 'Submitted Dokploy deployment %s for %s\n' "$deployment_id" "$IMAGE_REFERENCE" + +deployment_status="" +for _ in $(seq 1 120); do + deployment_status="$(dokploy_get "deployment.all?applicationId=${DOKPLOY_APPLICATION_ID}" \ + | jq --raw-output --arg id "$deployment_id" '.[] | select(.deploymentId == $id) | .status')" + [[ "$deployment_status" == "done" || "$deployment_status" == "error" ]] && break + sleep 5 +done +if [[ "$deployment_status" != "done" ]]; then + printf 'Dokploy deployment %s ended as %s\n' "$deployment_id" "${deployment_status:-missing}" >&2 + exit 1 +fi + +# Dokploy records `done` once Swarm accepts the update; the rollout is proven +# only when the public endpoint reports the deployed commit. +served_commit="" +for _ in $(seq 1 90); do + served_commit="$(curl --silent --connect-timeout 5 --max-time 15 "${PUBLIC_URL}/actuator/info" \ + | jq --raw-output '.deployment.commit // empty' 2>/dev/null || true)" + if [[ "$served_commit" == "$SOURCE_COMMIT" ]]; then + printf '%s serves %s from %s\n' "$PUBLIC_URL" "$SOURCE_COMMIT" "$IMAGE_REFERENCE" + exit 0 + fi + sleep 10 +done +printf '%s still serves %s, expected %s\n' "$PUBLIC_URL" "${served_commit:-nothing}" "$SOURCE_COMMIT" >&2 +exit 1 From e6f4b8a30f049245138ab7b6fb7e1a4906d567a7 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 2 Oct 2026 11:18:07 -0700 Subject: [PATCH 28/29] build: upgrade Gradle wrapper to 9.8.0 The shared ~/.gradle init script configures daemon-log retention, which Gradle 9.2.1 cannot compile, so every local build failed before configuration. Pin the distribution checksum while upgrading. --- gradle/wrapper/gradle-wrapper.properties | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2b..17171eaa 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c From 10d18175d230fa3cb9cbed270bb847dff715fc26 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 2 Oct 2026 11:30:01 -0700 Subject: [PATCH 29/29] ci: pull deployed images through the registry.haiku.host registry row Dokploy authenticates Swarm image pulls with the application's registry row unless inline docker credentials are set. Point the application at the row named by DOKPLOY_REGISTRY_ID and empty its inline credentials whenever the source or pull registry differs. --- .github/workflows/build.yml | 1 + scripts/deploy_dokploy_image.sh | 16 +++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 24433c33..5c81db1c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -432,6 +432,7 @@ jobs: DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }} DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} DOKPLOY_APPLICATION_ID: ${{ vars.DOKPLOY_APPLICATION_ID }} + DOKPLOY_REGISTRY_ID: ${{ vars.DOKPLOY_REGISTRY_ID }} IMAGE_REFERENCE: ${{ steps.index.outputs.reference }} SOURCE_COMMIT: ${{ github.sha }} PUBLIC_URL: ${{ github.ref_name == 'main' && 'https://javachat.ai' || 'https://dev.javachat.ai' }} diff --git a/scripts/deploy_dokploy_image.sh b/scripts/deploy_dokploy_image.sh index 907f2f65..4c56006d 100755 --- a/scripts/deploy_dokploy_image.sh +++ b/scripts/deploy_dokploy_image.sh @@ -5,7 +5,7 @@ set -euo pipefail : "${DOKPLOY_URL:?}" "${DOKPLOY_API_KEY:?}" "${DOKPLOY_APPLICATION_ID:?}" -: "${IMAGE_REFERENCE:?}" "${SOURCE_COMMIT:?}" "${PUBLIC_URL:?}" +: "${DOKPLOY_REGISTRY_ID:?}" "${IMAGE_REFERENCE:?}" "${SOURCE_COMMIT:?}" "${PUBLIC_URL:?}" if [[ ! "$IMAGE_REFERENCE" =~ @sha256:([a-f0-9]{64})$ ]]; then printf 'IMAGE_REFERENCE must end in an exact @sha256 digest: %s\n' "$IMAGE_REFERENCE" >&2 @@ -33,12 +33,18 @@ current_labels="$(jq --compact-output '.labelsSwarm // {}' <<<"$application")" target_labels="$(jq --compact-output --arg key "$revision_label" --arg commit "$SOURCE_COMMIT" \ '.[$key] = $commit' <<<"$current_labels")" -if [[ "$(jq --raw-output .sourceType <<<"$application")" != docker ]]; then - # A Git-built application has no image to compare against; switch its source. +# Swarm workers pull with the registry row's credentials only when the +# application's inline docker credentials are empty; the update schema accepts +# strings, not null, and Dokploy treats an empty username as absent. +if ! jq --exit-status --arg registryId "$DOKPLOY_REGISTRY_ID" \ + '.sourceType == "docker" and .registryId == $registryId + and (.username // "") == "" and (.password // "") == "" and (.registryUrl // "") == ""' <<<"$application" >/dev/null; then + # A Git-built or differently credentialed application switches source and pull registry. update_request="$(jq --null-input --compact-output \ --arg applicationId "$DOKPLOY_APPLICATION_ID" --arg dockerImage "$IMAGE_REFERENCE" \ - --argjson labelsSwarm "$target_labels" \ - '{applicationId: $applicationId, sourceType: "docker", dockerImage: $dockerImage, labelsSwarm: $labelsSwarm}')" + --arg registryId "$DOKPLOY_REGISTRY_ID" --argjson labelsSwarm "$target_labels" \ + '{applicationId: $applicationId, sourceType: "docker", dockerImage: $dockerImage, labelsSwarm: $labelsSwarm, + registryId: $registryId, username: "", password: "", registryUrl: ""}')" else update_request="$(jq --null-input --compact-output \ --arg applicationId "$DOKPLOY_APPLICATION_ID" --arg dockerImage "$IMAGE_REFERENCE" \