feat: analyst proposes multiple options; README with gitea remote

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ahmed mohammed
2026-09-22 07:44:01 +03:00
co-authored by Claude Opus 4.7
parent 4fefd0f6da
commit 816f22c227
3 changed files with 189 additions and 21 deletions
+86 -21
View File
@@ -1,36 +1,101 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # SQL Server qAI
## Getting Started Paste a SQL Server ShowPlan XML (or drop a `.sqlplan`), get a report with several ranked remediation options and
rewritten queries. Two models, one job: a **generative analyst model** proposes the options and writes the report, and
**Jev** (TypeSafe System One) scores every option and decides which one to run first. Jev never writes SQL; it returns
typed scores, a choice, and confidences. The report renders as OpenUI elements, not a markdown blob.
First, run the development server: ## Pipeline
1. `lib/digest.ts` parses the XML in code: top operators, missing indexes, warnings, waits, estimate-vs-actual row
errors, tables, statement text, subtree cost, parallelism. Raw XML never reaches a model.
2. The analyst model receives the digest JSON and proposes 2–4 genuinely different options (index, rewrite, statistics,
parallelism, schema), each with ordered action steps and an optional rewrite.
3. Jev answers four questions per option (`bottleneck_fit`, `semantic_safety`, `ease` `score`; `root_cause` `noul`) and
one cross-option call (`first_to_run` `choice`, `anything_worth_running` `noul`).
4. `lib/jev.ts` combines them with visible weights (0.40 / 0.30 / 0.15 / 0.15 — four numbers in that file, the only
tuning knob) and prints a verdict. Jev's pick leads only when its confidence clears 0.5; otherwise the report says
the order came from composite scoring.
5. The analyst streams the final report as OpenUI Lang; `<Renderer>` paints it as it arrives.
Getting the XML: SSMS → **Ctrl+M** (include actual execution plan) → run the query → right-click the Execution Plan tab
→ *Save As .sqlplan*. No SSMS: prefix the batch with `SET STATISTICS XML ON;` and copy the XML result cell.
## Run it
This repo runs as a plain Node process. Containerization is written (`Dockerfile`, Task 12 of the plan) but is not
required for local work — `npm run dev` is the normal path on a small machine.
```bash ```bash
npm run dev npm install
# or cp .env.example .env.local # then fill in APP_SECRET and DATA_DIR
yarn dev npm run dev # http://localhost:3000
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. `.env.local`:
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. | key | why |
| --- | --- |
| `APP_SECRET` | ≥16 chars. Encrypts API keys at rest (AES-256-GCM). Changing it makes stored keys unreadable. |
| `DATA_DIR` | where `qai.db` (SQLite, `node:sqlite`) lives. Default `./data`. Docker uses `/data`. |
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. Model keys are **not** config. Each user types them in the app (first-run guide or `/settings`), so a second user
brings their own endpoint and their own spend. Blank key field = keep the stored key.
## Learn More - Analyst: any OpenAI-compatible base URL + key + model, plus optional extra JSON params.
A local endpoint such as `http://192.168.88.131:8888/v1` works; use key `EMPTY` when the server has no auth.
- Jev: a TypeSafe API key from `console.typesafe.ai/keys`, model usually `jev-latest`.
To learn more about Next.js, take a look at the following resources: ## Tests
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ```bash
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. npm test # node --test on tests/*.test.ts, no test framework installed
npm run build # production build + type check
npm run e2e # Playwright: signup, settings, real analyst + real Jev run, copy, print, history, 4 widths
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! The unit suites run offline: the analyst and Jev are exercised through injected `fetch`, so no key and no network.
`npm run e2e` is the opposite — it drives the real app against the real models, and reads `JEV_API_KEY` from
`.env.local`. Expect it to be slow; that is the models, not the harness.
## Deploy on Vercel `tests/digest.test.ts` skips unless a genuine plan exists at `fixtures/real.sqlplan`. Drop one there from SSMS and the
skip becomes the real check on ShowPlan attribute spellings.
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. ## Git remote (Gitea)
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. Origin points at the self-hosted Gitea:
```bash
git remote -v # http://192.168.88.39:3000/ahmed/sql-server-query-ai.git
git push origin feat/sql-server-qai
```
The remote URL deliberately contains **no password**. Git prompts for the username (`ahmed`) and password the first
time, or you can keep them out of prompts:
```bash
git config credential.helper osxkeychain # remember them locally
git push origin feat/sql-server-qai
```
Never commit the password into `.git/config`, a remote URL, or this README — a stored credential in the repo is
readable by whoever you hand the repo to. If you want it non-interactive in CI, create a Gitea token
(Settings → Applications) and use `http://192.168.88.39:3000/ahmed/sql-server-query-ai.git` with the token as password.
## Layout
```
lib/digest.ts XML → bounded digest (the only shape models see)
lib/analyst.ts propose 2–4 options; stream the final OpenUI report
lib/jev.ts questions, weights, ranking, verdict, fallback
lib/report-ui.tsx OpenUI component library — the same module builds the prompt and renders the answer
lib/db.ts node:sqlite: users, sessions, settings, analyses
lib/vault.ts AES-256-GCM seal/open/mask for credentials
lib/auth.ts scrypt passwords, hashed session tokens (DB stores the SHA-256, never the token)
app/api/analyze SSE pipeline: digesting → proposing → judging → writing
tests/ offline unit suites tests-e2e/ Playwright
docs/superpowers/ spec + the 13-task implementation plan this was built from
```
Design: dark terminal console, JetBrains Mono for data, IBM Plex Sans for prose, one phosphor accent.
Print stylesheet flips to paper white and drops the rail, buttons and chrome — that is the Export to PDF path.
+58
View File
@@ -0,0 +1,58 @@
import { z } from "zod/v4";
import { digestForModel, type Digest } from "./digest.ts";
import type { AnalystConfig } from "./settings.ts";
export class AnalystError extends Error {}
const Action = z.object({ title: z.string().min(3), detail: z.string().min(3),
effort: z.enum(["low", "medium", "high"]), requires_change_control: z.boolean() });
const RawCandidate = z.object({ key: z.string().min(2), title: z.string().min(3), diagnosis: z.string().min(15),
actions: z.array(Action).min(1), rewrite_sql: z.string().nullable(), expected: z.string().min(5) });
const Proposal = z.object({ candidates: z.array(RawCandidate).min(2).max(4) });
export type Action = z.infer<typeof Action>;
export type Candidate = z.infer<typeof RawCandidate>;
export const CANDIDATE_SYSTEM_PROMPT = `You are a SQL Server performance engineer. You receive a JSON digest of one
execution plan. Judge only what the digest contains — never invent operators, waits, or row counts you were not given.
Return 2 to 4 genuinely different remediation options, not one option restated. Real differences: an index change, a
rewritten query, a statistics or parallelism fix, a batching or schema change. At least one option must be the
lowest-risk one. Each option needs a snake_case key, a diagnosis naming WHY the plan is slow, 1-4 ordered actions, an
optional rewritten query, and an expected effect stated as a concrete claim about the plan (logical reads, seek vs
scan, tempdb spill, degree of parallelism).
A rewrite must return the same rows as the original. If you cannot rewrite safely, set rewrite_sql to null.
Output ONE JSON object and nothing else:
{"candidates":[{"key":"idx_covering_orders","title":"...","diagnosis":"...","actions":[{"title":"...","detail":"...",
"effort":"low|medium|high","requires_change_control":false}],"rewrite_sql":null,"expected":"..."}]}`;
export function extractJson(text: string): unknown {
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
const s = (fenced ? fenced[1] : text).trim();
const start = s.indexOf("{"); const end = s.lastIndexOf("}");
if (start === -1 || end <= start) throw new AnalystError("The analyst model returned prose instead of JSON.");
try { return JSON.parse(s.slice(start, end + 1)); }
catch { throw new AnalystError("The analyst model returned malformed JSON."); }
}
export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note: string,
fetchImpl: typeof fetch = fetch): Promise<Candidate[]> {
const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
body: JSON.stringify({ model: cfg.model, temperature: 0.6, ...cfg.extra, stream: false,
messages: [{ role: "system", content: CANDIDATE_SYSTEM_PROMPT },
{ role: "user", content: JSON.stringify({ digest: digestForModel(digest), user_note: note || null }) }] }),
}).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); });
if (!res.ok) throw new AnalystError(`Analyst model returned HTTP ${res.status}. ${(await res.text()).slice(0, 300)}`);
const content: string = ((await res.json() as any)?.choices?.[0]?.message?.content) ?? "";
const parsed = Proposal.safeParse(extractJson(content));
if (!parsed.success) {
const n = (extractJson(content) as any)?.candidates?.length ?? 0;
throw new AnalystError(n < 2
? `The analyst model proposed ${n} option(s); at least 2 are needed before Jev can rank anything.`
: `Analyst output did not match the schema at ${parsed.error.issues[0].path.join(".")}: ${parsed.error.issues[0].message}`);
}
return parsed.data.candidates;
}
+45
View File
@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
const { proposeCandidates, extractJson, AnalystError } = await import("../lib/analyst.ts");
const cfg = { baseUrl: "https://api.test/v1", apiKey: "k", model: "m", extra: {} };
const digest = { sql: "SELECT 1", bytes: 8, subtreeCost: 1, parallel: false, nonParallelReason: null,
earlyAbort: null, tables: ["t"], topOperators: [], rowGuessErrors: [], missingIndexes: [], warnings: [], waits: [] } as any;
const ok = (content: string) => ({ ok: true, status: 200,
json: async () => ({ choices: [{ message: { content } }] }) });
const CANDS = { candidates: [
{ key: "idx_customer", title: "Covering index", diagnosis: "Scan of 1.2M rows on Sales.Orders, no key on CustomerId.",
actions: [{ title: "Create index", detail: "CREATE INDEX ...", effort: "medium", requires_change_control: true }],
rewrite_sql: null, expected: "seek instead of scan" },
{ key: "rewrite_split", title: "Split the statement", diagnosis: "One statement scans everything then filters.",
actions: [{ title: "Deploy rewrite", detail: "Two statements", effort: "high", requires_change_control: true }],
rewrite_sql: "SELECT ...", expected: "far fewer logical reads" }] };
test("returns schema-valid candidates", async () => {
const got = await proposeCandidates(cfg, digest, "", async () => ok(JSON.stringify(CANDS)) as any);
assert.equal(got.length, 2);
assert.equal(got[0].key, "idx_customer");
});
test("survives code fences and sends extra params", async () => {
let sent: any;
const fetchImpl = async (_u: any, init: any) => { sent = JSON.parse(init.body);
return ok("```json\n" + JSON.stringify(CANDS) + "\n```") as any; };
const got = await proposeCandidates({ ...cfg, extra: { temperature: 0.1 } }, digest, "index exists already", fetchImpl as any);
assert.equal(got.length, 2);
assert.equal(sent.temperature, 0.1);
assert.match(sent.messages[0].content, /candidates/);
assert.match(sent.messages[1].content, /index exists already/);
});
test("names the stage on auth, prose, and shape failures", async () => {
await assert.rejects(() => proposeCandidates(cfg, digest, "",
async () => ({ ok: false, status: 401, text: async () => "bad key" }) as any),
(e: any) => e instanceof AnalystError && /401/.test(e.message));
await assert.rejects(() => proposeCandidates(cfg, digest, "", async () => ok("no json here") as any), AnalystError);
await assert.rejects(() => proposeCandidates(cfg, digest, "",
async () => ok(JSON.stringify({ candidates: [CANDS.candidates[0]] })) as any),
(e: any) => /at least 2/.test(e.message));
});
test("extractJson copes with fences and chatter", () => {
assert.deepEqual(extractJson('Sure!\n```json\n{"a":1}\n```'), { a: 1 });
assert.deepEqual(extractJson('noise {"a":1} noise'), { a: 1 });
});