178 lines
5.5 KiB
TypeScript
178 lines
5.5 KiB
TypeScript
import type Config from '../types/config';
|
|
import imageToBase64 from 'background/utils/image-to-base64';
|
|
import isGPTModelGreaterOrEqualTo4 from 'background/utils/version-support-images';
|
|
import { ChatCompletionMessageParam, ChatCompletionUserMessageParam } from 'openai/resources';
|
|
import { parseMoodleQuestion } from './parse-question';
|
|
import { MoodleQuestionQuery /* MoodleQuestionType */ } from '../types/question-types';
|
|
|
|
// The attempt and the cmid allow us to identify a quiz
|
|
type History = {
|
|
host: string;
|
|
cmid: string; // The id of the quiz
|
|
attempt: string; // The attempt of the current quiz
|
|
history: ChatCompletionMessageParam[];
|
|
};
|
|
|
|
const INSTRUCTION = `
|
|
You are an expert quiz solver.
|
|
Please solve the provided question based on its type and provide the correct result.
|
|
- For choice questions, output the exact index(es) of the correct answer(s).
|
|
- For text/numerical questions, provide the exact wording or number.
|
|
- For essay questions, provide a highly detailed and complete response.
|
|
Always output strict JSON according to the requested schema block.
|
|
`.trim();
|
|
|
|
const SYSTEM_INSTRUCTION_MESSAGE = {
|
|
role: 'system',
|
|
content: INSTRUCTION
|
|
} as const satisfies ChatCompletionMessageParam;
|
|
|
|
/**
|
|
* Get the content to send to ChatGPT API (it allows to includes images if supported)
|
|
* @param config
|
|
*/
|
|
async function getContent(
|
|
config: Config,
|
|
questionElement: HTMLElement,
|
|
// We provide the structured JSON if parsed, otherwise fallback to normalized text string
|
|
textContent: string
|
|
): Promise<ChatCompletionUserMessageParam['content']> {
|
|
const imagesElements = questionElement.querySelectorAll('img');
|
|
|
|
if (
|
|
!config.includeImages ||
|
|
!isGPTModelGreaterOrEqualTo4(config.model) ||
|
|
imagesElements.length === 0
|
|
) {
|
|
return textContent;
|
|
}
|
|
|
|
const contentWithImages: ChatCompletionUserMessageParam['content'] = [];
|
|
|
|
const base64Images = Array.from(imagesElements).map(imgEl => imageToBase64(imgEl));
|
|
const base64ImagesResolved = await Promise.allSettled(base64Images);
|
|
|
|
for (const result of base64ImagesResolved) {
|
|
if (result.status === 'fulfilled') {
|
|
contentWithImages.push({
|
|
type: 'image_url',
|
|
image_url: { url: result.value }
|
|
});
|
|
} else if (config.logs) {
|
|
console.error(result.reason);
|
|
}
|
|
}
|
|
|
|
contentWithImages.push({
|
|
type: 'text',
|
|
text: textContent
|
|
});
|
|
|
|
return contentWithImages;
|
|
}
|
|
|
|
/**
|
|
* Create a new history object from the current page
|
|
* @returns
|
|
*/
|
|
function createNewHistory(): History {
|
|
const urlParams = new URLSearchParams(document.location.search);
|
|
|
|
return {
|
|
host: document.location.host,
|
|
cmid: urlParams.get('cmid') ?? '',
|
|
attempt: urlParams.get('attempt') ?? '',
|
|
history: []
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load the past history from the session storage otherwise return the default history object
|
|
* @returns
|
|
*/
|
|
function loadPastHistory(): History | null {
|
|
return JSON.parse(sessionStorage.moodleGPTHistory ?? 'null');
|
|
}
|
|
|
|
/**
|
|
* Check if two history are from the same origin
|
|
* @param a
|
|
* @param b
|
|
* @returns
|
|
*/
|
|
function areHistoryFromSameQuiz(a: History, b: History): boolean {
|
|
const KEYS_TO_COMPARE: (keyof History)[] = ['host', 'cmid', 'attempt'];
|
|
|
|
for (const key of KEYS_TO_COMPARE) {
|
|
if (a[key] !== b[key]) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Return the content to send to chatgpt api with history if needed
|
|
* @param config
|
|
* @param questionElement
|
|
* @param question
|
|
* @returns
|
|
*/
|
|
async function getContentWithHistory(
|
|
config: Config,
|
|
questionElement: HTMLElement,
|
|
question: string
|
|
): Promise<{
|
|
messages: [typeof SYSTEM_INSTRUCTION_MESSAGE, ...ChatCompletionMessageParam[]];
|
|
saveResponse?: (response: string) => void;
|
|
query: MoodleQuestionQuery | null;
|
|
}> {
|
|
const parsedQuery = parseMoodleQuestion(questionElement, question);
|
|
const textContent = parsedQuery ? JSON.stringify(parsedQuery, null, 2) : question;
|
|
|
|
const content = await getContent(config, questionElement, textContent);
|
|
const message: ChatCompletionMessageParam = { role: 'user', content };
|
|
|
|
const buildResult = (historyMsg: ChatCompletionMessageParam[]) => {
|
|
const historyObj = { history: historyMsg };
|
|
return {
|
|
messages: [SYSTEM_INSTRUCTION_MESSAGE, ...historyMsg, message] as [
|
|
typeof SYSTEM_INSTRUCTION_MESSAGE,
|
|
...ChatCompletionMessageParam[]
|
|
],
|
|
query: parsedQuery,
|
|
saveResponse(response: string) {
|
|
if (config.history) {
|
|
historyObj.history.push(message);
|
|
historyObj.history.push({ role: 'assistant', content: response });
|
|
// Note we probably need the full 'history' object here to stringify it:
|
|
// We will recreate it or reuse the loaded one
|
|
let historyToSave: History;
|
|
const pastHistory: History | null = loadPastHistory();
|
|
const newHistory: History = createNewHistory();
|
|
if (pastHistory === null || !areHistoryFromSameQuiz(pastHistory, newHistory)) {
|
|
historyToSave = newHistory;
|
|
} else {
|
|
historyToSave = pastHistory;
|
|
}
|
|
historyToSave.history = historyObj.history;
|
|
sessionStorage.moodleGPTHistory = JSON.stringify(historyToSave);
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
if (!config.history) {
|
|
return buildResult([]);
|
|
}
|
|
|
|
const pastHistory: History | null = loadPastHistory();
|
|
const newHistory: History = createNewHistory();
|
|
if (pastHistory === null || !areHistoryFromSameQuiz(pastHistory, newHistory)) {
|
|
return buildResult(newHistory.history);
|
|
} else {
|
|
return buildResult(pastHistory.history);
|
|
}
|
|
}
|
|
|
|
export default getContentWithHistory;
|