🎉 initial commit
This commit is contained in:
125
src/renderer/utils/annotationParser.ts
Normal file
125
src/renderer/utils/annotationParser.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { TextAnnotation, AnnotationType } from '../types/editor'
|
||||
|
||||
// Attempt to locate AI-quoted text in the document and create highlight annotations
|
||||
export function parseAnnotationsFromAIResponse(
|
||||
aiResponse: string,
|
||||
documentContent: string
|
||||
): TextAnnotation[] {
|
||||
const annotations: TextAnnotation[] = []
|
||||
let id = 0
|
||||
|
||||
// Match quoted strings — handles "straight", "curly", and 'single' quotes
|
||||
// Minimum 10 chars to avoid matching short words
|
||||
const quotePattern = /["""''](.{10,300}?)["""'']/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = quotePattern.exec(aiResponse)) !== null) {
|
||||
const quotedText = match[1].trim()
|
||||
|
||||
// Try exact match first
|
||||
let docIndex = documentContent.indexOf(quotedText)
|
||||
|
||||
// If not found exactly, try a normalized version (collapse whitespace)
|
||||
if (docIndex === -1) {
|
||||
const normalized = quotedText.replace(/\s+/g, ' ')
|
||||
docIndex = findNormalized(documentContent, normalized)
|
||||
}
|
||||
|
||||
if (docIndex === -1) continue
|
||||
|
||||
// Don't annotate the same range twice
|
||||
const alreadyAnnotated = annotations.some(
|
||||
(a) => a.from === docIndex && a.to === docIndex + quotedText.length
|
||||
)
|
||||
if (alreadyAnnotated) continue
|
||||
|
||||
// Determine annotation type from context around the quote in the AI response
|
||||
const contextStart = Math.max(0, match.index - 200)
|
||||
const contextBefore = aiResponse.slice(contextStart, match.index).toLowerCase()
|
||||
const type = classifyType(contextBefore)
|
||||
|
||||
// Try to extract a suggestion from text after the quote
|
||||
const afterQuote = aiResponse.slice(match.index + match[0].length, match.index + match[0].length + 400)
|
||||
const suggestion = extractSuggestion(afterQuote)
|
||||
|
||||
// Extract a short message label from the ISSUE/PROBLEM line before the quote
|
||||
const message = extractMessage(aiResponse, match.index)
|
||||
|
||||
annotations.push({
|
||||
id: `ai-${id++}`,
|
||||
type,
|
||||
from: docIndex,
|
||||
to: docIndex + quotedText.length,
|
||||
matchedText: quotedText,
|
||||
message: message || `${type.replace('_', ' ')} — hover for details`,
|
||||
suggestion
|
||||
})
|
||||
}
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
function classifyType(contextBefore: string): AnnotationType {
|
||||
if (contextBefore.includes('passive')) return 'passive_voice'
|
||||
if (
|
||||
contextBefore.includes('consistency') ||
|
||||
contextBefore.includes('character') ||
|
||||
contextBefore.includes('timeline') ||
|
||||
contextBefore.includes('repeated') ||
|
||||
contextBefore.includes('contradiction')
|
||||
) {
|
||||
return 'consistency'
|
||||
}
|
||||
return 'style'
|
||||
}
|
||||
|
||||
function extractSuggestion(text: string): string | undefined {
|
||||
// Look for SUGGESTION: "..." pattern
|
||||
const m = text.match(/SUGGESTION:\s*["""'](.{5,200}?)["""']/i)
|
||||
return m?.[1]?.trim()
|
||||
}
|
||||
|
||||
function extractMessage(response: string, quoteIndex: number): string {
|
||||
// Look back for ISSUE: or PROBLEM: line
|
||||
const before = response.slice(Math.max(0, quoteIndex - 300), quoteIndex)
|
||||
const issueMatch = before.match(/(?:ISSUE|PROBLEM|WHY):\s*(.+?)(?:\n|$)/gi)
|
||||
if (issueMatch) {
|
||||
const last = issueMatch[issueMatch.length - 1]
|
||||
return last.replace(/^(?:ISSUE|PROBLEM|WHY):\s*/i, '').trim().slice(0, 120)
|
||||
}
|
||||
// Fall back to the last sentence before the quote
|
||||
const sentences = before.split(/[.!?]\s+/)
|
||||
const last = sentences[sentences.length - 1]?.trim()
|
||||
return last?.slice(0, 120) ?? ''
|
||||
}
|
||||
|
||||
// Find a normalized string in a document (ignores whitespace differences)
|
||||
function findNormalized(document: string, normalized: string): number {
|
||||
const words = normalized.split(' ')
|
||||
if (words.length < 3) return -1
|
||||
|
||||
// Search for the first few words as an anchor
|
||||
const anchor = words.slice(0, 4).join(' ')
|
||||
let searchFrom = 0
|
||||
|
||||
while (searchFrom < document.length) {
|
||||
const idx = document.indexOf(words[0], searchFrom)
|
||||
if (idx === -1) break
|
||||
|
||||
// Extract a comparable slice from the document
|
||||
const slice = document.slice(idx, idx + normalized.length * 2).replace(/\s+/g, ' ')
|
||||
if (slice.startsWith(normalized)) {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Check if the anchor matches
|
||||
const docSlice = document.slice(idx, idx + anchor.length + 20).replace(/\s+/g, ' ')
|
||||
if (docSlice.startsWith(anchor)) {
|
||||
return idx
|
||||
}
|
||||
|
||||
searchFrom = idx + 1
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
88
src/renderer/utils/passiveVoice.ts
Normal file
88
src/renderer/utils/passiveVoice.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { TextAnnotation } from '../types/editor'
|
||||
|
||||
// Common irregular past participles
|
||||
const IRREGULAR_PP =
|
||||
'written|known|seen|found|made|done|given|taken|left|told|shown|brought|' +
|
||||
'felt|kept|held|set|put|become|come|run|begun|gone|sent|built|paid|said|' +
|
||||
'heard|met|read|lost|won|broken|fallen|grown|drawn|driven|eaten|forgotten|' +
|
||||
'hidden|ridden|risen|stolen|sworn|thrown|worn|woken|chosen|frozen|gotten|' +
|
||||
'proven|shaken|spoken|stolen|undertaken|woven|withdrawn|born|caught|bought|' +
|
||||
'brought|fought|taught|thought|sought|hit|hurt|let|put|cut|shut|split|spread|' +
|
||||
'led|fed|bled|bred|fled|sped|spun|stung|struck|strung|swung|flung|clung|' +
|
||||
'rung|sung|slung|hung|dug|dug|stuck|struck|stunk|shrunk|drunk|sunk|sprung'
|
||||
|
||||
// Pattern: [to-be form] [optional adverb] [past participle]
|
||||
// Handles: "was written", "is being known", "were quickly sent"
|
||||
const PASSIVE_PATTERN = new RegExp(
|
||||
`\\b(is|was|were|are|been|being|be|am)\\b(\\s+\\w+ly)?\\s+(${IRREGULAR_PP}|\\w+ed)\\b`,
|
||||
'gi'
|
||||
)
|
||||
|
||||
function findSentenceStart(text: string, pos: number): number {
|
||||
let i = pos - 1
|
||||
while (i > 0) {
|
||||
// Look for sentence-ending punctuation followed by whitespace
|
||||
if (/[.!?]/.test(text[i]) && i + 1 < text.length && /\s/.test(text[i + 1])) {
|
||||
return i + 2
|
||||
}
|
||||
// Also stop at paragraph breaks
|
||||
if (text[i] === '\n' && i > 0 && text[i - 1] === '\n') {
|
||||
return i + 1
|
||||
}
|
||||
i--
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function findSentenceEnd(text: string, pos: number): number {
|
||||
let i = pos
|
||||
while (i < text.length) {
|
||||
if (/[.!?]/.test(text[i])) {
|
||||
return i + 1
|
||||
}
|
||||
if (text[i] === '\n') {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
return text.length
|
||||
}
|
||||
|
||||
export function detectPassiveVoice(text: string): TextAnnotation[] {
|
||||
const annotations: TextAnnotation[] = []
|
||||
const seenRanges = new Set<string>()
|
||||
|
||||
PASSIVE_PATTERN.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = PASSIVE_PATTERN.exec(text)) !== null) {
|
||||
const matchStart = match.index
|
||||
const matchEnd = match.index + match[0].length
|
||||
|
||||
// Skip if this looks like "has been" (perfect passive is sometimes fine)
|
||||
// and skip matches inside markdown headers
|
||||
const lineStart = text.lastIndexOf('\n', matchStart) + 1
|
||||
const lineText = text.slice(lineStart, matchEnd)
|
||||
if (lineText.trimStart().startsWith('#')) continue
|
||||
|
||||
const from = findSentenceStart(text, matchStart)
|
||||
const to = findSentenceEnd(text, matchEnd)
|
||||
const key = `${from}-${to}`
|
||||
|
||||
if (seenRanges.has(key)) continue
|
||||
seenRanges.add(key)
|
||||
|
||||
const sentence = text.slice(from, to).trim()
|
||||
annotations.push({
|
||||
id: `pv-${matchStart}`,
|
||||
type: 'passive_voice',
|
||||
from,
|
||||
to,
|
||||
matchedText: sentence,
|
||||
message: `Passive voice: "${match[0].trim()}"`,
|
||||
suggestion: undefined
|
||||
})
|
||||
}
|
||||
|
||||
return annotations
|
||||
}
|
||||
Reference in New Issue
Block a user