Jump to content

User:Phlsph7/SpellGrammarSuggestions.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// Uses the OpenAI API to provide suggestions on spelling and grammar. The suggestions are in wikitext using the template "text diff".
(function(){
	const scriptName = 'SpellGrammarSuggestions';

	$.when(mw.loader.using('mediawiki.util'), $.ready).then(function(){
		const portletLink = mw.util.addPortletLink('p-tb', '#', scriptName, scriptName + 'Id');
		portletLink.onclick = function(e) {
			e.preventDefault();
			start();
		};
	});

	const originalSentenceTag = "**Original sentence:**";
	const correctionTag = "**Correction:**";
	const explanationTag = "**Explanation:**";
	const noSuggestionsMessage = "**No errors found.**";
	let hasError = false;
	let modalTextarea;
	
	function start(){
		modalTextarea = openModalWithTextarea();
	}
	
	// get AI assessments for each text segment, transform them to wikitext, and display the result in the log area
	async function getAssessments(){
		const textSegments = getTextSegments();
		const assessments = [];
		
		for(let i = 0; i < textSegments.length; i++){
			let message = `Processing text segment ${i+1}/${textSegments.length} ...`;
			logTextarea(message);
			assessment = await getAssessment(textSegments[i]) + '\n';
			
			if(hasError){
				break;
			}
			assessments.push(assessment);
		}

		if(hasError){
			clearTextarea();
			logTextarea(`There was an error. The most likely sources of the error are:

* You entered a false OpenAI API key.
* Your OpenAI account ran out of credit.

You can ask at the script talk page if you are unable to resolve the error.`);
		}
		else{
			let fullAssessment = assessments.join('\n').split('\r').join('');
			fullAssessment = fullAssessment.split(noSuggestionsMessage).join('');
			
			fullAssessment = fullReplace(fullAssessment, '\n\n\n', '\n\n');
			fullAssessment = fullReplace(fullAssessment, '\n ', '\n');
			while(fullAssessment[0] === '\n'){
				fullAssessment = fullAssessment.substring(1);
			}
			
			fullAssessment = fullAssessment.trim();
			let wikiText = '';
			if(fullAssessment.length < 20){
				wikiText = 'The script did not detect any spelling or grammar errors.';
			}
			else{
				wikiText = fullAssessmentToWikitext(fullAssessment);
			}

			clearTextarea();
			logTextarea(wikiText);
		}
	}

	// get an individual assessment for a single text segment
	async function getAssessment(textSegment){
		const systemPrompt = 
`Check the provided encyclopedic text for spelling and grammar errors, and suggest corrections.

- Review the text thoroughly to identify any spelling mistakes or grammatical issues.
- Offer clear corrections for each identified error.
- Maintain the original meaning and encyclopedic style of the text.
- Only report sentences that contain objective errors. Do not make subjective style suggestions.
- Do not make changes to the English variant (American English, British English)
- Provide a concise explanation for each correction about why it is necessary.

# Output Format

The output should be a list of identified errors and their corrections in the following format:

${originalSentenceTag} "[original sentence with error]"
${correctionTag} "[corrected sentence]"
${explanationTag} "[explanation of the correction]"

${originalSentenceTag} ...

Do not list sentences that contain no errors. If the whole text contains no errors, respond only with the following standard message:

${noSuggestionsMessage}`;
		const messages = [
			{role: "system", content: systemPrompt},
			{role: "user", content: textSegment},
		];
		
		console.log(messages);

		const url = "https://api.openai.com/v1/chat/completions";
		const body = JSON.stringify({
			"messages": messages,
			"model": "gpt-4o",
			"temperature": 0,
		});
		const headers = {
			"content-type": "application/json",
			Authorization: "Bearer " + localStorage.getItem('SpellGrammarSuggestionsAPIKey'),
		};

		const init = {
			method: "POST",
			body: body,
			headers: headers
		};
		
		let assessment;
		const response = await fetch(url, init);
		console.log(response);
		if(response.ok){
			const json = await response.json();
			assessment = json.choices[0].message.content;
		}
		else{
			hasError = true;
			assessment = 'error';
		}
		
		console.log(assessment);
		return assessment;
	}

	// transform the html paragraphs containing the article text into text segments
	function getTextSegments(){
		const elementContainer = $('#mw-content-text').find('.mw-parser-output').eq(0)[0].cloneNode(true);
		
		// remove references
		const refs = elementContainer.querySelectorAll('.reference, .Inline-Template');
		for(let ref of refs){
			ref.outerHTML = '';
		}
		
		// remove annotation (of math elements)
		const anElements = elementContainer.querySelectorAll('annotation');
		for(let anElement of anElements){
			anElement.outerHTML = '';
		}
		
		// get p-elements
		const children = Array.from(elementContainer.children);
		const elementParas = children.filter(function(item){
			return item.tagName.toLowerCase() === 'p';
		});
		
		// transform p-elements to text
		const textParas = [];
		for(let elementPara of elementParas){
			let tempText = elementPara.innerText;
			
			// adjustments like removing newlines caused by mathematical formulas
			tempText = tempText.split('\r').join('')
				.split('\n').join(' ')
				.split('\t').join(' ');
			tempText = fullReplace(tempText, '  ', ' ');
			tempText = tempText.trim();
				
			// ignore very short paragraphs
			const minTextParaLength = 50;
			if(tempText.length >= minTextParaLength){
				textParas.push(tempText);
			}
		}
		
		// combine textParas to form longer textSegments
		const maxTextSegmentLength = 5000;
		const textSegments = textParas.splice(0, 1);
		let curSegmentIndex = 0;
		while(textParas.length > 0){
			let curPara = textParas.splice(0, 1)[0];
			if(textSegments[curSegmentIndex].length + curPara.length < 5000){
				textSegments[curSegmentIndex] += '\n\n' + curPara;
			}
			else{
				textSegments.push(curPara);
				curSegmentIndex++;
			}
		}
		
		return textSegments;
	}

	// transform the full assessment to wikitext
	function fullAssessmentToWikitext(fullAssessment){
		let wikitext = '';
		let individualAssessments = fullAssessment.split(originalSentenceTag);
		for(let individualAssessment of individualAssessments){
			if(hasExpectedFormat(individualAssessment)){
				let originalSentence = individualAssessment.split(correctionTag)[0].trim();
				originalSentence = removeQuotationMarks(originalSentence);
				let remainingAssesment = individualAssessment.split(correctionTag)[1];
				let correctedSentence = remainingAssesment.split(explanationTag)[0].trim();
				correctedSentence = removeQuotationMarks(correctedSentence);
				let explanationSentence = remainingAssesment.split(explanationTag)[1].trim();
				wikitext += `{{text diff|${originalSentence}|${correctedSentence}}}\n:::Explanation: ${explanationSentence}\n\n\n\n`;
			}
			else if (individualAssessment.trim() > 50){
				individualAssessment = originalSentenceTag + ' ' + individualAssessment.trim();
				individualAssessment = individualAssessment.split(':**').join('')
					.split('**').join('*'); 
				individualAssessment += '\n*:[This suggestion is not displayed using the template "text diff" because the AI response did not have the expected format.]\n\n\n\n';
			}
		}
		
		return wikitext.trim();
		
		// LLM output format may be inconsistent so it has to be checked
		function hasExpectedFormat(individualAssessment){
			let correctionTagCount = individualAssessment.split(correctionTag).length - 1;
			let explanationTagCount = individualAssessment.split(correctionTag).length - 1;
			
			return correctionTagCount === 1 && explanationTagCount === 1;
		}
		
		function removeQuotationMarks(text){
			if (text.startsWith('"')) {
				text = text.substring(1);
			}
			if (text.endsWith('"')) {
				text = text.substring(0, text.length - 1);
			}
			return text;
		}
	}

	// create the modal overlay to display controls and output
	function openModalWithTextarea() {
		const overlay = document.createElement('div');
		overlay.style.position = 'fixed';
		overlay.style.top = '0';
		overlay.style.left = '0';
		overlay.style.width = '100%';
		overlay.style.height = '100%';
		overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
		overlay.style.display = 'flex';
		overlay.style.justifyContent = 'center';
		overlay.style.alignItems = 'center';
		overlay.style.zIndex = '1000';

		const modal = document.createElement('div');
		modal.style.backgroundColor = 'white';
		modal.style.padding = '15px';
		modal.style.borderRadius = '5px';
		modal.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.1)';
		modal.style.width = '80%';
		modal.style.height = '70%';
		modal.style.display = 'flex';
		modal.style.flexDirection = 'column';
		overlay.appendChild(modal);
		
		const title = document.createElement('div');
		title.innerHTML = "SpellGrammarSuggestions";
		title.style.marginBottom = '15px';
		modal.appendChild(title);

		const textarea = document.createElement('textarea');
		textarea.style.width = '100%';
		textarea.style.height = '80%';
		textarea.style.resize = 'none';
		textarea.style.marginBottom = '15px';
		textarea.style.borderRadius = '5px';
		textarea.readOnly = true;
		modal.appendChild(textarea);
		
		const buttonContainer = document.createElement('div');
		buttonContainer.style.display = 'flex';
		buttonContainer.style.flexDirection = 'row';
		modal.appendChild(buttonContainer);
		
		const startButton = addButton("Start", function(){
			clearTextarea();
			let currentAPIKey = localStorage.getItem('SpellGrammarSuggestionsAPIKey');
			if(currentAPIKey === 'null' || currentAPIKey === null || currentAPIKey === ''){
				clearTextarea();
				logTextarea('No OpenAI API key detected. This script requires an OpenAI API key. Use the button below to add one.');
			}
			else{
				getAssessments();
				startButton.disabled = true;
			}
		});
		
		addButton("Copy", function(){
			modalTextarea.select();
			document.execCommand("copy");
		});
		addButton("Add/Remove API key", function(){			
			let currentAPIKey = localStorage.getItem('SpellGrammarSuggestionsAPIKey');
			if(currentAPIKey === 'null' || currentAPIKey === null){
				currentAPIKey = '';
			}
			
			let input = prompt('Please enter your OpenAI API key. It starts with "sk-...". It will be saved locally on your device. It will not be shared with anyone and will only be used for your queries to OpenAI. To delete your API key, leave this field empty and press [OK].', currentAPIKey);
			
			// check that the cancel-button was not pressed
			if(input !== null){
				localStorage.setItem('SpellGrammarSuggestionsAPIKey', input);
			}
			
			startButton.disabled = false;
		});
		addButton("Close", function(){
			document.body.removeChild(overlay);
		});

		document.body.appendChild(overlay);
		
		return textarea;
		
		function addButton(textContent, clickFunction){
			const button = document.createElement('button');
			button.textContent = textContent;
			button.style.padding = '5px';
			button.style.margin = '5px';
			button.style.flex = '1';
			button.addEventListener('click', clickFunction);
			buttonContainer.appendChild(button);
			return button;
		}
	}

	function logTextarea(text){
		modalTextarea.value = text + '\n' + modalTextarea.value;
	}

	function clearTextarea(){
		modalTextarea.value = '';
	}
	
	// get the title of the page
	function getTitle(){
		let innerText = document.getElementById('firstHeading').innerText;
		if(innerText.substring(0, 8) === 'Editing '){
			innerText = innerText.substring(8);
		}
		if(innerText.substring(0, 6) === 'Draft:'){
			innerText = innerText.substring(6);
		}
		if(innerText.includes('User:')){
			let parts = innerText.split('/');
			parts.shift();
			innerText = parts.join('/');
		}
		return innerText;
	}
	
	// replace the old string with the new string until no instance of the old string remains
	function fullReplace(string, oldSubstring, newSubstring){
		let newString = string;
		while(newString.includes(oldSubstring)){
			newString = newString.split(oldSubstring).join(newSubstring);
		}
		return newString;
	}
})();