Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ dist
.vscode
.idea
.DS_Store
# The Rulekeeper's .NET projects (schema migrator, local Aspire host) aren't part of the site image
# The Ruleferret's .NET projects (schema migrator, local Aspire host) aren't part of the site image
src
10 changes: 5 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ NEXT_PUBLIC_GTM_CONTAINER_ID=#{{ NEXT_PUBLIC_GTM_CONTAINER_ID }}
# Only required for staging/production environments
NEXT_PUBLIC_APPLICATIONINSIGHTS_CONNECTION_STRING=#{{APPLICATIONINSIGHTS_CONNECTION_STRING}}

# The Rulekeeper (Rules Chat) database. Aspire sets these when it runs the index (pnpm aspire, see
# docs/rulekeeper/local-development.md); set them here only to run pnpm rules-chat:index yourself. The local password is in
# The Ruleferret (Rules Chat) database. Aspire sets these when it runs the index (pnpm aspire, see
# docs/ruleferret/local-development.md); set them here only to run pnpm rules-chat:index yourself. The local password is in
# the AppHost's user secrets: dotnet user-secrets list --project src/RulesChat/RulesChat.AppHost
# On Azure, leave the user and password unset: the site signs in with its managed identity.
RULES_CHAT_SQL_SERVER=localhost
Expand All @@ -63,7 +63,7 @@ RULES_CHAT_SQL_PASSWORD=#{{RULES_CHAT_SQL_PASSWORD}}
# "true" accepts a self-signed SQL Server certificate. Local development only; leave unset anywhere else.
RULES_CHAT_SQL_TRUST_CERTIFICATE=true

# The Rulekeeper's models, through any OpenAI-compatible API. Locally: Ollama with bge-m3 (1024 dimensions, so leave
# The Ruleferret's models, through any OpenAI-compatible API. Locally: Ollama with bge-m3 (1024 dimensions, so leave
# RULES_CHAT_EMBEDDING_DIMENSIONS unset). On Azure, leave the API key unset: calls sign in with the managed identity.
RULES_CHAT_AI_BASE_URL=http://localhost:11434/v1
RULES_CHAT_AI_API_KEY=ollama
Expand All @@ -78,11 +78,11 @@ RULES_CHAT_REASONING_EFFORT=none
# On Azure only: the client ID of the managed identity the site signs in to SQL and Foundry with.
RULES_CHAT_IDENTITY_CLIENT_ID=

# The Rulekeeper's switch and limits. Read at runtime, so they change without a rebuild.
# The Ruleferret's switch and limits. Read at runtime, so they change without a rebuild.
RULES_CHAT_ENABLED=false
# "true" lets any signed-in user in without the CRM employee check. Only a development server honours it.
RULES_CHAT_DEV_ACCESS=false
# Questions a day for signed-in GitHub users who aren't SSW staff. 0 keeps The Rulekeeper to staff.
# Questions a day for signed-in GitHub users who aren't SSW staff. 0 keeps The Ruleferret to staff.
RULES_CHAT_MEMBER_DAILY_LIMIT=0
# Non-staff are paused for the rest of the month once the estimated cost reaches this.
RULES_CHAT_MONTHLY_BUDGET_USD=100
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy-infrastructure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ jobs:
# PR deploys run this workflow from main against the PR's own commit. An older branch's deploy.ps1 can't take
# the parameter below, and its Bicep would remove the shared Rules Chat identity from the staging site.
if (-not (Get-Command ./infra/deploy.ps1).Parameters.ContainsKey('RulesChatSqlAdminClientId')) {
throw "This branch predates The Rulekeeper infrastructure. Merge or rebase main into it, then deploy again."
throw "This branch predates The Ruleferret infrastructure. Merge or rebase main into it, then deploy again."
}

# The pipeline administers the Rules Chat database, so it can apply schema changes on every deploy.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/rules-chat-index.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Index Rules for The Rulekeeper
name: Index Rules for The Ruleferret

