better algo, added history, fixed bugs and refactoring
This commit is contained in:
+48
-46
@@ -17,7 +17,7 @@ const listeners: Listener[] = [];
|
||||
function codeListener(config: Config) {
|
||||
document.body.addEventListener("keydown", function (event) {
|
||||
pressedKeys.push(event.key);
|
||||
if (pressedKeys.length > config.code.length) pressedKeys.shift();
|
||||
if (pressedKeys.length > config.code!.length) pressedKeys.shift();
|
||||
if (pressedKeys.join("") === config.code) {
|
||||
pressedKeys.length = 0;
|
||||
setUpMoodleGpt(config);
|
||||
@@ -25,51 +25,6 @@ function codeListener(config: Config) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup moodleGPT into the page (remove/injection)
|
||||
* @param config
|
||||
* @returns
|
||||
*/
|
||||
function setUpMoodleGpt(config: Config) {
|
||||
/* Removing events */
|
||||
if (listeners.length > 0) {
|
||||
for (const listener of listeners) {
|
||||
if (config.cursor) listener.element.style.cursor = "initial";
|
||||
listener.element.removeEventListener("click", listener.fn);
|
||||
}
|
||||
if (config.title) titleIndications("Removed");
|
||||
listeners.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Code injection */
|
||||
const inputQuery = ["checkbox", "radio", "text", "number"]
|
||||
.map((e) => `input[type="${e}"]`)
|
||||
.join(",");
|
||||
const query = inputQuery + ", textarea, select, [contenteditable]";
|
||||
const forms = document.querySelectorAll(".formulation");
|
||||
|
||||
for (const form of forms) {
|
||||
const hiddenButton: HTMLElement = form.querySelector(".qtext");
|
||||
|
||||
if (!hiddenButton) continue;
|
||||
|
||||
if (config.cursor) hiddenButton.style.cursor = "pointer";
|
||||
|
||||
const injectionFunction = reply.bind(
|
||||
null,
|
||||
config,
|
||||
hiddenButton,
|
||||
form,
|
||||
query
|
||||
);
|
||||
listeners.push({ element: hiddenButton, fn: injectionFunction });
|
||||
hiddenButton.addEventListener("click", injectionFunction);
|
||||
}
|
||||
|
||||
if (config.title) titleIndications("Injected");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the event listener on a specific question
|
||||
* @param element
|
||||
@@ -82,4 +37,51 @@ function removeListener(element: HTMLElement) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup moodleGPT into the page (remove/injection)
|
||||
* @param config
|
||||
* @returns
|
||||
*/
|
||||
function setUpMoodleGpt(config: Config) {
|
||||
// Removing events if there are already declared
|
||||
if (listeners.length > 0) {
|
||||
for (const listener of listeners) {
|
||||
if (config.cursor) listener.element.style.cursor = "initial";
|
||||
listener.element.removeEventListener("click", listener.fn);
|
||||
}
|
||||
if (config.title) titleIndications("Removed");
|
||||
listeners.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Query to find inputs and forms
|
||||
const inputTypeQuery = ["checkbox", "radio", "text", "number"]
|
||||
.map((e) => `input[type="${e}"]`)
|
||||
.join(",");
|
||||
const inputQuery = inputTypeQuery + ", textarea, select, [contenteditable]";
|
||||
const forms = document.querySelectorAll(".formulation");
|
||||
|
||||
// For each form we inject a function on the queqtion
|
||||
for (const form of forms) {
|
||||
const questionElement: HTMLElement | null = form.querySelector(".qtext");
|
||||
|
||||
if (questionElement === null) continue;
|
||||
|
||||
if (config.cursor) questionElement.style.cursor = "pointer";
|
||||
|
||||
const injectionFunction = reply.bind(null, {
|
||||
config,
|
||||
questionElement,
|
||||
form: form as HTMLElement,
|
||||
inputQuery,
|
||||
removeListener: () => removeListener(questionElement),
|
||||
});
|
||||
|
||||
listeners.push({ element: questionElement, fn: injectionFunction });
|
||||
questionElement.addEventListener("click", injectionFunction);
|
||||
}
|
||||
|
||||
if (config.title) titleIndications("Injected");
|
||||
}
|
||||
|
||||
export { codeListener, removeListener, setUpMoodleGpt };
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import type Config from "@typing/config";
|
||||
import normalizeText from "@utils/normalize-text";
|
||||
import htmlTableToString from "@utils/html-table-to-string";
|
||||
|
||||
/**
|
||||
* Normalize the question and add sub informations
|
||||
* Normalize the question as text and add sub informations
|
||||
* @param langage
|
||||
* @param question
|
||||
* @returns
|
||||
*/
|
||||
function createQuestion(config: Config, questionContainer: HTMLElement) {
|
||||
function createAndNormalizeQuestion(questionContainer: HTMLElement) {
|
||||
let question = questionContainer.innerText;
|
||||
|
||||
/* We remove unnecessary information */
|
||||
// We remove unnecessary information
|
||||
const accesshideElements: NodeListOf<HTMLElement> =
|
||||
questionContainer.querySelectorAll(".accesshide");
|
||||
for (const useless of accesshideElements) {
|
||||
question = question.replace(useless.innerText, "");
|
||||
}
|
||||
|
||||
/* Make tables more readable for chat-gpt */
|
||||
// Make tables more readable for chat-gpt
|
||||
const tables: NodeListOf<HTMLTableElement> =
|
||||
questionContainer.querySelectorAll(".qtext table");
|
||||
for (const table of tables) {
|
||||
@@ -31,4 +30,4 @@ function createQuestion(config: Config, questionContainer: HTMLElement) {
|
||||
return normalizeText(question, false);
|
||||
}
|
||||
|
||||
export default createQuestion;
|
||||
export default createAndNormalizeQuestion;
|
||||
|
||||
+58
-18
@@ -2,6 +2,41 @@ import type Config from "@typing/config";
|
||||
import type GPTAnswer from "@typing/gptAnswer";
|
||||
import normalizeText from "@utils/normalize-text";
|
||||
|
||||
type History = {
|
||||
url: string | null;
|
||||
system: { role: ROLE; content: string };
|
||||
history: { role: ROLE; content: string }[];
|
||||
};
|
||||
|
||||
enum ROLE {
|
||||
SYSTEM = "system",
|
||||
USER = "user",
|
||||
ASSISTANT = "assistant",
|
||||
}
|
||||
|
||||
const INSTRUCTION: string = `
|
||||
Act as a quiz solver for the best notation with the following rules:
|
||||
- When asked for the result of an equation, provide only the result without any other information and skip the other rules.
|
||||
- If no answer(s) are given, answer the statement as usual without following the other rules, providing the most detailed, complete and precise explanation.
|
||||
- For 'put in order' questions, provide the position of the answer separated by a new line (e.g., '1\n3\n2') and ignore other rules.- Always reply in this format: '<answer 1>\n<answer 2>\n...'
|
||||
- Always reply in the format: '<answer 1>\n<answer 2>\n...'.
|
||||
- Retain only the correct answer(s).
|
||||
- Maintain the same order for the answers as in the text.
|
||||
- Retain all text from the answer with its description, content or definition.
|
||||
- Only provide answers that exactly match the given answer in the text.
|
||||
- The question always has the correct answer(s), so you should always provide an answer.
|
||||
- Always respond in the same language as the user's question.
|
||||
`.trim();
|
||||
|
||||
const history: History = {
|
||||
url: null,
|
||||
system: {
|
||||
role: ROLE.SYSTEM,
|
||||
content: INSTRUCTION,
|
||||
},
|
||||
history: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the response from chatGPT api
|
||||
* @param config
|
||||
@@ -12,8 +47,19 @@ async function getChatGPTResponse(
|
||||
config: Config,
|
||||
question: string
|
||||
): Promise<GPTAnswer> {
|
||||
const URL = location.hostname + location.pathname;
|
||||
|
||||
// We reset the history when we enter a new moodle quiz or when it's desactivate
|
||||
if (!config.history || history.url !== URL) {
|
||||
history.url = URL;
|
||||
history.history = [];
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutControler = setTimeout(() => controller.abort(), 15000);
|
||||
const timeoutControler = setTimeout(() => controller.abort(), 15 * 1000);
|
||||
|
||||
const message = { role: ROLE.USER, content: question };
|
||||
|
||||
const req = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -23,33 +69,27 @@ async function getChatGPTResponse(
|
||||
signal: config.timeout ? controller.signal : null,
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `
|
||||
Follow those rules:
|
||||
- Sometimes there won't be a question, so just answer the statement as you normally would without following the other rules and give the most detailled and complete answer with explication.
|
||||
- For put in order question just give the good order separate by new line
|
||||
- Your goal is to understand the statement and to reply to each question by giving only the answer.
|
||||
- You will keep the same order for the answers like in the text.
|
||||
- You will separate all the answer with new lines and only show the correctes one.
|
||||
- You will only give the answers for each question and omit the questions, statement, title or other informations from the response.
|
||||
- You will only give answer with exactly the same text as the gived answers.
|
||||
- The question always have the good answer so you should always give an answer to the question.
|
||||
- You will always respond in the same langage as the user question.`,
|
||||
},
|
||||
{ role: "user", content: question },
|
||||
],
|
||||
messages: [history.system, ...history.history, message],
|
||||
temperature: 0.8,
|
||||
top_p: 1.0,
|
||||
presence_penalty: 1.0,
|
||||
stop: null,
|
||||
}),
|
||||
});
|
||||
|
||||
clearTimeout(timeoutControler);
|
||||
|
||||
const rep = await req.json();
|
||||
const response = rep.choices[0].message.content;
|
||||
|
||||
// Register the conversation
|
||||
if (config.history) {
|
||||
history.history.push(message);
|
||||
history.history.push({ role: ROLE.ASSISTANT, content: response });
|
||||
}
|
||||
|
||||
return {
|
||||
question,
|
||||
response,
|
||||
normalizedResponse: normalizeText(response),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type GPTAnswer from "@typing/gptAnswer";
|
||||
import type Config from "@typing/config";
|
||||
import handleClipboard from "@core/questions/clipboard";
|
||||
import handleContentEditable from "@core/questions/contenteditable";
|
||||
import handleNumber from "@core/questions/number";
|
||||
import handleRadio from "@core/questions/radio";
|
||||
import handleCheckbox from "@core/questions/checkbox";
|
||||
import handleSelect from "@core/questions/select";
|
||||
import handleTextbox from "@core/questions/textbox";
|
||||
|
||||
type Props = {
|
||||
config: Config;
|
||||
questionElement: HTMLElement;
|
||||
inputList: NodeListOf<HTMLElement>;
|
||||
gptAnswer: GPTAnswer;
|
||||
removeListener: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Autocomplete mode:
|
||||
* Autocomplete the question by checking the good answer
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
function autoCompleteMode(props: Props) {
|
||||
if (!props.config.infinite) props.removeListener();
|
||||
|
||||
const handlers = [
|
||||
handleContentEditable,
|
||||
handleTextbox,
|
||||
handleNumber,
|
||||
handleSelect,
|
||||
handleRadio,
|
||||
handleCheckbox,
|
||||
];
|
||||
|
||||
for (const handler of handlers) {
|
||||
if (handler(props.config, props.inputList, props.gptAnswer)) return;
|
||||
}
|
||||
|
||||
// In the case we can't auto complete the question
|
||||
handleClipboard(props.config, props.gptAnswer);
|
||||
}
|
||||
|
||||
export default autoCompleteMode;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type Config from "@typing/config";
|
||||
import type GPTAnswer from "@typing/gptAnswer";
|
||||
import handleClipboard from "@core/questions/clipboard";
|
||||
|
||||
type Props = {
|
||||
config: Config;
|
||||
questionElement: HTMLElement;
|
||||
gptAnswer: GPTAnswer;
|
||||
removeListener: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clipboard mode:
|
||||
* Simply copy the answer into the clipboard
|
||||
* @param props
|
||||
*/
|
||||
function clipboardMode(props: Props) {
|
||||
if (!props.config.infinite) props.removeListener();
|
||||
handleClipboard(props.config, props.gptAnswer);
|
||||
}
|
||||
|
||||
export default clipboardMode;
|
||||
@@ -0,0 +1,36 @@
|
||||
import type GPTAnswer from "@typing/gptAnswer";
|
||||
|
||||
type Props = {
|
||||
questionElement: HTMLElement;
|
||||
gptAnswer: GPTAnswer;
|
||||
removeListener: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Question to answer mode:
|
||||
* Simply turn the question into the answer by clicking on it
|
||||
* @param props
|
||||
*/
|
||||
function questionToAnswerMode(props: Props) {
|
||||
const questionElement = props.questionElement;
|
||||
|
||||
props.removeListener();
|
||||
|
||||
const questionBackup = questionElement.textContent;
|
||||
|
||||
questionElement.textContent = props.gptAnswer.response;
|
||||
|
||||
// Format the content
|
||||
questionElement.style.whiteSpace = "pre-wrap";
|
||||
|
||||
// To go back to the question / answer
|
||||
let contentIsResponse = true;
|
||||
questionElement.addEventListener("click", function () {
|
||||
questionElement.style.whiteSpace = contentIsResponse ? "" : "pre-warp";
|
||||
questionElement.textContent = contentIsResponse
|
||||
? questionBackup
|
||||
: props.gptAnswer.response;
|
||||
});
|
||||
}
|
||||
|
||||
export default questionToAnswerMode;
|
||||
@@ -0,0 +1,58 @@
|
||||
import type Config from "@typing/config";
|
||||
import type GPTAnswer from "@typing/gptAnswer";
|
||||
import Logs from "@utils/logs";
|
||||
import normalizeText from "@utils/normalize-text";
|
||||
import { pickBestReponse, toPourcentage } from "@utils/pick-best-response";
|
||||
|
||||
/**
|
||||
* Handle input checkbox elements
|
||||
* @param config
|
||||
* @param inputList
|
||||
* @param gptAnswer
|
||||
*/
|
||||
function handleCheckbox(
|
||||
config: Config,
|
||||
inputList: NodeListOf<HTMLElement>,
|
||||
gptAnswer: GPTAnswer
|
||||
): boolean {
|
||||
const firstInput = inputList?.[0] as HTMLInputElement;
|
||||
|
||||
// Handle the case the input is not a checkbox
|
||||
if (!firstInput || firstInput.type !== "checkbox") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const corrects = gptAnswer.normalizedResponse.split("\n");
|
||||
|
||||
const possibleAnswers = Array.from(inputList)
|
||||
.map((inp) => ({
|
||||
element: inp,
|
||||
value: normalizeText(inp?.parentElement?.textContent ?? ""),
|
||||
}))
|
||||
.filter((obj) => obj.value !== "");
|
||||
|
||||
for (const correct of corrects) {
|
||||
const bestAnswer = pickBestReponse(correct, possibleAnswers);
|
||||
|
||||
if (config.logs && bestAnswer.value) {
|
||||
Logs.bestAnswer(bestAnswer.value, bestAnswer.similarity);
|
||||
}
|
||||
|
||||
const correctInput = bestAnswer.element as HTMLInputElement;
|
||||
if (config.mouseover) {
|
||||
correctInput.addEventListener(
|
||||
"mouseover",
|
||||
() => (correctInput.checked = true),
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
correctInput.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default handleCheckbox;
|
||||
@@ -16,10 +16,11 @@ function handleContentEditable(
|
||||
const input = inputList[0];
|
||||
|
||||
if (
|
||||
inputList.length !== 1 ||
|
||||
inputList.length !== 1 || // for now we don't handle many input for editable textcontent
|
||||
input.getAttribute("contenteditable") !== "true"
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.typing) {
|
||||
let index = 0;
|
||||
@@ -29,14 +30,16 @@ function handleContentEditable(
|
||||
event.preventDefault();
|
||||
input.textContent = gptAnswer.response.slice(0, ++index);
|
||||
|
||||
/* Put the cursor at the end of the typed text */
|
||||
// Put the cursor at the end of the typed text
|
||||
input.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(input);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
if (selection !== null) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
input.textContent = gptAnswer.response;
|
||||
|
||||
@@ -15,20 +15,25 @@ function handleNumber(
|
||||
): boolean {
|
||||
const input = inputList[0] as HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
if (inputList.length !== 1 || input.type !== "number") return false;
|
||||
if (
|
||||
inputList.length !== 1 || // for now we don't handle many input number
|
||||
input.type !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const number = gptAnswer.normalizedResponse
|
||||
.match(/\d+([,\.]\d+)?/gi)?.[0]
|
||||
?.replace(",", ".");
|
||||
|
||||
if (!number) return false;
|
||||
if (number === undefined) return false;
|
||||
|
||||
if (config.typing) {
|
||||
let index = 0;
|
||||
input.addEventListener("keydown", function (event: KeyboardEvent) {
|
||||
if (event.key === "Backspace") index = number.length + 1;
|
||||
if (index > number.length) return;
|
||||
input.addEventListener("keydown", function (event: Event) {
|
||||
event.preventDefault();
|
||||
if ((<KeyboardEvent>event).key === "Backspace") index = number.length + 1;
|
||||
if (index > number.length) return;
|
||||
if (number.slice(index, index + 1) === ".") ++index;
|
||||
input.value = number.slice(0, ++index);
|
||||
});
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type Config from "@typing/config";
|
||||
import type GPTAnswer from "@typing/gptAnswer";
|
||||
import Logs from "@utils/logs";
|
||||
import normalizeText from "@utils/normalize-text";
|
||||
|
||||
/**
|
||||
* Handle checkbox and input elements
|
||||
* @param config
|
||||
* @param inputList
|
||||
* @param gptAnswer
|
||||
*/
|
||||
function handleRadioAndCheckbox(
|
||||
config: Config,
|
||||
inputList: NodeListOf<HTMLElement>,
|
||||
gptAnswer: GPTAnswer
|
||||
): boolean {
|
||||
const input = inputList?.[0] as HTMLInputElement;
|
||||
|
||||
if (!input || (input.type !== "checkbox" && input.type !== "radio"))
|
||||
return false;
|
||||
|
||||
for (const input of inputList as NodeListOf<HTMLInputElement>) {
|
||||
const content = normalizeText(input.parentNode.textContent);
|
||||
const valide = gptAnswer.normalizedResponse.includes(content);
|
||||
if (config.logs) Logs.responseTry(content, valide);
|
||||
if (valide) {
|
||||
if (config.mouseover) {
|
||||
input.addEventListener("mouseover", () => (input.checked = true), {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
input.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export default handleRadioAndCheckbox;
|
||||
@@ -0,0 +1,57 @@
|
||||
import type Config from "@typing/config";
|
||||
import type GPTAnswer from "@typing/gptAnswer";
|
||||
import Logs from "@utils/logs";
|
||||
import normalizeText from "@utils/normalize-text";
|
||||
import { pickBestReponse, toPourcentage } from "@utils/pick-best-response";
|
||||
|
||||
/**
|
||||
* Handle input radio elements
|
||||
* @param config
|
||||
* @param inputList
|
||||
* @param gptAnswer
|
||||
*/
|
||||
function handleRadio(
|
||||
config: Config,
|
||||
inputList: NodeListOf<HTMLElement>,
|
||||
gptAnswer: GPTAnswer
|
||||
): boolean {
|
||||
const firstInput = inputList?.[0] as HTMLInputElement;
|
||||
|
||||
// Handle the case the input is not a radio
|
||||
if (!firstInput || firstInput.type !== "radio") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const possibleAnswers = Array.from(inputList)
|
||||
.map((inp) => ({
|
||||
element: inp,
|
||||
value: normalizeText(inp?.parentElement?.textContent ?? ""),
|
||||
}))
|
||||
.filter((obj) => obj.value !== "");
|
||||
|
||||
const bestAnswer = pickBestReponse(
|
||||
gptAnswer.normalizedResponse,
|
||||
possibleAnswers
|
||||
);
|
||||
|
||||
if (config.logs && bestAnswer.value) {
|
||||
Logs.bestAnswer(bestAnswer.value, bestAnswer.similarity);
|
||||
}
|
||||
|
||||
const correctInput = bestAnswer.element as HTMLInputElement;
|
||||
if (config.mouseover) {
|
||||
correctInput.addEventListener(
|
||||
"mouseover",
|
||||
() => (correctInput.checked = true),
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
correctInput.checked = true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default handleRadio;
|
||||
@@ -2,6 +2,7 @@ import type Config from "@typing/config";
|
||||
import type GPTAnswer from "@typing/gptAnswer";
|
||||
import Logs from "@utils/logs";
|
||||
import normalizeText from "@utils/normalize-text";
|
||||
import { pickBestReponse, toPourcentage } from "@utils/pick-best-response";
|
||||
|
||||
/**
|
||||
* Handle select elements (and put in order select)
|
||||
@@ -17,81 +18,43 @@ function handleSelect(
|
||||
): boolean {
|
||||
if (inputList.length === 0 || inputList[0].tagName !== "SELECT") return false;
|
||||
|
||||
let correct = gptAnswer.normalizedResponse.split("\n");
|
||||
const corrects = gptAnswer.normalizedResponse.split("\n");
|
||||
|
||||
if (config.logs) Logs.array(correct);
|
||||
if (config.logs) Logs.array(corrects);
|
||||
|
||||
/**
|
||||
* Sometimes ChatGPT give the question so we should remove them
|
||||
* Example:
|
||||
* 5*5
|
||||
* 25
|
||||
* 10+10
|
||||
* 20
|
||||
* 20-10
|
||||
* 10
|
||||
*
|
||||
* And we only want to keep answers
|
||||
* 25
|
||||
* 20
|
||||
* 10
|
||||
*/
|
||||
if (correct.length === inputList.length * 2) {
|
||||
correct = correct.filter((answer, index) => index % 2 === 1);
|
||||
}
|
||||
for (let i = 0; i < inputList.length; ++i) {
|
||||
if (!corrects[i]) break;
|
||||
|
||||
for (let j = 0; j < inputList.length; ++j) {
|
||||
const options = inputList[j].querySelectorAll("option");
|
||||
const options = inputList[i].querySelectorAll("option");
|
||||
|
||||
for (const option of options) {
|
||||
const content = normalizeText(option.textContent);
|
||||
const valide = correct[j].includes(content);
|
||||
const possibleAnswers = Array.from(options)
|
||||
.map((opt) => ({
|
||||
element: opt,
|
||||
value: normalizeText(opt.textContent ?? ""),
|
||||
}))
|
||||
.filter((obj) => obj.value !== "");
|
||||
|
||||
/* Handle put in order question */
|
||||
if (!/[^\d]+/gi.test(content)) {
|
||||
const elementTitle = (option.parentNode as HTMLElement)
|
||||
.closest("tr")
|
||||
.querySelector(".text");
|
||||
const content = normalizeText(elementTitle.textContent);
|
||||
const bestAnswer = pickBestReponse(corrects[i], possibleAnswers);
|
||||
|
||||
const indexCorrectAnswer = correct.findIndex((answer) => {
|
||||
const valide = answer.includes(content);
|
||||
if (config.logs) Logs.responseTry(content, valide);
|
||||
return valide;
|
||||
});
|
||||
if (config.logs && bestAnswer.value) {
|
||||
Logs.bestAnswer(bestAnswer.value, bestAnswer.similarity);
|
||||
}
|
||||
|
||||
if (indexCorrectAnswer !== -1) {
|
||||
//we do + 1 because we skip the first option: Choose...
|
||||
if (config.mouseover) {
|
||||
options[indexCorrectAnswer + 1].closest("select").addEventListener(
|
||||
"click",
|
||||
function () {
|
||||
options[indexCorrectAnswer + 1].selected = "selected" as any;
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
} else {
|
||||
options[indexCorrectAnswer + 1].selected = "selected" as any;
|
||||
}
|
||||
break;
|
||||
const correctOption = bestAnswer.element as HTMLOptionElement;
|
||||
const currentSelect = correctOption.closest("select");
|
||||
|
||||
if (currentSelect === null) continue;
|
||||
|
||||
if (config.mouseover) {
|
||||
currentSelect.addEventListener(
|
||||
"click",
|
||||
() => (correctOption.selected = true),
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
}
|
||||
/* End */
|
||||
|
||||
if (config.logs) Logs.responseTry(content, valide);
|
||||
|
||||
if (valide) {
|
||||
if (config.mouseover) {
|
||||
option
|
||||
.closest("select")
|
||||
.addEventListener("click", () => (option.selected = true), {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
option.selected = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
correctOption.selected = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,17 +16,20 @@ function handleTextbox(
|
||||
const input = inputList[0] as HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
if (
|
||||
inputList.length !== 1 ||
|
||||
inputList.length !== 1 || // for now we don't handle many input text
|
||||
(input.tagName !== "TEXTAREA" && input.type !== "text")
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.typing) {
|
||||
let index = 0;
|
||||
input.addEventListener("keydown", function (event: KeyboardEvent) {
|
||||
if (event.key === "Backspace") index = gptAnswer.response.length + 1;
|
||||
if (index > gptAnswer.response.length) return;
|
||||
input.addEventListener("keydown", function (event: Event) {
|
||||
event.preventDefault();
|
||||
if ((<KeyboardEvent>event).key === "Backspace") {
|
||||
index = gptAnswer.response.length + 1;
|
||||
}
|
||||
if (index > gptAnswer.response.length) return;
|
||||
input.value = gptAnswer.response.slice(0, ++index);
|
||||
});
|
||||
} else {
|
||||
|
||||
+50
-68
@@ -1,35 +1,33 @@
|
||||
import type Config from "@typing/config";
|
||||
import Logs from "@utils/logs";
|
||||
import getChatGPTResponse from "./get-response";
|
||||
import createQuestion from "./create-question";
|
||||
import handleRadioAndCheckbox from "./questions/radio-checkbox";
|
||||
import handleSelect from "./questions/select";
|
||||
import handleTextbox from "./questions/textbox";
|
||||
import handleClipboard from "./questions/clipboard";
|
||||
import handleNumber from "./questions/number";
|
||||
import handleContentEditable from "./questions/contenteditable";
|
||||
import { removeListener } from "./code-listener";
|
||||
import createAndNormalizeQuestion from "./create-question";
|
||||
import clipboardMode from "./modes/clipboard";
|
||||
import questionToAnswerMode from "./modes/question-to-answer";
|
||||
import autoCompleteMode from "./modes/autocomplete";
|
||||
|
||||
type Props = {
|
||||
config: Config;
|
||||
questionElement: HTMLElement;
|
||||
form: HTMLElement;
|
||||
inputQuery: string;
|
||||
removeListener: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reply to the question
|
||||
* @param config
|
||||
* @param hiddenButton
|
||||
* @param form
|
||||
* @param query
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
async function reply(
|
||||
config: Config,
|
||||
hiddenButton: HTMLElement,
|
||||
form: HTMLElement,
|
||||
query: string
|
||||
) {
|
||||
if (config.cursor) hiddenButton.style.cursor = "wait";
|
||||
async function reply(props: Props): Promise<void> {
|
||||
if (props.config.cursor) props.questionElement.style.cursor = "wait";
|
||||
|
||||
const question = createQuestion(config, form);
|
||||
const inputList: NodeListOf<HTMLElement> = form.querySelectorAll(query);
|
||||
const question = createAndNormalizeQuestion(props.form);
|
||||
const inputList: NodeListOf<HTMLElement> = props.form.querySelectorAll(
|
||||
props.inputQuery
|
||||
);
|
||||
|
||||
const gptAnswer = await getChatGPTResponse(config, question).catch(
|
||||
const gptAnswer = await getChatGPTResponse(props.config, question).catch(
|
||||
(error) => ({
|
||||
error,
|
||||
})
|
||||
@@ -37,63 +35,47 @@ async function reply(
|
||||
|
||||
const haveError = typeof gptAnswer === "object" && "error" in gptAnswer;
|
||||
|
||||
if (config.cursor)
|
||||
hiddenButton.style.cursor =
|
||||
config.infinite || haveError ? "pointer" : "initial";
|
||||
if (props.config.cursor) {
|
||||
props.questionElement.style.cursor =
|
||||
props.config.infinite || haveError ? "pointer" : "initial";
|
||||
}
|
||||
|
||||
if (haveError) {
|
||||
console.error(gptAnswer.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.logs) {
|
||||
if (props.config.logs) {
|
||||
Logs.question(question);
|
||||
Logs.response(gptAnswer);
|
||||
}
|
||||
|
||||
/* Handle clipboard mode */
|
||||
if (config.mode === "clipboard") {
|
||||
if (!config.infinite) removeListener(hiddenButton);
|
||||
return handleClipboard(config, gptAnswer);
|
||||
switch (props.config.mode) {
|
||||
case "clipboard":
|
||||
clipboardMode({
|
||||
config: props.config,
|
||||
questionElement: props.questionElement,
|
||||
gptAnswer,
|
||||
removeListener: props.removeListener,
|
||||
});
|
||||
break;
|
||||
case "question-to-answer":
|
||||
questionToAnswerMode({
|
||||
gptAnswer,
|
||||
questionElement: props.questionElement,
|
||||
removeListener: props.removeListener,
|
||||
});
|
||||
break;
|
||||
case "autocomplete":
|
||||
autoCompleteMode({
|
||||
config: props.config,
|
||||
gptAnswer,
|
||||
inputList,
|
||||
questionElement: props.questionElement,
|
||||
removeListener: props.removeListener,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
/* Handle question to answer mode */
|
||||
if (config.mode === "question-to-answer") {
|
||||
removeListener(hiddenButton);
|
||||
|
||||
const questionContainer = form.querySelector<HTMLElement>(".qtext");
|
||||
const questionBackup = questionContainer.textContent;
|
||||
|
||||
questionContainer.textContent = gptAnswer.response;
|
||||
questionContainer.style.whiteSpace = "pre-wrap";
|
||||
|
||||
questionContainer.addEventListener("click", function () {
|
||||
const isNotResponse = questionContainer.textContent === questionBackup;
|
||||
questionContainer.style.whiteSpace = isNotResponse ? "pre-wrap" : null;
|
||||
questionContainer.textContent = isNotResponse
|
||||
? gptAnswer.response
|
||||
: questionBackup;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/* Better then set once on the event because if there is an error the user can click an other time on the question */
|
||||
if (!config.infinite) removeListener(hiddenButton);
|
||||
|
||||
const handlers = [
|
||||
handleContentEditable,
|
||||
handleTextbox,
|
||||
handleNumber,
|
||||
handleSelect,
|
||||
handleRadioAndCheckbox,
|
||||
];
|
||||
|
||||
for (const handler of handlers) {
|
||||
if (handler(config, inputList, gptAnswer)) return;
|
||||
}
|
||||
|
||||
/* In the case we can't auto complete the question */
|
||||
handleClipboard(config, gptAnswer);
|
||||
}
|
||||
|
||||
export default reply;
|
||||
|
||||
@@ -9,6 +9,7 @@ type Config = {
|
||||
logs?: boolean;
|
||||
title?: boolean;
|
||||
timeout?: boolean;
|
||||
history?: boolean;
|
||||
mode?: "autocomplete" | "question-to-answer" | "clipboard";
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
type GPTAnswer = {
|
||||
question: string;
|
||||
response: string;
|
||||
normalizedResponse: string;
|
||||
};
|
||||
|
||||
@@ -13,9 +13,9 @@ function htmlTableToString(table: HTMLTableElement) {
|
||||
const content = cell.textContent?.trim();
|
||||
maxColumnsLength[index] = Math.max(
|
||||
maxColumnsLength[index] || 0,
|
||||
content.length || 0
|
||||
content?.length || 0
|
||||
);
|
||||
return content;
|
||||
return content ?? "";
|
||||
});
|
||||
tab.push(cellsContent);
|
||||
});
|
||||
@@ -29,7 +29,7 @@ function htmlTableToString(table: HTMLTableElement) {
|
||||
const mappedLine = line.map((content, index) =>
|
||||
content.padEnd(
|
||||
maxColumnsLength[index],
|
||||
"\u00A0" /* For no matching with \s */
|
||||
"\u00A0" // For no matching with \s
|
||||
)
|
||||
);
|
||||
return "| " + mappedLine.join(" | ") + " |";
|
||||
|
||||
+8
-3
@@ -1,4 +1,5 @@
|
||||
import GPTAnswer from "@typing/gptAnswer";
|
||||
import { toPourcentage } from "./pick-best-response";
|
||||
|
||||
class Logs {
|
||||
static question(text: string) {
|
||||
@@ -6,9 +7,13 @@ class Logs {
|
||||
console.log("%c[QUESTION]: %s", css, text);
|
||||
}
|
||||
|
||||
static responseTry(text: string, valide: boolean) {
|
||||
const css = "color: " + (valide ? "green" : "red");
|
||||
console.log("%c[CHECKING]: %s", css, text);
|
||||
static bestAnswer(answer: string, similarity: number) {
|
||||
const css = "color: green";
|
||||
console.log(
|
||||
"%c[BEST ANSWER]: %s",
|
||||
css,
|
||||
`"${answer}" with a similarity of ${toPourcentage(similarity)}`
|
||||
);
|
||||
}
|
||||
|
||||
static array(arr: unknown[]) {
|
||||
|
||||
@@ -3,20 +3,18 @@
|
||||
* @param text
|
||||
*/
|
||||
function normalizeText(text: string, toLowerCase: boolean = true) {
|
||||
if (toLowerCase) text = text.toLowerCase();
|
||||
|
||||
let normalizedText = text
|
||||
.replace(/\n+/gi, "\n") //remove duplicate new lines
|
||||
.replace(/(\n\s*\n)+/g, "\n") //remove useless white sapce from textcontent
|
||||
.replace(/[ \t]+/gi, " "); //replace multiples space or tabs by a space
|
||||
.replace(/(\n\s*\n)+/g, "\n") //remove useless white space from textcontent
|
||||
.replace(/[ \t]+/gi, " ") //replace multiples space or tabs by a space
|
||||
.trim()
|
||||
// We remove the following content because sometimes ChatGPT will reply: "answer d"
|
||||
.replace(/^[a-z\d]\.\s/gi, "") //a. text, b. text, c. text, 1. text, 2. text, 3.text
|
||||
.replace(/\n[a-z\d]\.\s/gi, "\n"); //same but with new line
|
||||
|
||||
if (toLowerCase) normalizedText = normalizedText.toLowerCase();
|
||||
|
||||
return (
|
||||
normalizedText
|
||||
.trim()
|
||||
/* We remove that because sometimes ChatGPT will reply: "answer d" */
|
||||
.replace(/^[a-z\d]\.\s/gi, "") //a. text, b. text, c. text, 1. text, 2. text, 3.text
|
||||
.replace(/\n[a-z\d]\.\s/gi, "\n") //same but with new line
|
||||
);
|
||||
return normalizedText;
|
||||
}
|
||||
|
||||
export default normalizeText;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
type BestResponse = {
|
||||
similarity: number;
|
||||
value: string | null;
|
||||
element: HTMLElement | null;
|
||||
};
|
||||
|
||||
type ResponsesBySimilarity = {
|
||||
similarity: number;
|
||||
value: string;
|
||||
element: HTMLElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the levenshtein distance between two sentence
|
||||
* @param str1
|
||||
* @param str2
|
||||
* @returns
|
||||
*/
|
||||
function levenshteinDistance(str1: string, str2: string) {
|
||||
if (str1.length === 0) return str2.length;
|
||||
if (str2.length === 0) return str1.length;
|
||||
|
||||
const matrix: number[][] = [];
|
||||
const str1WithoutSpaces = str1.replace(/\s+/, "");
|
||||
const str2WithoutSpaces = str2.replace(/\s+/, "");
|
||||
|
||||
for (let i = 0; i <= str1WithoutSpaces.length; ++i) {
|
||||
matrix.push([i]);
|
||||
for (let j = 1; j <= str2WithoutSpaces.length; ++j) {
|
||||
matrix[i][j] =
|
||||
i === 0
|
||||
? j
|
||||
: Math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] +
|
||||
(str1WithoutSpaces[i - 1] === str2WithoutSpaces[j - 1] ? 0 : 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[str1WithoutSpaces.length][str2WithoutSpaces.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the similarity between two sentences from 0 to 1 (best)
|
||||
* @param str1
|
||||
* @param str2
|
||||
* @returns
|
||||
*/
|
||||
function sentenceSimilarity(str1: string, str2: string) {
|
||||
const longerLength = str1.length > str2.length ? str1.length : str2.length;
|
||||
if (longerLength === 0) return 1;
|
||||
return (longerLength - levenshteinDistance(str1, str2)) / longerLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best sentence that correspond to the answer
|
||||
* @param arr
|
||||
* @param answer
|
||||
* @returns
|
||||
*/
|
||||
export function pickBestReponse(
|
||||
answer: string,
|
||||
arr: { element: HTMLElement; value: string }[]
|
||||
): BestResponse {
|
||||
let bestResponse: BestResponse = {
|
||||
element: null,
|
||||
similarity: 0,
|
||||
value: null,
|
||||
};
|
||||
for (const obj of arr) {
|
||||
const similarity = sentenceSimilarity(obj.value, answer);
|
||||
if (similarity === 1) {
|
||||
return { element: obj.element, value: obj.value, similarity };
|
||||
}
|
||||
if (similarity > bestResponse.similarity) {
|
||||
bestResponse = { element: obj.element, value: obj.value, similarity };
|
||||
}
|
||||
}
|
||||
return bestResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the sentences sorted by score with a score superior or equal to what is asked
|
||||
* @param answer
|
||||
* @param arr
|
||||
* @param score
|
||||
* @returns
|
||||
*/
|
||||
export function pickResponsesWithSimilarityGreaterThan(
|
||||
answer: string,
|
||||
arr: { element: HTMLElement; value: string }[],
|
||||
score: number
|
||||
): ResponsesBySimilarity[] {
|
||||
const responses: ResponsesBySimilarity[] = [];
|
||||
for (const obj of arr) {
|
||||
const similarity = sentenceSimilarity(obj.value, answer);
|
||||
if (similarity >= score)
|
||||
responses.push({
|
||||
similarity,
|
||||
value: obj.value,
|
||||
element: obj.element,
|
||||
});
|
||||
}
|
||||
return responses.sort((a, b) => a.similarity - b.similarity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a number to a readable string pourcentage
|
||||
* @param similarity
|
||||
*/
|
||||
export function toPourcentage(similarity: number): string {
|
||||
return Math.round(similarity * 100 * 100) / 100 + "%";
|
||||
}
|
||||
Reference in New Issue
Block a user