mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge remote-tracking branch 'origin/dev' into external-db-sync
This commit is contained in:
commit
f726f6165b
@ -120,7 +120,7 @@ const waitForVerificationEmail = async (testEmail: string, useInbucket: boolean)
|
||||
throw new StackAssertionError(`Couldn't find verification email in time limit`, { recipient_email: testEmail, max_poll_attempts: MAX_POLL_ATTEMPTS, poll_interval_ms: POLL_INTERVAL_MS });
|
||||
};
|
||||
|
||||
export const GET = createSmartRouteHandler({
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: {
|
||||
hidden: true,
|
||||
summary: "Email Health Monitor",
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
@ -619,6 +620,7 @@ function TableContent({ tableId }: { tableId: TableId }) {
|
||||
|
||||
export default function PageClient() {
|
||||
const [selectedTable, setSelectedTable] = useState<TableId | null>("events");
|
||||
const [queryDialogOpen, setQueryDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="analytics">
|
||||
@ -647,7 +649,7 @@ export default function PageClient() {
|
||||
</div>
|
||||
<div className="py-4 px-4">
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("spotlight-toggle"))}
|
||||
onClick={() => setQueryDialogOpen(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors hover:transition-none w-full"
|
||||
>
|
||||
<SparkleIcon className="h-4 w-4" />
|
||||
@ -667,6 +669,23 @@ export default function PageClient() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Query moved dialog */}
|
||||
<Dialog open={queryDialogOpen} onOpenChange={setQueryDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Analytics Queries have moved to the Control Center</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Typography variant="secondary">
|
||||
You can now do analytics queries directly from the Control Center. To open the Control Center, press <kbd className="px-1.5 py-0.5 rounded bg-muted font-mono text-xs">⌘</kbd> + <kbd className="px-1.5 py-0.5 rounded bg-muted font-mono text-xs">K</kbd>
|
||||
</Typography>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setQueryDialogOpen(false)}>OK</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
|
||||
@ -230,102 +230,6 @@ const CyclingPlaceholder = memo(function CyclingPlaceholder({
|
||||
);
|
||||
});
|
||||
|
||||
// Analytics query mode placeholder component
|
||||
const AnalyticsQueryPlaceholder = memo(function AnalyticsQueryPlaceholder({
|
||||
onSelectQuery,
|
||||
}: {
|
||||
onSelectQuery?: (query: string) => void,
|
||||
}) {
|
||||
const tables = [
|
||||
{ id: "events", name: "Events", description: "User events and actions", query: "SELECT * FROM events ORDER BY event_at DESC LIMIT 100" },
|
||||
];
|
||||
|
||||
const exampleQueries = [
|
||||
"Show me all events from the last hour",
|
||||
"Count events by type",
|
||||
"SELECT event_type, COUNT(*) FROM events GROUP BY event_type",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center select-none px-6">
|
||||
{/* Top spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="relative w-fit max-w-md">
|
||||
{/* Header */}
|
||||
<div className="relative text-center mb-6">
|
||||
<h2 className="relative text-base font-semibold text-foreground mb-1 inline-block">
|
||||
Analytics Query
|
||||
</h2>
|
||||
<p className="text-[11px] text-muted-foreground/50">
|
||||
Query your data using English or ClickHouse SQL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tables section */}
|
||||
<div className="mb-6">
|
||||
<p className="text-[9px] text-muted-foreground/40 uppercase tracking-wider mb-3 text-center">Available Tables</p>
|
||||
<div className="space-y-2">
|
||||
{tables.map((table) => (
|
||||
<button
|
||||
key={table.id}
|
||||
type="button"
|
||||
onClick={() => onSelectQuery?.(table.query)}
|
||||
className="w-full flex items-center gap-3 rounded-lg px-4 py-3 transition-colors hover:transition-none hover:bg-foreground/[0.04] border border-foreground/[0.06]"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-500/10 flex items-center justify-center">
|
||||
<PlayIcon className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<h3 className="text-[12px] font-medium text-foreground font-mono">
|
||||
{table.name}
|
||||
</h3>
|
||||
<p className="text-[10px] text-muted-foreground/50">
|
||||
{table.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Example queries */}
|
||||
<div>
|
||||
<p className="text-[9px] text-muted-foreground/40 uppercase tracking-wider mb-3 text-center">Try something like</p>
|
||||
<div className="space-y-1.5">
|
||||
{exampleQueries.map((q, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => onSelectQuery?.(q)}
|
||||
className="w-full text-left text-[11px] text-muted-foreground/60 hover:text-foreground px-3 py-2 rounded-md hover:bg-foreground/[0.04] transition-colors hover:transition-none font-mono truncate"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="w-full shrink-0 -mx-6 px-6">
|
||||
<div className="py-3 border-t border-foreground/[0.06] w-full flex items-center justify-center gap-5 text-[10px] text-muted-foreground/40">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-foreground/[0.06] font-mono">↵</kbd>
|
||||
<span>run query</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-foreground/[0.06] font-mono">esc</kbd>
|
||||
<span>close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Reusable Results List Component
|
||||
export const CmdKResultsList = memo(function CmdKResultsList({
|
||||
@ -337,7 +241,6 @@ export const CmdKResultsList = memo(function CmdKResultsList({
|
||||
showCyclingPlaceholder = false,
|
||||
onSelectExampleQuery,
|
||||
isParentColumn = false,
|
||||
analyticsQueryMode = false,
|
||||
}: {
|
||||
commands: CmdKCommand[],
|
||||
selectedIndex: number,
|
||||
@ -351,8 +254,6 @@ export const CmdKResultsList = memo(function CmdKResultsList({
|
||||
onSelectExampleQuery?: (query: string) => void,
|
||||
/** When true, selection shows as outline only (for parent columns) */
|
||||
isParentColumn?: boolean,
|
||||
/** When true, show analytics query placeholder instead of cycling placeholder */
|
||||
analyticsQueryMode?: boolean,
|
||||
}) {
|
||||
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
||||
const hasResults = commands.length > 0;
|
||||
@ -366,9 +267,6 @@ export const CmdKResultsList = memo(function CmdKResultsList({
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!hasResults) {
|
||||
if (analyticsQueryMode) {
|
||||
return <AnalyticsQueryPlaceholder onSelectQuery={onSelectExampleQuery} />;
|
||||
}
|
||||
if (showCyclingPlaceholder) {
|
||||
return <CyclingPlaceholder onSelectQuery={onSelectExampleQuery} />;
|
||||
}
|
||||
@ -505,8 +403,6 @@ export function CmdKSearch({
|
||||
const [activeDepth, setActiveDepth] = useState(0); // Which column is active (0 = main list)
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([0]); // Selected index in each column
|
||||
const [nestedBlurHandlers, setNestedBlurHandlers] = useState<(() => void)[]>([]); // onBlur handlers for each depth
|
||||
// Analytics query mode state
|
||||
const [analyticsQueryMode, setAnalyticsQueryMode] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@ -528,23 +424,15 @@ export function CmdKSearch({
|
||||
setOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleAnalyticsQuery = () => {
|
||||
setAnalyticsQueryMode(true);
|
||||
setQuery("");
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", down);
|
||||
window.addEventListener("spotlight-toggle", handleToggle);
|
||||
window.addEventListener("spotlight-analytics-query", handleAnalyticsQuery);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", down);
|
||||
window.removeEventListener("spotlight-toggle", handleToggle);
|
||||
window.removeEventListener("spotlight-analytics-query", handleAnalyticsQuery);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Focus and select input when opening, reset analytics mode when closing
|
||||
// Focus and select input when opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedIndex(0);
|
||||
@ -553,16 +441,13 @@ export function CmdKSearch({
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
} else {
|
||||
// Reset analytics mode when closing
|
||||
setAnalyticsQueryMode(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Get commands from the hook
|
||||
const commands = useCmdKCommands({ projectId, enabledApps, query, onEnableApp });
|
||||
|
||||
// Filter commands based on query
|
||||
// Filter and sort commands based on query
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
@ -872,7 +757,7 @@ export function CmdKSearch({
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={analyticsQueryMode ? "Type your query... (English or ClickHouse SQL)" : "Search or ask AI..."}
|
||||
placeholder="Search or ask AI..."
|
||||
className="flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground/50"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
@ -934,7 +819,6 @@ export function CmdKSearch({
|
||||
showCyclingPlaceholder={true}
|
||||
onSelectExampleQuery={setQuery}
|
||||
isParentColumn={activeDepth > 0}
|
||||
analyticsQueryMode={analyticsQueryMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1097,7 +981,7 @@ export function CmdKTrigger() {
|
||||
"rounded-[12px]",
|
||||
"ring-2 ring-inset ring-foreground/[0.06]",
|
||||
"transition-all duration-300 hover:transition-none",
|
||||
"hover:ring-blue-500/15 hover:shadow-[0_0_16px_rgba(59,130,246,0.08),inset_0_1px_0_rgba(255,255,255,0.03)]"
|
||||
"hover:ring-[#9196F4]/20 hover:shadow-[0_0_20px_rgba(145,150,244,0.12),inset_0_1px_0_rgba(255,255,255,0.03)]"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
||||
163
apps/dashboard/src/lib/classify-query.test.ts
Normal file
163
apps/dashboard/src/lib/classify-query.test.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { classifyClickHouseSqlVsPrompt } from "./classify-query";
|
||||
|
||||
describe("classifyClickHouseSqlVsPrompt", () => {
|
||||
describe("SQL queries", () => {
|
||||
it("should classify basic SELECT query as SQL", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("SELECT * FROM events WHERE user_id = 1 LIMIT 10");
|
||||
expect(result.kind).toBe("sql");
|
||||
expect(result.confidence).toBeGreaterThan(0.7);
|
||||
expect(result.reasons).toContain("select-from-structure");
|
||||
});
|
||||
|
||||
it("should classify SELECT with GROUP BY as SQL", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("SELECT event_type, COUNT(*) FROM events GROUP BY event_type");
|
||||
expect(result.kind).toBe("sql");
|
||||
expect(result.reasons).toContain("select-from-structure");
|
||||
});
|
||||
|
||||
it("should classify INSERT statement as SQL", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("INSERT INTO events (user_id, event_type) VALUES (1, 'click')");
|
||||
expect(result.kind).toBe("sql");
|
||||
expect(result.reasons).toContain("insert-into-structure");
|
||||
});
|
||||
|
||||
it("should classify CREATE TABLE as SQL", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("CREATE TABLE users (id UInt64, name String) ENGINE = MergeTree()");
|
||||
expect(result.kind).toBe("sql");
|
||||
expect(result.reasons).toContain("starts-with-sql-keyword");
|
||||
});
|
||||
|
||||
it("should classify query with ClickHouse-specific keywords", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("SELECT * FROM events FINAL PREWHERE event_at > now() - INTERVAL 1 DAY");
|
||||
expect(result.kind).toBe("sql");
|
||||
expect(result.reasons).toContain("clickhouse-ish:prewhere,final");
|
||||
});
|
||||
|
||||
it("should classify query with quoted identifiers", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("SELECT `user_id`, `event_type` FROM `events`");
|
||||
expect(result.kind).toBe("sql");
|
||||
expect(result.reasons).toContain("quoted-identifiers");
|
||||
});
|
||||
|
||||
it("should classify query with dot notation", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("SELECT e.user_id FROM events e");
|
||||
expect(result.kind).toBe("sql");
|
||||
expect(result.reasons).toContain("dot-identifiers");
|
||||
});
|
||||
|
||||
it("should classify SHOW TABLES as SQL", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("SHOW TABLES");
|
||||
expect(result.kind).toBe("sql");
|
||||
expect(result.reasons).toContain("starts-with-sql-keyword");
|
||||
});
|
||||
|
||||
it("should classify DESCRIBE as SQL", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("DESCRIBE events");
|
||||
expect(result.kind).toBe("sql");
|
||||
expect(result.reasons).toContain("starts-with-sql-keyword");
|
||||
});
|
||||
|
||||
it("should classify query with SQL comments as SQL", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("SELECT * FROM events -- get all events");
|
||||
expect(result.kind).toBe("sql");
|
||||
expect(result.reasons).toContain("sql-comments");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Natural language prompts", () => {
|
||||
it("should classify question about writing query as prompt", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("can you write me a clickhouse query to count events by day?");
|
||||
expect(result.kind).toBe("prompt");
|
||||
expect(result.reasons).toContain("ends-with-question-mark");
|
||||
expect(result.reasons).toContain("prompt-words");
|
||||
});
|
||||
|
||||
it("should classify 'select the best option please' as prompt (false positive guard)", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("select the best option please");
|
||||
expect(result.kind).toBe("prompt");
|
||||
expect(result.reasons).toContain("english-select-false-positive-guard");
|
||||
expect(result.reasons).toContain("prompt-words");
|
||||
});
|
||||
|
||||
it("should classify help request as prompt", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("help me understand how to query events");
|
||||
expect(result.kind).toBe("prompt");
|
||||
expect(result.reasons).toContain("prompt-words");
|
||||
});
|
||||
|
||||
it("should classify 'what is' question as prompt", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("what is the schema of the events table?");
|
||||
expect(result.kind).toBe("prompt");
|
||||
expect(result.reasons).toContain("prompt-words");
|
||||
expect(result.reasons).toContain("ends-with-question-mark");
|
||||
});
|
||||
|
||||
it("should classify simple text without operators as prompt", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("show me user activity");
|
||||
expect(result.kind).toBe("prompt");
|
||||
});
|
||||
|
||||
it("should classify request with 'please' as prompt", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("please get me the latest events");
|
||||
expect(result.kind).toBe("prompt");
|
||||
expect(result.reasons).toContain("prompt-words");
|
||||
});
|
||||
|
||||
it("should classify 'how to' question as prompt", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("how do I join two tables in ClickHouse?");
|
||||
expect(result.kind).toBe("prompt");
|
||||
expect(result.reasons).toContain("prompt-words");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty input", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("");
|
||||
expect(result.kind).toBe("prompt");
|
||||
expect(result.confidence).toBe(0.5);
|
||||
expect(result.reasons).toContain("empty");
|
||||
});
|
||||
|
||||
it("should handle null input", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt(null);
|
||||
expect(result.kind).toBe("prompt");
|
||||
expect(result.reasons).toContain("empty");
|
||||
});
|
||||
|
||||
it("should handle undefined input", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt(undefined);
|
||||
expect(result.kind).toBe("prompt");
|
||||
expect(result.reasons).toContain("empty");
|
||||
});
|
||||
|
||||
it("should strip markdown code fences", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("```sql\nSELECT * FROM events\n```");
|
||||
expect(result.kind).toBe("sql");
|
||||
expect(result.reasons).toContain("select-from-structure");
|
||||
});
|
||||
|
||||
it("should handle whitespace-only input", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt(" ");
|
||||
expect(result.kind).toBe("prompt");
|
||||
expect(result.reasons).toContain("empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Confidence scoring", () => {
|
||||
it("should have higher confidence for clear SQL", () => {
|
||||
const sqlResult = classifyClickHouseSqlVsPrompt("SELECT * FROM events WHERE user_id = 1 LIMIT 10");
|
||||
const promptResult = classifyClickHouseSqlVsPrompt("can you write me a query?");
|
||||
|
||||
expect(sqlResult.confidence).toBeGreaterThan(0.7);
|
||||
expect(promptResult.confidence).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it("should return score breakdown", () => {
|
||||
const result = classifyClickHouseSqlVsPrompt("SELECT * FROM events");
|
||||
expect(result.score).toBeDefined();
|
||||
expect(result.score?.sql).toBeGreaterThan(0);
|
||||
expect(result.score?.margin).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
99
apps/dashboard/src/lib/classify-query.ts
Normal file
99
apps/dashboard/src/lib/classify-query.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Classifies whether user input is a ClickHouse SQL query or a natural language prompt.
|
||||
* Used to prioritize "Run Query" vs "Ask AI" in the command center.
|
||||
*/
|
||||
export function classifyClickHouseSqlVsPrompt(input: unknown): {
|
||||
kind: "sql" | "prompt",
|
||||
confidence: number,
|
||||
reasons: string[],
|
||||
score?: { sql: number, prompt: number, margin: number },
|
||||
} {
|
||||
const raw = (input ?? "").toString();
|
||||
const s = raw.trim();
|
||||
|
||||
if (!s) return { kind: "prompt", confidence: 0.5, reasons: ["empty"] };
|
||||
|
||||
// Strip code fences if someone pasted markdown
|
||||
const unfenced = s.replace(/^```[\w-]*\n([\s\S]*?)\n```$/m, "$1").trim();
|
||||
|
||||
const lower = unfenced.toLowerCase();
|
||||
const words = lower.match(/[a-z_]+/g) ?? [];
|
||||
const wordSet = new Set(words);
|
||||
|
||||
let sql = 0;
|
||||
let prompt = 0;
|
||||
const reasons: string[] = [];
|
||||
|
||||
// 1) Strong starts (high signal)
|
||||
const startsWithSql = /^(with|select|insert|update|delete|alter|create|drop|truncate|show|describe|desc|explain|use|set)\b/i.test(unfenced);
|
||||
if (startsWithSql) { sql += 3; reasons.push("starts-with-sql-keyword"); }
|
||||
|
||||
// 2) Structural patterns (very strong)
|
||||
const hasSelectFrom = /\bselect\b[\s\S]{0,300}\bfrom\b/i.test(unfenced);
|
||||
if (hasSelectFrom) { sql += 4; reasons.push("select-from-structure"); }
|
||||
|
||||
const hasInsertInto = /\binsert\b[\s\S]{0,80}\binto\b/i.test(unfenced);
|
||||
if (hasInsertInto) { sql += 4; reasons.push("insert-into-structure"); }
|
||||
|
||||
// 3) Common SQL clauses
|
||||
const clauseHits = [
|
||||
"where", "group", "order", "limit", "having", "join", "on", "union", "distinct", "values", "format", "settings"
|
||||
].filter(k => wordSet.has(k));
|
||||
if (clauseHits.length) {
|
||||
sql += Math.min(3, clauseHits.length);
|
||||
reasons.push("sql-clauses:" + clauseHits.join(","));
|
||||
}
|
||||
|
||||
// 4) ClickHouse-ish tokens (extra signal)
|
||||
const chHits = [
|
||||
"prewhere", "final", "sample", "array", "engine", "partition", "ttl", "distributed", "merge", "replacing", "collapsing",
|
||||
"materialized", "view", "database", "table", "cluster"
|
||||
].filter(k => wordSet.has(k));
|
||||
if (chHits.length) { sql += 2; reasons.push("clickhouse-ish:" + chHits.join(",")); }
|
||||
|
||||
// 5) Operator / punctuation density
|
||||
const opCount = (unfenced.match(/(<=|>=|!=|=|<|>|\b(in|like|ilike|between|and|or)\b)/gi) ?? []).length;
|
||||
if (opCount >= 2) { sql += 2; reasons.push("many-operators"); }
|
||||
else if (opCount === 1) { sql += 1; reasons.push("some-operators"); }
|
||||
|
||||
const punct = (unfenced.match(/[(),;*]/g) ?? []).length;
|
||||
const punctRatio = punct / Math.max(1, unfenced.length);
|
||||
if (punctRatio > 0.03) { sql += 1; reasons.push("sql-punctuation-density"); }
|
||||
|
||||
// 6) Identifier-ish things
|
||||
if (/`[^`]+`/.test(unfenced) || /"[^"]+"\."[^"]+"/.test(unfenced)) {
|
||||
sql += 1; reasons.push("quoted-identifiers");
|
||||
}
|
||||
if (/\b[a-z_]+\.[a-z_]+\b/i.test(unfenced)) {
|
||||
sql += 1; reasons.push("dot-identifiers");
|
||||
}
|
||||
if (/--|\/\*/.test(unfenced)) {
|
||||
sql += 1; reasons.push("sql-comments");
|
||||
}
|
||||
|
||||
// Prompt-ish features
|
||||
if (/[?]\s*$/.test(unfenced)) { prompt += 2; reasons.push("ends-with-question-mark"); }
|
||||
if (/\b(please|could you|can you|what|why|how|explain|help)\b/i.test(unfenced)) {
|
||||
prompt += 2; reasons.push("prompt-words");
|
||||
}
|
||||
|
||||
// If it's mostly letters/spaces and barely any operators, lean prompt.
|
||||
const symbolChars = (unfenced.match(/[^a-z0-9_\s]/gi) ?? []).length;
|
||||
const symbolRatio = symbolChars / Math.max(1, unfenced.length);
|
||||
if (symbolRatio < 0.06 && opCount === 0 && !startsWithSql) {
|
||||
prompt += 2; reasons.push("low-symbol-low-operator");
|
||||
}
|
||||
|
||||
// Avoid the classic false positive: "select ..." in English without any SQL structure
|
||||
if (/^select\b/i.test(unfenced) && !hasSelectFrom && clauseHits.length === 0 && opCount === 0) {
|
||||
prompt += 3; reasons.push("english-select-false-positive-guard");
|
||||
}
|
||||
|
||||
const margin = sql - prompt;
|
||||
const kind = margin >= 2 ? "sql" : "prompt";
|
||||
|
||||
// Confidence: squish margin into [0.5..0.99]
|
||||
const confidence = Math.max(0.5, Math.min(0.99, 0.5 + Math.abs(margin) * 0.12));
|
||||
|
||||
return { kind, confidence, reasons, score: { sql, prompt, margin } };
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { niceBackendFetch } from "../../backend-helpers";
|
||||
|
||||
it("should return ok when email health check succeeds", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/health/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"authorization": `Bearer ${getEnvVariable("STACK_EMAIL_MONITOR_SECRET_TOKEN")}`,
|
||||
},
|
||||
@ -19,6 +20,7 @@ it("should return ok when email health check succeeds", async ({ expect }) => {
|
||||
|
||||
it("should reject requests with invalid token", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/health/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"authorization": "Bearer invalid-token",
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user