diff --git a/web/frontend/src/features/workspace/components/extraction-block-extension.ts b/web/frontend/src/features/workspace/components/extraction-block-extension.ts index d2d1f1464..71f228e62 100644 --- a/web/frontend/src/features/workspace/components/extraction-block-extension.ts +++ b/web/frontend/src/features/workspace/components/extraction-block-extension.ts @@ -158,14 +158,51 @@ export const ExtractionBlockExtension = Node.create({ }, addKeyboardShortcuts() { - const isSelectionInExtractionBlock = () => { - const { $from } = this.editor.state.selection + const getSelectionInExtractionBlock = () => { + const { selection } = this.editor.state + if (!selection.empty) { + return null + } + + const { $from } = selection + let blockDepth: number | null = null + for (let depth = $from.depth; depth > 0; depth--) { if ($from.node(depth).type.name === this.name) { - return true + blockDepth = depth + break } } - return false + + if (blockDepth === null) { + return null + } + + return { $from, blockDepth } + } + + const isSelectionInExtractionBlock = () => { + return getSelectionInExtractionBlock() !== null + } + + const isSelectionAtExtractionBlockStart = () => { + const selectionInBlock = getSelectionInExtractionBlock() + if (!selectionInBlock) { + return false + } + + const { $from, blockDepth } = selectionInBlock + if ($from.parentOffset !== 0) { + return false + } + + for (let depth = $from.depth; depth > blockDepth; depth--) { + if ($from.index(depth) !== 0) { + return false + } + } + + return true } const splitToParagraphInBlock = () => { @@ -178,6 +215,14 @@ export const ExtractionBlockExtension = Node.create({ } return { + Backspace: () => { + const selectionInBlock = getSelectionInExtractionBlock() + if (!selectionInBlock || !isSelectionAtExtractionBlockStart()) { + return false + } + + return true + }, Enter: () => splitToParagraphInBlock(), 'Shift-Enter': () => { return splitToParagraphInBlock() diff --git a/web/frontend/src/features/workspace/components/extraction-block.tsx b/web/frontend/src/features/workspace/components/extraction-block.tsx index a20d42be9..f2ca5d4e6 100644 --- a/web/frontend/src/features/workspace/components/extraction-block.tsx +++ b/web/frontend/src/features/workspace/components/extraction-block.tsx @@ -8,13 +8,75 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { extractionChatScopePluginKey } from './extraction-context-plugin' import { cn } from '@/helpers/utils' +const CONFLICT_GROUP_PREFIX = 'conflict-group--' +const CONFLICT_VARIANT_PREFIX = 'conflict-variant--' + +const conflictVariantStore = new Map() +const conflictVariantListeners = new Set<() => void>() + +function notifyConflictVariantListeners() { + conflictVariantListeners.forEach((listener) => listener()) +} + +function getConflictClasses(value: unknown) { + return String(value || '') + .split(/\s+/) + .filter(Boolean) +} + +function getConflictClassValue(classes: string[], prefix: string) { + const match = classes.find((className) => className.startsWith(prefix)) + return match ? match.slice(prefix.length) : null +} + +function formatVariantLabel(variantName: string) { + if (variantName === 'council') { + return 'council' + } + + return variantName.replace(/[-_]+/g, ' ') +} + +function getConflictGroupVariants(editor: NodeViewProps['editor'], conflictGroup: string) { + const variants = new Map() + + editor.state.doc.descendants((currentNode) => { + if (currentNode.type.name !== 'extractionBlock') { + return true + } + + const classes = getConflictClasses(currentNode.attrs.htmlClass) + const currentGroup = getConflictClassValue(classes, CONFLICT_GROUP_PREFIX) + const variant = getConflictClassValue(classes, CONFLICT_VARIANT_PREFIX) + + if (currentGroup !== conflictGroup || !variant) { + return true + } + + variants.set(variant, { + label: formatVariantLabel(variant), + blockId: (currentNode.attrs.blockId as string | null) ?? null, + }) + + return true + }) + + return Array.from(variants.entries()) + .map(([value, meta]) => ({ value, ...meta })) + .sort((left, right) => { + if (left.value === 'council') return -1 + if (right.value === 'council') return 1 + return left.label.localeCompare(right.label) + }) +} + interface ExtractionBlockProps extends NodeViewProps { onBlockSelect?: (blockId: string | null, pageNumber: number | null) => void onBlockDelete?: (blockId: string, pageNumber: number | null) => void } export function ExtractionBlock({ node, editor, extension, deleteNode }: ExtractionBlockProps) { - const { blockId, title, type, page, isNew } = node.attrs + const { blockId, title, type, page, isNew, htmlClass } = node.attrs const onBlockSelect = extension.options.onBlockSelect const onBlockDelete = extension.options.onBlockDelete const onToggleBlockContext = extension.options.onToggleBlockContext @@ -31,6 +93,20 @@ export function ExtractionBlock({ node, editor, extension, deleteNode }: Extract pageNumber !== null && (chatScopePluginState?.pageNumbersInChatScope ?? []).includes(pageNumber) const isWholeDocumentInChatScope = chatScopePluginState?.isWholeDocumentInChatScope ?? false const isContextInteractionDisabled = chatScopePluginState?.isInteractionDisabled ?? false + const htmlClasses = getConflictClasses(htmlClass) + const hasConflictClass = htmlClasses.includes('conflict') + const conflictGroup = getConflictClassValue(htmlClasses, CONFLICT_GROUP_PREFIX) + const conflictVariant = getConflictClassValue(htmlClasses, CONFLICT_VARIANT_PREFIX) + const [activeConflictVariant, setActiveConflictVariant] = useState(() => { + if (!conflictGroup) { + return null + } + + return conflictVariantStore.get(conflictGroup) ?? 'council' + }) + const conflictVariants = conflictGroup ? getConflictGroupVariants(editor, conflictGroup) : [] + const isConflictVariantVisible = + !conflictGroup || !conflictVariant || activeConflictVariant === conflictVariant const blockContextTitle = isContextInteractionDisabled ? 'Prompt context is unavailable while you have unsaved changes.' : isWholeDocumentInChatScope @@ -57,6 +133,43 @@ export function ExtractionBlock({ node, editor, extension, deleteNode }: Extract } }, [editor]) + useEffect(() => { + if (!conflictGroup) { + return + } + + const initialVariant = conflictVariantStore.get(conflictGroup) ?? 'council' + conflictVariantStore.set(conflictGroup, initialVariant) + setActiveConflictVariant(initialVariant) + + const syncVariant = () => { + setActiveConflictVariant(conflictVariantStore.get(conflictGroup) ?? 'council') + } + + conflictVariantListeners.add(syncVariant) + + return () => { + conflictVariantListeners.delete(syncVariant) + } + }, [conflictGroup]) + + useEffect(() => { + if (!hasConflictClass || !blockId) { + return + } + + const blockElement = editor.view.dom.querySelector(`[data-block-id="${blockId}"]`) + if (!(blockElement instanceof HTMLElement)) { + return + } + + blockElement + .querySelectorAll('.conflict-comment, .conflict-comment *') + .forEach((element) => { + element.setAttribute('contenteditable', 'false') + }) + }, [editor, blockId, hasConflictClass, activeConflictVariant]) + const handleToggleBlockScope = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() @@ -110,16 +223,35 @@ export function ExtractionBlock({ node, editor, extension, deleteNode }: Extract onBlockDelete?.(blockId, pageNumber) } + const handleConflictTabClick = (variant: string) => (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + + if (!conflictGroup) { + return + } + + conflictVariantStore.set(conflictGroup, variant) + notifyConflictVariantListeners() + } + return ( - {(type || page || isNew) && ( + {(type || page || isNew || conflictVariants.length > 0) && (
)} + {conflictVariants.length > 0 && ( +
+
+ {conflictVariants.map((variant) => { + const isActive = activeConflictVariant === variant.value + + return ( + + ) + })} +
+
+ )} + ) diff --git a/web/frontend/src/features/workspace/components/extraction-editor-extensions.ts b/web/frontend/src/features/workspace/components/extraction-editor-extensions.ts index 2d4f9d707..8804538d2 100644 --- a/web/frontend/src/features/workspace/components/extraction-editor-extensions.ts +++ b/web/frontend/src/features/workspace/components/extraction-editor-extensions.ts @@ -1,4 +1,5 @@ import StarterKit from '@tiptap/starter-kit' +import { mergeAttributes, Node } from '@tiptap/core' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table' @@ -28,6 +29,55 @@ const OcrParagraph = Paragraph.extend({ }, }) +const OcrSpan = Node.create({ + name: 'ocrSpan', + + group: 'inline', + + inline: true, + + content: 'inline*', + + selectable: false, + + addAttributes() { + return { + htmlClass: { + default: null, + parseHTML: (element) => element.getAttribute('class'), + renderHTML: (attributes) => { + if (!attributes.htmlClass) return {} + return { class: attributes.htmlClass } + }, + }, + htmlId: { + default: null, + parseHTML: (element) => element.getAttribute('id'), + renderHTML: (attributes) => { + if (!attributes.htmlId) return {} + return { id: attributes.htmlId } + }, + }, + title: { + default: null, + parseHTML: (element) => element.getAttribute('title'), + renderHTML: (attributes) => { + if (!attributes.title) return {} + return { title: attributes.title } + }, + }, + } + }, + + parseHTML() { + return [{ tag: 'span' }] + }, + + renderHTML({ HTMLAttributes }) { + return ['span', mergeAttributes(HTMLAttributes), 0] + }, +}) + function parseCellAttrs(node: HTMLElement) { const colspan = Number(node.getAttribute('colspan') || 1) const rowspan = Number(node.getAttribute('rowspan') || 1) @@ -185,6 +235,7 @@ export function createExtractionTableExtensions() { ListItem.configure(ListKitStylingConfig.listItem), ExtractionDocument, OcrParagraph, + OcrSpan, Table.configure(TableKitStylingConfig.table), TableRow.configure(TableKitStylingConfig.tableRow), OcrTableHeader.configure(TableKitStylingConfig.tableHeader), diff --git a/web/frontend/src/features/workspace/components/extraction-editor.test.tsx b/web/frontend/src/features/workspace/components/extraction-editor.test.tsx index cd5d36234..06b7e9cd0 100644 --- a/web/frontend/src/features/workspace/components/extraction-editor.test.tsx +++ b/web/frontend/src/features/workspace/components/extraction-editor.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, waitFor, within } from '@testing-library/react' +import { act, fireEvent, render, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ExtractionEditor from './extraction-editor' @@ -43,6 +43,33 @@ const hocrBlock = `
` +const contentWithConflictBlock = ` +
+

First block

+
+
+

Conflicting block

+
+` + +const contentWithConflictTabs = ` +
+

Recorded By/Date:

+

13NOV23

+

Consensus reached on 13NOV23.

+
+
+

Recorded By/Date:

+

13N0V23

+

Miner-U suggested 13N0V23.

+
+
+

Recorded By/Date:

+

13NOV23

+

GPT-4 Vision confirmed 13NOV23.

+
+` + describe('ExtractionEditor', () => { beforeEach(() => { vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock) @@ -661,4 +688,178 @@ describe('ExtractionEditor', () => { expect(oldToggleBlockContext).not.toHaveBeenCalled() expect(latestToggleBlockContext).toHaveBeenCalledWith('block_1_1', 1) }) + + it('highlights extraction blocks marked with the conflict hOCR class', async () => { + const { container } = render( + + ) + + await waitFor(() => { + expect(container.querySelector('[data-block-id="block_1_2"]')).not.toBeNull() + }) + + const conflictBlock = container.querySelector('[data-block-id="block_1_2"]') + + expect(conflictBlock).toHaveClass('ocr_carea') + expect(conflictBlock).toHaveClass('conflict') + expect(conflictBlock).toHaveClass('border-l-amber-500') + expect(conflictBlock).toHaveClass('bg-amber-50/70') + }) + + it('renders conflict variants as tabs and switches the visible variant', async () => { + const { container } = render( + + ) + + await waitFor(() => { + expect(container.querySelector('[data-block-id="block_1_4"]')).not.toBeNull() + expect(container.querySelector('[data-block-id="block_1_4__miner_u"]')).not.toBeNull() + }) + + const councilBlock = container.querySelector('[data-block-id="block_1_4"]') as HTMLElement + const minerBlock = container.querySelector( + '[data-block-id="block_1_4__miner_u"]' + ) as HTMLElement + + expect(councilBlock).not.toHaveClass('hidden') + expect(minerBlock).toHaveClass('hidden') + expect(within(councilBlock).getByText('Consensus reached on 13NOV23.')).toBeInTheDocument() + expect(councilBlock.querySelector('.conflict-target')).not.toBeNull() + + fireEvent.click(within(councilBlock).getByRole('button', { name: 'miner u' })) + + await waitFor(() => { + expect(minerBlock).not.toHaveClass('hidden') + }) + + expect(councilBlock).toHaveClass('hidden') + expect(within(minerBlock).getByText('Miner-U suggested 13N0V23.')).toBeInTheDocument() + expect(within(minerBlock).getByText('13N0V23')).toBeInTheDocument() + }) + + it('marks conflict comment lines as non-editable', async () => { + const { container } = render( + + ) + + await waitFor(() => { + expect(container.querySelector('#comment_line_block_1_4__gpt_4_vision')).not.toBeNull() + }) + + const commentLine = container.querySelector( + '#comment_line_block_1_4__gpt_4_vision' + ) as HTMLElement + + expect(commentLine).toHaveAttribute('contenteditable', 'false') + }) + + it('does not merge a conflict block into the previous block on backspace at block start', async () => { + const contentWithPrecedingBlock = ` +
+

Dry mouth

+
+
+

Dry eyes

+
+
+

Headache

+
+ ` + + const { container } = render( + + ) + + await waitFor(() => { + expect(container.querySelector('[data-block-id="block_1_4"]')).not.toBeNull() + }) + + const editorElement = container.querySelector('.ProseMirror') as HTMLElement + const conflictTextNode = container.querySelector( + '[data-block-id="block_1_4"] .ocr_par .ocr_line' + )?.firstChild + + expect(editorElement).not.toBeNull() + expect(conflictTextNode).not.toBeNull() + + const selection = window.getSelection() + const range = document.createRange() + range.setStart(conflictTextNode as Text, 0) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + + fireEvent.focus(editorElement) + document.dispatchEvent(new Event('selectionchange')) + + await act(async () => { + fireEvent.keyDown(editorElement, { key: 'Backspace', code: 'Backspace' }) + }) + + expect(container.querySelectorAll('[data-block-id="block_1_4"]').length).toBe(1) + expect(container.querySelectorAll('[data-block-id="block_1_3"]').length).toBe(1) + + const previousBlockContent = container.querySelector( + '[data-block-id="block_1_3"] [data-node-view-content]' + ) + + expect(previousBlockContent?.textContent).toContain('Headache') + expect(previousBlockContent?.textContent).not.toContain('Change in your sense of') + }) }) diff --git a/web/frontend/src/features/workspace/components/extraction-editor.tsx b/web/frontend/src/features/workspace/components/extraction-editor.tsx index 43fb00ce9..534f1557d 100644 --- a/web/frontend/src/features/workspace/components/extraction-editor.tsx +++ b/web/frontend/src/features/workspace/components/extraction-editor.tsx @@ -107,17 +107,6 @@ const ExtractionEditor = ({ attributes: { class: 'prose prose-sm max-w-none focus:outline-none', }, - editable: function isEditable(state) { - const { $from } = state.selection - for (let depth = $from.depth; depth > 0; depth--) { - const node = $from.node(depth) - if (node.type.name === 'customTab') { - const modelAttr = node.attrs?.model - return modelAttr === 'council' - } - } - return true - }, }, onCreate: ({ editor }) => { onBaselineReady(editor.getHTML()) diff --git a/web/frontend/src/mocks/data/extractions/conflict_tabs.hocr b/web/frontend/src/mocks/data/extractions/conflict_tabs.hocr new file mode 100644 index 000000000..31306ef2b --- /dev/null +++ b/web/frontend/src/mocks/data/extractions/conflict_tabs.hocr @@ -0,0 +1,103 @@ + + + + + + + + +
+ +
+

+ Dry mouth +

+
+ +
+

+ Dry eyes +

+
+ +
+

+ Headache +

+
+ +
+

+ + Change in your sense of + taste + +

+

+ + Council selected “taste” as the most likely correct extraction. + +

+
+ +
+

+ + Change in your sense of + smell + +

+

+ + Miner-U read the final token as “smell” because the last word shape is ambiguous. + +

+
+ +
+

+ + Change in your sense of + taste + +

+

+ + GPT-4 Vision prefers “taste” based on the visible ascender and word length. + +

+
+ +
+

+ + Change in your sense of + taste + +

+

+ + DeepSeek OCR 2 also selected “taste”, but with lower confidence than council. + +

+
+ +
+ + \ No newline at end of file