# Starts the Container Apps index job now, instead of waiting for its hourly run, and waits for it to finish. Without
# options it embeds only the new and changed rules, like the hourly run.
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ const { data } = await client.queries.latestRulesQuery({
3. Add to `embedTemplates` in TinaCMS schema
4. Add to `getMarkdownComponentMapping()` for rendering

## The Rulekeeper (Rules Chat)
## The Ruleferret (Rules Chat)

The AI chat that answers questions from the rules. To run it locally, see `docs/rulekeeper/local-development.md`. Its Azure resources are described in `infra/README.md`.
The AI chat that answers questions from the rules. To run it locally, see `docs/ruleferret/local-development.md`. Its Azure resources are described in `infra/README.md`.

## Architecture Decision Records

Expand Down
16 changes: 8 additions & 8 deletions __tests__/components/SearchDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { onAskRulekeeper } from "@/components/chat/askRulekeeper";
import { onAskRuleferret } from "@/components/chat/askRuleferret";
import { openSearch } from "@/components/search/openSearch";
import { SearchDialog } from "@/components/search/SearchDialog";
import { SearchTrigger } from "@/components/search/SearchTrigger";
Expand Down Expand Up @@ -149,17 +149,17 @@ describe("SearchDialog", () => {
});
});

describe("Ask The Rulekeeper", () => {
describe("Ask The Ruleferret", () => {
function recordQuestions() {
const questions: string[] = [];
const stop = onAskRulekeeper((question) => questions.push(question));
const stop = onAskRuleferret((question) => questions.push(question));
return { questions, stop };
}

it("isn't offered without chat access", async () => {
await openAndType("pull");
await screen.findByRole("option", { name: /over the shoulder/ });
expect(screen.queryByRole("option", { name: /Ask The Rulekeeper/ })).not.toBeInTheDocument();
expect(screen.queryByRole("option", { name: /Ask The Ruleferret/ })).not.toBeInTheDocument();
});

it("asks with an empty box on Enter", async () => {
Expand All @@ -169,7 +169,7 @@ describe("Ask The Rulekeeper", () => {
render(<SearchDialog />);
act(() => openSearch());
await screen.findByRole("combobox", { name: "Search rules" });
expect(screen.getAllByRole("option")[0]).toHaveTextContent("Ask The Rulekeeper");
expect(screen.getAllByRole("option")[0]).toHaveTextContent("Ask The Ruleferret");
await user.keyboard("{Enter}");
expect(questions).toEqual([""]);
stop();
Expand All @@ -181,18 +181,18 @@ describe("Ask The Rulekeeper", () => {
const { user } = await openAndType("pull request");
await screen.findByRole("option", { name: /over the shoulder/ });
const options = screen.getAllByRole("option");
expect(options[0]).toHaveTextContent("Ask The Rulekeeper");
expect(options[0]).toHaveTextContent("Ask The Ruleferret");
expect(options[1]).toHaveTextContent("over the shoulder");
await user.keyboard("{Enter}");
expect(questions).toEqual(["pull request"]);
expect(push).not.toHaveBeenCalled();
stop();
});

it("names The Rulekeeper on the header button", () => {
it("names The Ruleferret on the header button", () => {
mockHasChatAccess = true;
render(<SearchTrigger />);
expect(screen.getByRole("button", { name: "Search rules or ask The Rulekeeper" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Search rules or ask The Ruleferret" })).toBeInTheDocument();
});
});

Expand Down
4 changes: 2 additions & 2 deletions __tests__/components/chat/refusalMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ describe("refusalMessage", () => {

it("names the day the monthly pause ends", () => {
const message = refusalMessage(429, { reason: "paused", retryAt: later(60 * 24 * 20) }, now);
expect(message).toMatch(/^The Rulekeeper has reached its limit for this month\. It's back on .+\.$/);
expect(message).toMatch(/^The Ruleferret has reached its limit for this month\. It's back on .+\.$/);
});

it("falls back to plain wording without a usable retry time", () => {
expect(refusalMessage(429, { reason: "day", retryAt: "not a date" }, now)).toBe("You've used today's questions. Try again tomorrow.");
});

it("explains a refused account and treats anything else as a failure", () => {
expect(refusalMessage(403, null, now)).toBe("The Rulekeeper isn't available for your account.");
expect(refusalMessage(403, null, now)).toBe("The Ruleferret isn't available for your account.");
expect(refusalMessage(500, null, now)).toBe(FAILURE_TEXT);
expect(refusalMessage(429, { reason: "unknown" }, now)).toBe(FAILURE_TEXT);
});
Expand Down
6 changes: 3 additions & 3 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const LIMIT_MESSAGES: Record<LimitReason, string> = {
busy: "One question at a time. Wait for the answer to finish.",
minute: "Too many questions in a minute. Wait a moment and try again.",
day: "You've reached today's question limit.",
paused: "The Rulekeeper has reached its monthly limit.",
paused: "The Ruleferret has reached its monthly limit.",
};

type ChatTurn = { role: "user" | "assistant"; content: string };
Expand Down Expand Up @@ -56,7 +56,7 @@ export async function POST(request: Request) {

const session = await getAuth0().getSession();
const tier = await getRulesChatTier(session?.user);
if (!session || !tier) return NextResponse.json({ error: "The Rulekeeper is not available for this account" }, { status: 403 });
if (!session || !tier) return NextResponse.json({ error: "The Ruleferret is not available for this account" }, { status: 403 });
const userSub = session.user.sub;

const read = await readJsonBody(request);
Expand All @@ -73,7 +73,7 @@ export async function POST(request: Request) {
started = await startQuestion(userSub, tier, getRulesChatLimits());
} catch (error) {
console.error("[RulesChat] usage check failed:", error);
return NextResponse.json({ error: "The Rulekeeper is unavailable right now. Try again soon." }, { status: 503 });
return NextResponse.json({ error: "The Ruleferret is unavailable right now. Try again soon." }, { status: 503 });
}
if (!started.allowed) {
const { reason, retryAt } = started;
Expand Down
2 changes: 1 addition & 1 deletion app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getAuth0 } from "@/lib/auth0";
import { getRulesChatTier } from "@/lib/rulesChatAccess";

export const metadata: Metadata = {
title: "The Rulekeeper",
title: "The Ruleferret",
robots: { index: false },
};

Expand Down
14 changes: 7 additions & 7 deletions components/chat/ChatConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import Link from "next/link";
import { type ComponentType, type FormEvent, type KeyboardEvent, lazy, Suspense, useEffect, useRef, useState } from "react";
import type { Options as MarkdownOptions } from "react-markdown";
import { MAX_MESSAGE_CHARS } from "@/lib/rulesChat/limits";
import { onAskRulekeeper } from "./askRulekeeper";
import { onAskRuleferret } from "./askRuleferret";
import { isCitedRuleLink, linkCitations } from "./citations";
import { RulekeeperMark } from "./RulekeeperMark";
import { RuleferretMark } from "./RuleferretMark";
import { type ChatMessage, useChatMessages } from "./useChatMessages";

const SUGGESTIONS = ["Do you have a rule about pull requests?", "Do you have a rule about email etiquette?", "Do you have a rule about Definition of Done?"];
Expand All @@ -30,8 +30,8 @@ const ReactMarkdown = lazy(loadMarkdown);
export function ChatTitle() {
return (
<div className="flex min-w-0 items-center gap-2 text-white">
<RulekeeperMark size="small" />
<span className="truncate font-semibold">The Rulekeeper</span>
<RuleferretMark size="small" />
<span className="truncate font-semibold">The Ruleferret</span>
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs font-medium text-white">Beta</span>
</div>
);
Expand Down Expand Up @@ -129,7 +129,7 @@ export function ChatConversation({
if (pending) setDraft(question.slice(0, MAX_MESSAGE_CHARS));
else send(question.slice(0, MAX_MESSAGE_CHARS));
};
useEffect(() => onAskRulekeeper((question) => askFromElsewhere.current(question)), []);
useEffect(() => onAskRuleferret((question) => askFromElsewhere.current(question)), []);

const submit = (text: string) => {
const question = text.trim();
Expand All @@ -155,8 +155,8 @@ export function ChatConversation({
<div ref={scroller} className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-4">
{messages.length === 0 && !pending ? (
<div className="m-auto flex flex-col items-center gap-3 text-center">
<RulekeeperMark size="large" />
<p className="text-lg font-semibold text-ssw-black">Ask The Rulekeeper about SSW Rules</p>
<RuleferretMark size="large" />
<p className="text-lg font-semibold text-ssw-black">Ask The Ruleferret about SSW Rules</p>
<p className="max-w-72 text-sm text-gray-600">There's a rule for that. Probably several. Answers link to the rules they come from.</p>
<div className="mt-2 flex flex-col gap-2">
{SUGGESTIONS.map((suggestion) => (
Expand Down
14 changes: 7 additions & 7 deletions components/chat/ChatWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { usePathname } from "next/navigation";
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
import { useIsClient, useLocalStorage, useMediaQuery, useSessionStorage, useWindowSize } from "usehooks-ts";
import { cn } from "@/lib/utils";
import { onAskRulekeeper } from "./askRulekeeper";
import { onAskRuleferret } from "./askRuleferret";
import { type ChatActivity, ChatConversation, ChatTitle } from "./ChatConversation";
import { CHAT_POPOUT_PATH } from "./chatPopOut";
import {
Expand All @@ -22,7 +22,7 @@ import {
resizePanel,
usePointerDrag,
} from "./floating";
import { RulekeeperMark } from "./RulekeeperMark";
import { RuleferretMark } from "./RuleferretMark";
import { useClearChatOfOtherUsers } from "./useChatMessages";
import { useRulesChatAccess } from "./useRulesChatAccess";

Expand Down Expand Up @@ -87,7 +87,7 @@ export function ChatWidget() {
if (focusPillOnHide.current && mode === "minimised") pillOpenButton.current?.focus();
focusPillOnHide.current = false;
}, [mode]);
useEffect(() => onAskRulekeeper(() => setMode("open")), [setMode]);
useEffect(() => onAskRuleferret(() => setMode("open")), [setMode]);

// An answer that finishes while the chat is out of view is flagged on the pill until the chat is opened.
useEffect(() => {
Expand Down Expand Up @@ -127,7 +127,7 @@ export function ChatWidget() {
return (
<>
<section
aria-label="The Rulekeeper"
aria-label="The Ruleferret"
onKeyDown={onPanelKeyDown}
// Stays mounted while hidden so a half-typed question and a streaming answer survive.
className={cn(
Expand Down Expand Up @@ -189,11 +189,11 @@ export function ChatWidget() {
type="button"
onClick={() => setMode("open")}
className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-full pr-3 pl-1.5 focus-visible:outline-2 focus-visible:outline-ssw-red focus-visible:outline-offset-2"
aria-label={hasUnread ? "Open The Rulekeeper, new answer" : "Open The Rulekeeper"}
aria-label={hasUnread ? "Open The Ruleferret, new answer" : "Open The Ruleferret"}
>
<RulekeeperMark size="small" />
<RuleferretMark size="small" />
<span className="min-w-0 flex-1 text-left leading-tight">
<span className="block truncate font-semibold text-sm">The Rulekeeper</span>
<span className="block truncate font-semibold text-sm">The Ruleferret</span>
<span className={cn("block truncate text-xs", hasUnread ? "text-white" : "text-white/70")}>{pillStatus(activity, hasUnread)}</span>
</span>
{hasUnread && <span className="size-2.5 shrink-0 rounded-full bg-ssw-red ring-2 ring-ssw-black" aria-hidden />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";

// Kept apart from ChatConversation so the search dialog, on every page, doesn't load the chat's markdown renderer.
export function RulekeeperMark({ size }: { size: "small" | "large" }) {
export function RuleferretMark({ size }: { size: "small" | "large" }) {
return (
<span
aria-hidden
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
const ASK_EVENT = "rulekeeper:ask";
const ASK_EVENT = "ruleferret:ask";

// Opens The Rulekeeper from elsewhere on the page, such as the search dialog. With a question it is
// Opens The Ruleferret from elsewhere on the page, such as the search dialog. With a question it is
// asked straight away; without one the chat opens ready for typing.
export function askRulekeeper(question = "") {
export function askRuleferret(question = "") {
window.dispatchEvent(new CustomEvent<string>(ASK_EVENT, { detail: question }));
}

export function onAskRulekeeper(listener: (question: string) => void): () => void {
export function onAskRuleferret(listener: (question: string) => void): () => void {
const handler = (event: Event) => listener((event as CustomEvent<string>).detail);
window.addEventListener(ASK_EVENT, handler);
return () => window.removeEventListener(ASK_EVENT, handler);
Expand Down
6 changes: 3 additions & 3 deletions components/chat/refusalMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function dayAndTime(retryAt: Date, now: Date): string {

// What to tell the user when the chat API turns a question down, with times in their own time zone.
export function refusalMessage(status: number, body: RefusalBody, now = new Date()): string {
if (status === 403) return "The Rulekeeper isn't available for your account.";
if (status === 403) return "The Ruleferret isn't available for your account.";
// The request outgrew the server's limits. Asking again sends the same request, so only a new chat helps.
if (status === 400 || status === 413) return TOO_LONG_TEXT;
if (status !== 429) return FAILURE_TEXT;
Expand All @@ -38,8 +38,8 @@ export function refusalMessage(status: number, body: RefusalBody, now = new Date
: "You've used today's questions. Try again tomorrow.";
case "paused":
return retryAt
? `The Rulekeeper has reached its limit for this month. It's back on ${retryAt.toLocaleDateString([], { day: "numeric", month: "long" })}.`
: "The Rulekeeper has reached its limit for this month.";
? `The Ruleferret has reached its limit for this month. It's back on ${retryAt.toLocaleDateString([], { day: "numeric", month: "long" })}.`
: "The Ruleferret has reached its limit for this month.";
default:
return FAILURE_TEXT;
}
Expand Down
Loading
Loading