mirror of
https://github.com/hanzoai/extension.git
synced 2026-11-01 18:02:17 +00:00
@hanzo/canvas-lms: an embedded-app panel + OAuth/API-proxy service over the Canvas REST API, built on @hanzo/ai (api.hanzo.ai /v1) and @hanzo/iam, mirroring packages/procore. - Canvas OAuth2 Authorization-Code (server-side secret + non-rotating refresh) - Pure Canvas REST v1 wrappers (courses/assignments/submissions/discussions/ pages/modules/enrollments) + RFC 5988 Link-header pagination + HTML->text - Course-context windowing with honest truncation - Five AI actions: summarize course, draft feedback, generate questions, summarize submissions, draft announcement; gated write-backs (submission comment via PUT, announcement via POST) - 119 vitest tests; tsc --noEmit clean; esbuild build green - Registered in pnpm-workspace.yaml
90 lines
3.7 KiB
TypeScript
90 lines
3.7 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import type { AiClient, ChatCompletion, ChatCompletionCreateParams } from '@hanzo/ai';
|
|
import { ACTIONS, actionList, actionPrompt, isActionId, runAction } from '../src/actions.js';
|
|
import { buildCourseContext } from '../src/course.js';
|
|
|
|
const ctx = buildCourseContext([{ title: 'Assignments', blocks: ['Assignment: Lab Report 1'] }]);
|
|
|
|
function mockClient(text: string) {
|
|
const reply: ChatCompletion = {
|
|
id: 'c', object: 'chat.completion', created: 0, model: 'zen5',
|
|
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
|
|
};
|
|
const create = vi.fn(async (_p: ChatCompletionCreateParams) => reply);
|
|
return { client: { chat: { completions: { create } }, models: { list: vi.fn() } } as unknown as AiClient, create };
|
|
}
|
|
|
|
describe('actions: catalog', () => {
|
|
it('exposes exactly the five documented actions', () => {
|
|
expect(Object.keys(ACTIONS).sort()).toEqual(
|
|
['draftAnnouncement', 'draftFeedback', 'generateQuestions', 'summarizeCourse', 'summarizeSubmissions'].sort(),
|
|
);
|
|
});
|
|
|
|
it('every action has a non-empty label and prompt', () => {
|
|
for (const [id, a] of Object.entries(ACTIONS)) {
|
|
expect(a.label.length, id).toBeGreaterThan(0);
|
|
expect(a.prompt.length, id).toBeGreaterThan(20);
|
|
}
|
|
});
|
|
|
|
it('actionList mirrors the catalog order and shape', () => {
|
|
const list = actionList();
|
|
expect(list.map((a) => a.id)).toEqual(Object.keys(ACTIONS));
|
|
expect(list[0]).toEqual({ id: 'summarizeCourse', label: ACTIONS.summarizeCourse.label });
|
|
});
|
|
});
|
|
|
|
describe('actions: prompt intent', () => {
|
|
it('summarizeCourse grounds on the syllabus and modules', () => {
|
|
const p = actionPrompt('summarizeCourse');
|
|
expect(p).toMatch(/syllabus/i);
|
|
expect(p).toMatch(/module/i);
|
|
});
|
|
it('draftFeedback stays grounded and returns paste-ready comment text', () => {
|
|
const p = actionPrompt('draftFeedback');
|
|
expect(p).toMatch(/do not invent/i);
|
|
expect(p).toMatch(/submission comment/i);
|
|
});
|
|
it('generateQuestions asks for quiz questions and discussion prompts', () => {
|
|
const p = actionPrompt('generateQuestions');
|
|
expect(p).toMatch(/quiz questions/i);
|
|
expect(p).toMatch(/discussion prompts/i);
|
|
});
|
|
it('summarizeSubmissions asks for submitted/missing/late and scores', () => {
|
|
const p = actionPrompt('summarizeSubmissions');
|
|
expect(p).toMatch(/missing or late/i);
|
|
expect(p).toMatch(/scores or grades/i);
|
|
});
|
|
it('draftAnnouncement asks for a subject line and stays grounded on dates', () => {
|
|
const p = actionPrompt('draftAnnouncement');
|
|
expect(p).toMatch(/subject line/i);
|
|
expect(p).toMatch(/do not invent dates/i);
|
|
});
|
|
});
|
|
|
|
describe('actions: isActionId / actionPrompt guards', () => {
|
|
it('narrows known ids and rejects unknown', () => {
|
|
expect(isActionId('summarizeCourse')).toBe(true);
|
|
expect(isActionId('nope')).toBe(false);
|
|
});
|
|
it('actionPrompt throws on an unknown id', () => {
|
|
expect(() => actionPrompt('nope')).toThrow(/Unknown action/);
|
|
});
|
|
});
|
|
|
|
describe('actions: runAction', () => {
|
|
it('routes the resolved prompt through ask (one code path to the model)', async () => {
|
|
const { client, create } = mockClient('overview');
|
|
const out = await runAction('summarizeCourse', ctx, { name: 'BIO-101' }, { client });
|
|
expect(out).toBe('overview');
|
|
const user = String(create.mock.calls[0][0].messages[1].content);
|
|
expect(user).toContain(ACTIONS.summarizeCourse.prompt);
|
|
expect(user).toContain('Assignment: Lab Report 1');
|
|
});
|
|
|
|
it('rejects (not synchronously throws) on an unknown id', async () => {
|
|
await expect(runAction('nope', ctx)).rejects.toThrow(/Unknown action/);
|
|
});
|
|
});
|