feat(platform): ship AI fallback stack and entity-scoped course planning

Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-26 02:34:52 +04:00
parent 096b042daf
commit 35ccc0dfb2
51 changed files with 5487 additions and 538 deletions

View File

@@ -0,0 +1,82 @@
import { api } from "@/lib/api-client";
/**
* AI provider & API-key settings client.
*
* Backed by `backend/custom_addons/encoach_ai/controllers/ai_settings_controller.py`
* (`/api/ai/settings/providers`). API keys are write-only — the GET response
* only ever exposes `<key>_set: boolean` markers, never the key itself.
*
* Provider switches take effect on the very next request (no caching),
* so the LangGraph runtime instantly picks up the new selection.
*/
export type CapabilityKey = "text" | "image" | "audio" | "video";
export type ProviderKind = "paid" | "free" | "auto";
export interface ProviderOption {
value: string;
label: string;
kind: ProviderKind;
}
export interface CapabilityState {
active: string;
options: ProviderOption[];
paid_with_credentials: string[];
}
export interface AISettingsState {
providers: Record<CapabilityKey, CapabilityState>;
/** Boolean markers — `true` means a value is stored, never the value itself. */
keys_set: Record<string, boolean>;
/** Plain (non-secret) parameters, e.g. region, model name. */
plain: Record<string, string>;
}
export interface ProviderTestResult {
capability: CapabilityKey;
active: string;
chain: { provider: string; ok: boolean; note: string }[];
}
export interface AISettingsPatchPayload {
/** Active provider per capability. */
providers?: Partial<Record<CapabilityKey, string>>;
/**
* API keys keyed by short name (`openai_api_key`, `aws_access_key`,
* `aws_secret_key`, `aws_region`, `elevenlabs_api_key`,
* `gptzero_api_key`, `paymob_api_key`, `paymob_integration_id`,
* `paymob_iframe_id`, `paymob_hmac_secret`).
*
* - Sending an empty string clears the key.
* - Omitting the field leaves it unchanged.
*/
keys?: Record<string, string>;
/** Plain (non-secret) values like `aws_region`, `openai_model`. */
plain?: Record<string, string>;
}
export const aiSettingsService = {
async get(): Promise<AISettingsState> {
const resp = await api.get<{ data: AISettingsState }>(
"/ai/settings/providers",
);
return resp.data;
},
async update(payload: AISettingsPatchPayload): Promise<AISettingsState> {
const resp = await api.patch<{ data: AISettingsState }>(
"/ai/settings/providers",
payload,
);
return resp.data;
},
async test(capability: CapabilityKey): Promise<ProviderTestResult> {
return api.post<ProviderTestResult>(
"/ai/settings/providers/test",
{ capability },
);
},
};

View File

@@ -54,6 +54,20 @@ export const coursePlanService = {
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
},
async updateMaterial(
materialId: number,
payload: {
title?: string;
summary?: string;
body?: Record<string, unknown>;
body_text?: string;
share_date?: string | null;
is_static?: boolean;
},
): Promise<{ data: CoursePlanMaterial }> {
return api.patch(`/ai/course-plan/material/${materialId}`, payload);
},
// ---------------------------------------------------------------------
// Phase A — Sources
// ---------------------------------------------------------------------
@@ -101,6 +115,28 @@ export const coursePlanService = {
return api.delete(`/ai/course-plan/${planId}/sources/${sourceId}`);
},
/**
* Attach existing items from /admin/resources to a course plan as RAG
* sources. Returns the rows that were newly attached plus the ids
* that were skipped (already linked) or missing (deleted from the
* library) so the caller can show a clear toast — e.g. "Attached 2,
* skipped 1 already-linked".
*/
async attachResources(
planId: number,
resourceIds: number[],
): Promise<{
attached: CoursePlanSource[];
skipped_existing: number[];
missing: number[];
count: number;
}> {
return api.post(
`/ai/course-plan/${planId}/sources/from-resources`,
{ resource_ids: resourceIds },
);
},
// ---------------------------------------------------------------------
// Phase B — Deliverables / progress
// ---------------------------------------------------------------------
@@ -191,6 +227,12 @@ export const coursePlanService = {
student_user_ids: number[];
due_date?: string | null;
message?: string;
}
| {
mode: "entities";
entity_ids: number[];
due_date?: string | null;
message?: string;
},
): Promise<{ data: CoursePlanAssignment }> {
return api.post(`/ai/course-plan/${planId}/assignments`, payload);

View File

@@ -2,6 +2,14 @@ import { api } from "@/lib/api-client";
import { asPaginated, asRecordData } from "@/lib/odoo-api";
import type { Entity, EntityRole, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
export interface EntityUser {
id: number;
name: string;
login: string;
email: string;
active: boolean;
}
export const entitiesService = {
async list(params?: PaginationParams): Promise<PaginatedResponse<Entity>> {
const raw = await api.get<unknown>("/entities", params as Record<string, string | number | boolean | undefined>);
@@ -42,4 +50,20 @@ export const entitiesService = {
async getPermissions(entityId: number): Promise<string[]> {
return api.get<string[]>(`/permissions`, { entity_id: entityId });
},
async listEntityUsers(entityId: number): Promise<EntityUser[]> {
const raw = await api.get<unknown>(`/entities/${entityId}/users`);
const out = asPaginated<EntityUser>(raw);
return out.items;
},
async updateEntityUsers(entityId: number, userIds: number[]): Promise<ApiSuccessResponse> {
return api.patch<ApiSuccessResponse>(`/entities/${entityId}/users`, { user_ids: userIds });
},
async listPlatformUsers(params?: PaginationParams): Promise<EntityUser[]> {
const raw = await api.get<unknown>("/users/list", params as Record<string, string | number | boolean | undefined>);
const out = asPaginated<EntityUser>(raw);
return out.items;
},
};

View File

@@ -42,12 +42,27 @@ export const resourcesService = {
return api.post<ApiSuccessResponse>(`/resources/${id}/rate`, { rating });
},
async download(id: number): Promise<Blob> {
/**
* Downloads the binary and returns the blob alongside the filename
* the server suggested via ``Content-Disposition``. Callers should
* prefer that filename over the human ``name`` on the record so the
* extension is preserved (".pdf" / ".mp3" / ".png" etc.) — the
* legacy code dropped the extension because the human name was just
* "test".
*/
async download(id: number): Promise<{ blob: Blob; filename: string }> {
const res = await fetch(`${API_BASE_URL}/resources/${id}/download`, {
headers: { Authorization: `Bearer ${localStorage.getItem("encoach_token") ?? ""}` },
});
if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
return res.blob();
const cd = res.headers.get("content-disposition") || "";
let filename = "";
// Match either ``filename*=UTF-8''…`` (RFC 5987) or ``filename="…"``
const match =
/filename\*\s*=\s*[^']*''([^;]+)/i.exec(cd) ||
/filename\s*=\s*"?([^";]+)"?/i.exec(cd);
if (match) filename = decodeURIComponent(match[1].trim());
return { blob: await res.blob(), filename };
},
// Tag management