Core AI runtime - New encoach.ai.agent + encoach.ai.tool models with M2M tool binding, graph topology (simple|plan_review_revise|rag|react), model + fallback, temperature, max_tokens, response_format, max_revisions, quality checks and system prompt fields. - services/agent_runtime.py compiles a langgraph.StateGraph per agent and caches the build per (key, write_date). Emits a structured trace (output, tool_calls, retrieval_hits, revisions, quality_issues, ms, model_used, fallback_used) and auto-falls-back on rate-limit/5xx. - services/agent_tools.py registers 11 tool handlers wrapping existing services: resources.search, rubric.fetch, outcomes.fetch, student.profile, quality.cefr_check, quality.ai_detect, quality.content_gate, course_plan.save (mutates), course_plan.save_materials (mutates), scoring.grade_writing, scoring.grade_speaking. - 7 default agents seeded via data/agents_defaults.xml: course_planner, course_week_materials, exam_generator, exercise_generator, lms_tutor, writing_grader, speaking_grader. - Feature flag encoach_ai.use_langgraph_runtime (default True). - encoach_ai_course pipeline now routes through AgentRuntime when on, legacy SDK path kept as fallback. Admin UI - /admin/ai/prompts is now a tabbed Agents | Tools | Prompts console. - AIAgentsPanel: card grid + config dialog (model/temp/graph/tools/ system prompt) + built-in Test Runner showing live trace. - AIToolsPanel: registry table with category badges, mutates flag, schema viewer, edit dialog. - New /api/ai/agents* and /api/ai/tools* controller (list/get/update/ test, list-tools, toggle-tool). - Sidebar label nav.aiPrompts -> nav.aiAgents (AI Agents and Tools). - EN + AR (RTL) translations for ~80 new keys. Smart Wizard pages - /admin/quick-setup hub + CourseWizard, CoursePlanWizard, RubricWizard, ExamStructureWizard step-by-step flows. - /admin/course-plans list + detail pages. - /teacher/quick-setup mirror. Full demo seed + 8-role E2E - seed_full_demo.py adds the 5 missing user_types (approver, corporate, mastercorporate, agent, developer), activates a 2-stage exam-approval workflow with one pending request, creates a GE1-aligned 12-week B1 course plan with 6 detailed Week-1 materials (reading 400w, writing, listening 4-min script, speaking, grammar present simple vs continuous, vocabulary), and inserts sample ai.log + ai.feedback rows. - reset_demo_passwords.py forces every demo login back to canonical passwords (admin123/teacher123/student123/approver123/corporate123/ master123/agent123/dev123). - e2e_full_scenario.py: 46/46 PASS read-only API smoke across all 8 roles, including a live LangGraph round-trip on writing_grader. - e2e_approval_chain.py: 6/6 PASS mutation E2E - approver approves stage 1, admin approves stage 2, linked encoach.exam.custom flips to status=published, verified via psql. Docs - docs/PROJECT_SUMMARY.md updated to 2026-04-25: new Latest events bullets, refreshed credentials table, full sections 22 (LangGraph runtime) and 23 (full demo seed + 8-role E2E). - docs/ENCOACH_FULL_DEMO_QA_REPORT.md added with credentials, per-endpoint PASS/FAIL, mutation chain proof, LangGraph live output. - backend/GE1 Course Outline_ Fall AY25-26.pdf vendored as the reference outline the GE1 plan/materials are aligned to. Dependencies - requirements.txt: langgraph>=0.2.0, langchain-core>=0.3.0. - encoach_ai/__manifest__.py: external_dependencies updated. Made-with: Cursor
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
/**
|
|
* Odoo REST client with transparent access-token rotation.
|
|
*
|
|
* Tokens live in ``localStorage`` under three keys:
|
|
* - ``encoach_token`` — short-lived access JWT (1h)
|
|
* - ``encoach_refresh_token`` — long-lived refresh JWT (7d)
|
|
* - ``encoach_token_exp`` — epoch seconds for the access token
|
|
*
|
|
* When a request receives ``401`` *and* a refresh token is present, the client
|
|
* silently rotates the pair via ``POST /api/auth/refresh`` and retries the
|
|
* original request exactly once. Multiple concurrent 401s coalesce onto the
|
|
* same refresh promise so we never fire more than one rotation in flight.
|
|
*
|
|
* Motivation: before this change, every expired access token forced a full
|
|
* re-login, which interrupted long admin dashboards (stats, reports) multiple
|
|
* times per hour. With the refresh loop, access tokens can be short-lived
|
|
* (1h) without hurting UX, giving us the security benefit of short access
|
|
* windows without an eager logout.
|
|
*/
|
|
|
|
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
|
|
const BASE_URL = API_BASE_URL;
|
|
|
|
export function extractApiErrorMessage(data: unknown, status: number, statusText: string): string {
|
|
if (data && typeof data === "object" && data !== null && "error" in data) {
|
|
const msg = (data as { error: unknown }).error;
|
|
if (msg != null && String(msg).trim()) return String(msg);
|
|
}
|
|
return `${status} ${statusText}`;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public statusText: string,
|
|
public data: unknown,
|
|
) {
|
|
super(extractApiErrorMessage(data, status, statusText));
|
|
this.name = "ApiError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Turn an arbitrary thrown value from an API mutation into a human-readable,
|
|
* actionable toast description. Deployment QA kept reporting generic
|
|
* "Submit failed" / "Upload failed" toasts — this helper surfaces the HTTP
|
|
* status and, when we recognise the code, a hint about what to do next
|
|
* (payload too big → ask ops to raise nginx, timeout → retry, etc.).
|
|
*
|
|
* @param err the error thrown by an `api.*` call (usually an ApiError)
|
|
* @param fallback text to show if we can't classify the error
|
|
* @param context short context word used in the 413/504 hints
|
|
* ("file", "request", "payload" …)
|
|
*/
|
|
export function describeApiError(
|
|
err: unknown,
|
|
fallback = "Something went wrong",
|
|
context = "request",
|
|
): string {
|
|
const e = err as { status?: number; message?: string; data?: unknown } | null;
|
|
const status = e?.status;
|
|
// Try to extract a server-side error body even if the generic ApiError
|
|
// message wasn't populated (nginx 413/504 respond with an HTML page so
|
|
// `data.error` is empty and `message` looks like "413 ").
|
|
const serverMsg =
|
|
e?.data && typeof e.data === "object" && "error" in (e.data as object)
|
|
? String((e.data as { error: unknown }).error || "").trim()
|
|
: "";
|
|
|
|
if (status === 413) {
|
|
return `The ${context} is too large for the server to accept (HTTP 413). Try a smaller file / fewer tasks, or ask an admin to raise the nginx \`client_max_body_size\` and Odoo \`limit_request\`.`;
|
|
}
|
|
if (status === 504) {
|
|
return `The server took too long to respond (HTTP 504). The ${context} may still be processing — wait a minute and reload before retrying.`;
|
|
}
|
|
if (status === 502) {
|
|
return "The backend is unreachable (HTTP 502). Odoo may be restarting — try again in a moment.";
|
|
}
|
|
if (status === 401) {
|
|
return "Your session expired — please sign in again.";
|
|
}
|
|
if (status === 403) {
|
|
return serverMsg || "You don't have permission to do that (HTTP 403).";
|
|
}
|
|
if (status === 404) {
|
|
return serverMsg || "The requested item no longer exists (HTTP 404).";
|
|
}
|
|
if (status === 422 || status === 400) {
|
|
return serverMsg || `The server rejected the ${context} as invalid (HTTP ${status}).`;
|
|
}
|
|
if (status && status >= 500) {
|
|
return `${serverMsg || "The server returned an error"} (HTTP ${status}). Check the Odoo logs for a stack trace.`;
|
|
}
|
|
|
|
// Network failure / fetch aborted / CORS — ApiError not thrown at all.
|
|
if (!status && e?.message) return e.message;
|
|
|
|
return fallback;
|
|
}
|
|
|
|
const ACCESS_KEY = "encoach_token";
|
|
const REFRESH_KEY = "encoach_refresh_token";
|
|
const EXP_KEY = "encoach_token_exp";
|
|
|
|
function getAccessToken(): string | null {
|
|
return localStorage.getItem(ACCESS_KEY);
|
|
}
|
|
|
|
function getRefreshToken(): string | null {
|
|
return localStorage.getItem(REFRESH_KEY);
|
|
}
|
|
|
|
export function setToken(token: string): void {
|
|
localStorage.setItem(ACCESS_KEY, token);
|
|
}
|
|
|
|
export function setRefreshToken(token: string | null | undefined): void {
|
|
if (token) {
|
|
localStorage.setItem(REFRESH_KEY, token);
|
|
} else {
|
|
localStorage.removeItem(REFRESH_KEY);
|
|
}
|
|
}
|
|
|
|
export function setTokenExpiry(epochSeconds: number | null | undefined): void {
|
|
if (epochSeconds && Number.isFinite(epochSeconds)) {
|
|
localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds)));
|
|
} else {
|
|
localStorage.removeItem(EXP_KEY);
|
|
}
|
|
}
|
|
|
|
/** Persist the full token bundle returned by /api/login or /api/auth/refresh. */
|
|
export function persistTokenBundle(bundle: {
|
|
access_token?: string;
|
|
token?: string;
|
|
refresh_token?: string;
|
|
expires_in?: number;
|
|
}): void {
|
|
const access = bundle.access_token || bundle.token;
|
|
if (access) setToken(access);
|
|
setRefreshToken(bundle.refresh_token || null);
|
|
if (bundle.expires_in) {
|
|
setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in);
|
|
}
|
|
}
|
|
|
|
export function clearToken(): void {
|
|
localStorage.removeItem(ACCESS_KEY);
|
|
localStorage.removeItem(REFRESH_KEY);
|
|
localStorage.removeItem(EXP_KEY);
|
|
}
|
|
|
|
// Shared refresh promise — any 401 that arrives while a refresh is in flight
|
|
// waits on the existing request instead of firing a duplicate.
|
|
let refreshPromise: Promise<boolean> | null = null;
|
|
|
|
async function performRefresh(): Promise<boolean> {
|
|
const refreshToken = getRefreshToken();
|
|
if (!refreshToken) return false;
|
|
try {
|
|
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
});
|
|
if (!res.ok) return false;
|
|
const data: {
|
|
access_token?: string;
|
|
token?: string;
|
|
refresh_token?: string;
|
|
expires_in?: number;
|
|
} = await res.json().catch(() => ({} as never));
|
|
if (!data.access_token && !data.token) return false;
|
|
persistTokenBundle(data);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function refreshOnce(): Promise<boolean> {
|
|
if (!refreshPromise) {
|
|
refreshPromise = performRefresh().finally(() => {
|
|
refreshPromise = null;
|
|
});
|
|
}
|
|
return refreshPromise;
|
|
}
|
|
|
|
function getCurrentLanguage(): string {
|
|
// Mirrors the i18n bootstrap in src/i18n/index.ts: the user's explicit
|
|
// pick wins; everything else falls back to English. We intentionally do
|
|
// not consult navigator.language here so AI-generated content stays in
|
|
// English on first visit (matching the UI default) until the user flips
|
|
// the language toggle.
|
|
try {
|
|
const stored = localStorage.getItem("encoach-lang");
|
|
if (stored) return stored;
|
|
} catch {
|
|
// localStorage unavailable (SSR, sandboxing, etc.)
|
|
}
|
|
return "en";
|
|
}
|
|
|
|
function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> {
|
|
const headers: Record<string, string> = { ...extra };
|
|
if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
|
|
|
const token = getAccessToken();
|
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
|
|
// Tell the backend which UI language the user is on so AI-generated content
|
|
// (coaching tips, insights, alerts, narratives) can be returned in the same
|
|
// language instead of always defaulting to English.
|
|
if (!headers["Accept-Language"]) {
|
|
headers["Accept-Language"] = getCurrentLanguage();
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
/**
|
|
* Query param values we know how to serialise into a URL. Arrays are joined
|
|
* with commas because that's what our Odoo controllers expect for multi-value
|
|
* filters (e.g. `?state=draft,confirmed`).
|
|
*/
|
|
export type QueryParamValue =
|
|
| string
|
|
| number
|
|
| boolean
|
|
| null
|
|
| undefined
|
|
| Array<string | number | boolean>;
|
|
|
|
/**
|
|
* Accept any object-shaped bag of query params. We intentionally use `object`
|
|
* here rather than `Record<string, QueryParamValue>` because typed interfaces
|
|
* (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by
|
|
* default, which would force every call-site to cast.
|
|
*/
|
|
export type QueryParams = object;
|
|
|
|
function buildUrl(path: string, params?: QueryParams): string {
|
|
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
|
if (params) {
|
|
for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
|
|
if (rawValue === undefined || rawValue === null) continue;
|
|
if (Array.isArray(rawValue)) {
|
|
if (rawValue.length === 0) continue;
|
|
url.searchParams.set(key, rawValue.map(v => String(v)).join(","));
|
|
continue;
|
|
}
|
|
url.searchParams.set(key, String(rawValue));
|
|
}
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
async function parseResponse<T>(response: Response): Promise<T> {
|
|
const data = await response.json().catch(() => null);
|
|
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
|
|
return data as T;
|
|
}
|
|
|
|
type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean };
|
|
|
|
async function performRequest<T>(url: string, init: RequestInitWithSkip): Promise<T> {
|
|
const response = await fetch(url, init);
|
|
|
|
// Auth/refresh endpoints opt out of the retry loop — otherwise a bad
|
|
// refresh token would recurse forever.
|
|
const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login");
|
|
|
|
if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) {
|
|
const hadRefresh = !!getRefreshToken();
|
|
if (hadRefresh) {
|
|
const rotated = await refreshOnce();
|
|
if (rotated) {
|
|
// Rebuild headers so the new access token is attached.
|
|
const newInit: RequestInitWithSkip = {
|
|
...init,
|
|
_skipRetry: true,
|
|
headers: {
|
|
...(init.headers as Record<string, string>),
|
|
Authorization: `Bearer ${getAccessToken()}`,
|
|
},
|
|
};
|
|
return performRequest<T>(url, newInit);
|
|
}
|
|
}
|
|
const hadAccess = !!getAccessToken();
|
|
clearToken();
|
|
if (hadAccess || hadRefresh) {
|
|
// Use SPA-style navigation when possible; fall back to a hard nav only
|
|
// when we're inside a worker / non-browser context. A full document
|
|
// reload here used to feel like "the browser refreshes on every click"
|
|
// whenever an access token silently expired.
|
|
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
|
|
window.history.pushState({}, "", "/login");
|
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
}
|
|
}
|
|
throw new ApiError(401, response.statusText, await response.json().catch(() => null));
|
|
}
|
|
|
|
return parseResponse<T>(response);
|
|
}
|
|
|
|
export const api = {
|
|
async get<T>(path: string, params?: QueryParams): Promise<T> {
|
|
return performRequest<T>(buildUrl(path, params), {
|
|
method: "GET",
|
|
headers: buildHeaders(),
|
|
});
|
|
},
|
|
|
|
async post<T>(path: string, body?: unknown): Promise<T> {
|
|
return performRequest<T>(buildUrl(path), {
|
|
method: "POST",
|
|
headers: buildHeaders(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
},
|
|
|
|
async patch<T>(path: string, body?: unknown): Promise<T> {
|
|
return performRequest<T>(buildUrl(path), {
|
|
method: "PATCH",
|
|
headers: buildHeaders(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
},
|
|
|
|
async put<T>(path: string, body?: unknown): Promise<T> {
|
|
return performRequest<T>(buildUrl(path), {
|
|
method: "PUT",
|
|
headers: buildHeaders(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
},
|
|
|
|
async delete<T>(path: string): Promise<T> {
|
|
return performRequest<T>(buildUrl(path), {
|
|
method: "DELETE",
|
|
headers: buildHeaders(),
|
|
});
|
|
},
|
|
|
|
async upload<T>(path: string, formData: FormData): Promise<T> {
|
|
return performRequest<T>(buildUrl(path), {
|
|
method: "POST",
|
|
headers: buildHeaders(undefined, false),
|
|
body: formData,
|
|
});
|
|
},
|
|
};
|