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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ PUBLIC_ANON_MAX_MESSAGES=20
PUBLIC_ANON_WINDOW_SECONDS=600

# --- Limits / quotas (protect shared free-tier rate limits) ---
MAX_UPLOAD_MB=20
MAX_UPLOAD_MB=100
TENANT_DAILY_TOKEN_QUOTA=200000
TENANT_MAX_DOCUMENTS=200
RETRIEVAL_TOP_K=5
Expand Down
9 changes: 6 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,12 @@ PENDING ──► UPLOADED ──► PARSING ──► CHUNKING ──► EMBEDD
`allowed_origins`, `widget`).
- `RetrievalConfig(top_k=5, min_score=0.0, rerank=False)` — `min_score` is the
cosine floor (0 = no filter). **`rerank` is a phase-2 placeholder.**
- `DEFAULT_SYSTEM_PROMPT` — instructs *"Answer using ONLY the provided context
below … Never answer from general knowledge"* and gives an exact refusal
string used as the grounding signal downstream.
- `DEFAULT_SYSTEM_PROMPT` — a warm recruiting-assistant persona that runs a
one-question-at-a-time candidate screening chat, grounds every concrete fact
(roles, salary ranges, benefits, visa, how to apply) in the retrieved
reference material rather than inventing them, and redirects off-topic
requests with an exact opener (*"I'm here to help with our open roles and your
application"*) used as the refusal/grounding signal downstream.
- `generate_public_key()` → `pk_<token>` (Stripe-style publishable key, safe to
embed). `origin_allowed()` implements the embed allowlist policy (exact match
or `*.example.com` wildcard; empty list = any origin).
Expand Down
4 changes: 2 additions & 2 deletions evals/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ def ndcg_at_k(retrieved: Sequence[str], relevant: Sequence[str], k: int) -> floa

# --- Deterministic generation / grounding checks ---------------------------------

# Mirrors AskChatbot._REFUSAL_PREFIX: the canonical refusal opener. Kept in sync
# Mirrors AskChatbot._REFUSAL_PREFIX: the canonical redirect opener. Kept in sync
# with the default system prompt so the harness recognises an in-policy refusal.
REFUSAL_PREFIX = "I can only answer questions about"
REFUSAL_PREFIX = "I'm here to help with our open roles and your application"


def is_refusal(answer: str) -> bool:
Expand Down
4 changes: 2 additions & 2 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RAG Platform</title>
<title>Kore AI — Enterprise RAG Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AssistantDetailPage from "./pages/AssistantDetailPage";
import KnowledgePage from "./pages/KnowledgePage";
import AnalyticsPage from "./pages/AnalyticsPage";
import SettingsPage from "./pages/SettingsPage";
import HiringAgentPage from "./pages/HiringAgentPage";

export default function App() {
return (
Expand All @@ -38,6 +39,7 @@ export default function App() {
<Route path="/assistants" element={<AssistantsPage />} />
<Route path="/assistants/:id" element={<AssistantDetailPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/hiring-agent" element={<HiringAgentPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/settings" element={<SettingsPage />} />

Expand Down
21 changes: 21 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ export class ApiError extends Error {
}
}

const AUTH_KEYS = ["access_token", "refresh_token", "tenant_id", "user_id"];

/**
* Called when an authenticated request is rejected with 401 — i.e. we sent a
* token but the server no longer accepts it (expired, revoked, or the backend's
* signing secret / DB was reset). Clear the dead session and bounce to login so
* the user re-authenticates instead of staring at a silently-failing page.
* A hard navigation guarantees all in-memory auth state resets.
*/
function handleExpiredSession(): void {
AUTH_KEYS.forEach((k) => localStorage.removeItem(k));
if (!window.location.pathname.startsWith("/login")) {
window.location.assign("/login");
}
}

async function request<T>(
path: string,
options: RequestInit = {},
Expand All @@ -26,6 +42,10 @@ async function request<T>(

const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (!res.ok) {
// A 401 on a request we *sent a token for* means the session is dead —
// clear it and redirect to login. (Login/register send no token, so a 401
// there is a real "bad credentials" error and is left for the caller.)
if (res.status === 401 && token) handleExpiredSession();
let detail = res.statusText;
try {
const body = await res.json();
Expand Down Expand Up @@ -74,6 +94,7 @@ export function streamSSE(
});

if (!res.ok || !res.body) {
if (res.status === 401 && token) handleExpiredSession();
handlers.onError?.(`HTTP ${res.status}`);
return;
}
Expand Down
115 changes: 115 additions & 0 deletions frontend/src/api/hiring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { api } from "./client";

// ── Types (mirror the backend hiring-agent schemas) ──

export interface PlanStep {
id: number;
key: string;
name: string;
tool: string | null;
description: string;
depends_on: number[];
args: Record<string, unknown>;
}

export interface ExecutionPlan {
goal: string;
role: string;
seniority: string;
steps: PlanStep[];
summary: string;
}

export interface PendingApproval {
step_id: number;
step_key: string;
step_name: string;
tool: string | null;
kind: "sensitive" | "failure" | string;
reason: string;
}

export interface ExecutedTask {
step: number;
tool: string | null;
status: string; // ok | skipped | simulated | failed | rejected
observation: string;
}

export interface ExecutionState {
run_id: string;
goal: string;
status: string;
cursor: number;
total_steps: number;
pending_approval: PendingApproval | null;
executed: ExecutedTask[];
summary: string;
}

export interface RunSummary {
run_id: string;
goal: string;
status: string;
cursor: number;
total_steps: number;
created_at: string;
updated_at: string;
}

export interface ToolOutput {
step: number;
tool: string | null;
observation: string;
data: Record<string, unknown>;
}

export interface CompletedStep {
step: number;
from_state: string;
to_state: string;
tool: string | null;
timestamp: string;
}

export interface HiringMemory {
run_id: string;
tenant_id: string;
status: string;
current_state: string;
completed_steps: CompletedStep[];
tool_outputs: ToolOutput[];
llm_reasoning: Array<Record<string, unknown>>;
conversation: Array<Record<string, unknown>>;
candidate_decisions: Array<Record<string, unknown>>;
execution: {
goal: string;
plan: PlanStep[];
cursor: number;
pending_approval: PendingApproval | null;
} | null;
error: string | null;
created_at: string;
updated_at: string;
}

// Ranking output shape (as stored in a rank_candidates tool output's `data`).
export interface RankedCandidate {
candidate_id: string;
rank: number;
overall_score: number;
explanation: string;
matching_skills: string[];
missing_skills: string[];
}

// ── API ──

export const hiringApi = {
plan: (goal: string) => api.post<ExecutionPlan>("/hiring-agent/plan", { goal }),
execute: (goal: string) => api.post<ExecutionState>("/hiring-agent/execute", { goal }),
approve: (run_id: string) => api.post<ExecutionState>("/hiring-agent/approve", { run_id }),
reject: (run_id: string) => api.post<ExecutionState>("/hiring-agent/reject", { run_id }),
listRuns: () => api.get<RunSummary[]>("/hiring-agent/runs"),
getRun: (run_id: string) => api.get<HiringMemory>(`/hiring-agent/runs/${run_id}`),
};
88 changes: 44 additions & 44 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ function KnowledgeIcon() { return <svg className="w-[18px] h-[18px]" fill="none
function AnalyticsIcon() { return <svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg>; }
function SettingsIcon() { return <svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>; }
function LogoutIcon() { return <svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /></svg>; }
function HiringIcon() { return <svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /></svg>; }

const NAV_SECTIONS: NavSection[] = [
{
items: [
{ to: "/home", label: "Home", icon: <HomeIcon />, exact: true },
{ to: "/assistants", label: "Assistants", icon: <AssistantsIcon /> },
{ to: "/knowledge", label: "Knowledge", icon: <KnowledgeIcon /> },
{ to: "/hiring-agent", label: "Hiring Agent", icon: <HiringIcon /> },
{ to: "/analytics", label: "Analytics", icon: <AnalyticsIcon /> },
],
},
Expand All @@ -40,45 +42,49 @@ export default function Layout() {

const orgInitial = tenantId ? tenantId.slice(0, 2).toUpperCase() : "AC";

const navClass = ({ isActive }: { isActive: boolean }) =>
`group flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-medium transition-colors ${
isActive
? "bg-gray-100 text-gray-900"
: "text-gray-500 hover:bg-gray-100/70 hover:text-gray-900"
}`;

return (
<div className="flex h-screen overflow-hidden bg-gray-50">
<div className="flex h-screen overflow-hidden bg-canvas">
{/* ── Sidebar ── */}
<nav
aria-label="Primary navigation"
className="w-[220px] flex-shrink-0 bg-white border-r border-gray-200 flex flex-col select-none"
className="w-[236px] flex-shrink-0 bg-white border-r border-gray-200/80 flex flex-col select-none"
>
{/* Logo */}
<div className="flex items-center gap-2.5 px-4 h-[56px] border-b border-gray-100 flex-shrink-0">
<div className="w-7 h-7 rounded-lg bg-brand-600 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2}>
<div className="flex items-center gap-2.5 px-4 h-[60px] flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-ink-900 flex items-center justify-center flex-shrink-0 shadow-xs">
<svg className="w-[18px] h-[18px] text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
</div>
<div>
<span className="font-semibold text-[13px] text-gray-900 tracking-tight block leading-none">Kore AI</span>
<div className="leading-none">
<span className="font-semibold text-[14px] text-gray-900 tracking-tight block">Kore AI</span>
<span className="text-[10px] text-gray-400 font-medium">Enterprise Platform</span>
</div>
</div>

{/* Nav */}
<div className="flex-1 px-2 py-3 overflow-y-auto">
<div className="flex-1 px-3 py-2 overflow-y-auto">
<p className="eyebrow px-3 pb-1.5 pt-2">Workspace</p>
{NAV_SECTIONS.map((section, si) => (
<ul key={si} className="space-y-0.5" role="list">
{section.items.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
end={item.exact}
className={({ isActive }) =>
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 ${
isActive
? "bg-brand-50 text-brand-700"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
}`
}
>
{item.icon}
{item.label}
<NavLink to={item.to} end={item.exact} className={navClass}>
{({ isActive }) => (
<>
<span className={isActive ? "text-brand-600" : "text-gray-400 group-hover:text-gray-500"}>
{item.icon}
</span>
{item.label}
</>
)}
</NavLink>
</li>
))}
Expand All @@ -87,40 +93,34 @@ export default function Layout() {
</div>

{/* Footer */}
<div className="border-t border-gray-100 px-2 py-2 space-y-0.5">
<NavLink
to="/settings"
className={({ isActive }) =>
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 ${
isActive
? "bg-brand-50 text-brand-700"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
}`
}
>
<SettingsIcon />
Settings
<div className="px-3 py-3 space-y-1">
<NavLink to="/settings" className={navClass}>
{({ isActive }) => (
<>
<span className={isActive ? "text-brand-600" : "text-gray-400 group-hover:text-gray-500"}>
<SettingsIcon />
</span>
Settings
</>
)}
</NavLink>

{/* Org row + logout */}
<div className="flex items-center gap-2 px-3 py-2 mt-1">
<div className="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center flex-shrink-0">
<span className="text-[9px] font-bold text-gray-600">{orgInitial}</span>
<div className="flex items-center gap-2.5 rounded-lg border border-gray-200/80 px-2.5 py-2 mt-1">
<div className="avatar w-7 h-7 text-[10px]">{orgInitial}</div>
<div className="flex-1 min-w-0 leading-tight">
<span className="text-[12.5px] text-gray-800 truncate block font-medium">My Organisation</span>
<span className="text-[10.5px] text-gray-400">Free plan</span>
</div>
<span className="text-[12px] text-gray-500 truncate flex-1 font-medium">My Organisation</span>
<button
onClick={handleLogout}
aria-label="Sign out"
className="p-1 rounded text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
<button onClick={handleLogout} aria-label="Sign out" className="icon-btn w-7 h-7">
<LogoutIcon />
</button>
</div>
</div>
</nav>

{/* ── Main ── */}
<main className="flex-1 overflow-y-auto min-h-0 bg-gray-50">
<main className="flex-1 overflow-y-auto min-h-0 bg-canvas">
<Outlet />
</main>
</div>
Expand Down
Loading
Loading