Store all mode results (OCR, Describe, Freeform) in a single job record
- DB: add describe_text and freeform_text columns (ALTER TABLE IF NOT EXISTS) - Backend: commit and review endpoints accept/persist all three text fields - App: accumulate results per mode in state; tabs appear when >1 mode run; all results sent on Commit Job - JobDetail: tabbed text panel shows whichever fields are populated, all editable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,15 @@ def init_db():
|
|||||||
cur.execute("""
|
cur.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS ocr_jobs_submitted_at_idx ON ocr_jobs(submitted_at DESC)
|
CREATE INDEX IF NOT EXISTS ocr_jobs_submitted_at_idx ON ocr_jobs(submitted_at DESC)
|
||||||
""")
|
""")
|
||||||
|
# Add columns introduced after initial schema (safe to run repeatedly)
|
||||||
|
cur.execute("""
|
||||||
|
ALTER TABLE ocr_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS describe_text TEXT
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
ALTER TABLE ocr_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS freeform_text TEXT
|
||||||
|
""")
|
||||||
# Unique constraint: prevent duplicate (author, chapter, page) submissions.
|
# Unique constraint: prevent duplicate (author, chapter, page) submissions.
|
||||||
# Applies only when all three fields are non-null.
|
# Applies only when all three fields are non-null.
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
|
|||||||
@@ -607,6 +607,8 @@ class ReviewRequest(BaseModel):
|
|||||||
book: Optional[str] = None
|
book: Optional[str] = None
|
||||||
chapter: Optional[str] = None
|
chapter: Optional[str] = None
|
||||||
page: Optional[str] = None
|
page: Optional[str] = None
|
||||||
|
describe_text: Optional[str] = None
|
||||||
|
freeform_text: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _job_row_to_dict(row) -> Dict[str, Any]:
|
def _job_row_to_dict(row) -> Dict[str, Any]:
|
||||||
@@ -628,6 +630,8 @@ async def commit_job(
|
|||||||
chapter: str = Form(""),
|
chapter: str = Form(""),
|
||||||
page: str = Form(""),
|
page: str = Form(""),
|
||||||
ocr_text: str = Form(""),
|
ocr_text: str = Form(""),
|
||||||
|
describe_text: str = Form(""),
|
||||||
|
freeform_text: str = Form(""),
|
||||||
mode: str = Form("plain_ocr"),
|
mode: str = Form("plain_ocr"),
|
||||||
):
|
):
|
||||||
"""Commit an OCR job: save the image and insert a DB record."""
|
"""Commit an OCR job: save the image and insert a DB record."""
|
||||||
@@ -660,13 +664,13 @@ async def commit_job(
|
|||||||
"""
|
"""
|
||||||
INSERT INTO ocr_jobs
|
INSERT INTO ocr_jobs
|
||||||
(id, author, book, chapter, page, image_path, original_filename,
|
(id, author, book, chapter, page, image_path, original_filename,
|
||||||
ocr_text, mode, status)
|
ocr_text, describe_text, freeform_text, mode, status)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'unreviewed')
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'unreviewed')
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(job_id, author or None, book or None, chapter or None,
|
(job_id, author or None, book or None, chapter or None,
|
||||||
page or None, image_path, original_filename,
|
page or None, image_path, original_filename,
|
||||||
ocr_text or None, mode),
|
ocr_text or None, describe_text or None, freeform_text or None, mode),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -852,7 +856,9 @@ async def review_job(job_id: str, body: ReviewRequest):
|
|||||||
author = %s,
|
author = %s,
|
||||||
book = %s,
|
book = %s,
|
||||||
chapter = %s,
|
chapter = %s,
|
||||||
page = %s
|
page = %s,
|
||||||
|
describe_text = %s,
|
||||||
|
freeform_text = %s
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
@@ -863,6 +869,8 @@ async def review_job(job_id: str, body: ReviewRequest):
|
|||||||
body.book or None,
|
body.book or None,
|
||||||
body.chapter or None,
|
body.chapter or None,
|
||||||
body.page or None,
|
body.page or None,
|
||||||
|
body.describe_text,
|
||||||
|
body.freeform_text,
|
||||||
job_id,
|
job_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,15 +43,21 @@ function App() {
|
|||||||
const suggestions = useSuggestions()
|
const suggestions = useSuggestions()
|
||||||
|
|
||||||
const [metadata, setMetadata] = useState({ author: '', book: '', chapter: '', page: '' })
|
const [metadata, setMetadata] = useState({ author: '', book: '', chapter: '', page: '' })
|
||||||
const [editedOcrText, setEditedOcrText] = useState('')
|
// Results accumulated per mode: { plain_ocr: 'text', describe: 'text', freeform: 'text' }
|
||||||
|
const [modeResults, setModeResults] = useState({})
|
||||||
|
const [editedResults, setEditedResults] = useState({})
|
||||||
|
const [activeResultMode, setActiveResultMode] = useState(null)
|
||||||
const [commitLoading, setCommitLoading] = useState(false)
|
const [commitLoading, setCommitLoading] = useState(false)
|
||||||
const [commitResult, setCommitResult] = useState(null)
|
const [commitResult, setCommitResult] = useState(null)
|
||||||
|
|
||||||
// Modes that produce editable text output and can be committed to the DB
|
// Modes that produce editable text output and can be committed to the DB
|
||||||
const COMMITTABLE_MODES = new Set(['plain_ocr', 'describe', 'freeform'])
|
const COMMITTABLE_MODES = new Set(['plain_ocr', 'describe', 'freeform'])
|
||||||
|
const MODE_LABELS = { plain_ocr: 'OCR Text', describe: 'Description', freeform: 'Freeform' }
|
||||||
|
|
||||||
// Whether to show the full-screen result view
|
// Show the full-screen result view when any committable mode has a result (or is loading)
|
||||||
const showResultView = view === 'new_job' && COMMITTABLE_MODES.has(mode) && !!result
|
const showResultView = view === 'new_job' && (
|
||||||
|
Object.keys(modeResults).length > 0 || (loading && COMMITTABLE_MODES.has(mode))
|
||||||
|
)
|
||||||
|
|
||||||
const handleFileTypeChange = useCallback((newType) => {
|
const handleFileTypeChange = useCallback((newType) => {
|
||||||
setImage(null)
|
setImage(null)
|
||||||
@@ -69,14 +75,18 @@ function App() {
|
|||||||
setImagePreview(null)
|
setImagePreview(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
setResult(null)
|
setResult(null)
|
||||||
setEditedOcrText('')
|
setModeResults({})
|
||||||
|
setEditedResults({})
|
||||||
|
setActiveResultMode(null)
|
||||||
setCommitResult(null)
|
setCommitResult(null)
|
||||||
} else {
|
} else {
|
||||||
setImage(file)
|
setImage(file)
|
||||||
setImagePreview(fileType === 'image' ? URL.createObjectURL(file) : file)
|
setImagePreview(fileType === 'image' ? URL.createObjectURL(file) : file)
|
||||||
setError(null)
|
setError(null)
|
||||||
setResult(null)
|
setResult(null)
|
||||||
setEditedOcrText('')
|
setModeResults({})
|
||||||
|
setEditedResults({})
|
||||||
|
setActiveResultMode(null)
|
||||||
setCommitResult(null)
|
setCommitResult(null)
|
||||||
}
|
}
|
||||||
}, [imagePreview, fileType])
|
}, [imagePreview, fileType])
|
||||||
@@ -104,7 +114,12 @@ function App() {
|
|||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
})
|
})
|
||||||
setResult(response.data)
|
setResult(response.data)
|
||||||
setEditedOcrText(response.data.text || '')
|
if (COMMITTABLE_MODES.has(mode)) {
|
||||||
|
const text = response.data.text || ''
|
||||||
|
setModeResults(prev => ({ ...prev, [mode]: text }))
|
||||||
|
setEditedResults(prev => ({ ...prev, [mode]: text }))
|
||||||
|
setActiveResultMode(mode)
|
||||||
|
}
|
||||||
setCommitResult(null)
|
setCommitResult(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || err.message || 'An error occurred')
|
setError(err.response?.data?.detail || err.message || 'An error occurred')
|
||||||
@@ -115,7 +130,9 @@ function App() {
|
|||||||
|
|
||||||
const handleNewAnalysis = () => {
|
const handleNewAnalysis = () => {
|
||||||
setResult(null)
|
setResult(null)
|
||||||
setEditedOcrText('')
|
setModeResults({})
|
||||||
|
setEditedResults({})
|
||||||
|
setActiveResultMode(null)
|
||||||
setCommitResult(null)
|
setCommitResult(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +147,9 @@ function App() {
|
|||||||
formData.append('book', metadata.book)
|
formData.append('book', metadata.book)
|
||||||
formData.append('chapter', metadata.chapter)
|
formData.append('chapter', metadata.chapter)
|
||||||
formData.append('page', metadata.page)
|
formData.append('page', metadata.page)
|
||||||
formData.append('ocr_text', editedOcrText)
|
formData.append('ocr_text', editedResults.plain_ocr || '')
|
||||||
|
formData.append('describe_text', editedResults.describe || '')
|
||||||
|
formData.append('freeform_text', editedResults.freeform || '')
|
||||||
formData.append('mode', mode)
|
formData.append('mode', mode)
|
||||||
|
|
||||||
const response = await axios.post(`${API_BASE}/jobs`, formData, {
|
const response = await axios.post(`${API_BASE}/jobs`, formData, {
|
||||||
@@ -142,15 +161,15 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setCommitLoading(false)
|
setCommitLoading(false)
|
||||||
}
|
}
|
||||||
}, [image, editedOcrText, metadata, mode])
|
}, [image, editedResults, metadata, mode])
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
const text = editedOcrText || result?.text
|
const text = (activeResultMode && editedResults[activeResultMode]) || result?.text
|
||||||
if (text) navigator.clipboard.writeText(text)
|
if (text) navigator.clipboard.writeText(text)
|
||||||
}, [editedOcrText, result])
|
}, [activeResultMode, editedResults, result])
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
const text = editedOcrText || result?.text
|
const text = (activeResultMode && editedResults[activeResultMode]) || result?.text
|
||||||
if (!text) return
|
if (!text) return
|
||||||
const ext = { plain_ocr: 'txt', describe: 'txt', find_ref: 'txt', freeform: 'txt' }[mode] || 'txt'
|
const ext = { plain_ocr: 'txt', describe: 'txt', find_ref: 'txt', freeform: 'txt' }[mode] || 'txt'
|
||||||
const blob = new Blob([text], { type: 'text/plain' })
|
const blob = new Blob([text], { type: 'text/plain' })
|
||||||
@@ -260,16 +279,40 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="glass rounded-2xl p-4 flex flex-col h-full">
|
<div className="glass rounded-2xl p-4 flex flex-col h-full">
|
||||||
|
{/* Mode tabs — only shown when multiple modes have results */}
|
||||||
|
{Object.keys(modeResults).length > 1 && (
|
||||||
|
<div className="flex gap-1 mb-3 flex-shrink-0">
|
||||||
|
{Object.keys(modeResults).map(m => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setActiveResultMode(m)}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
activeResultMode === m
|
||||||
|
? 'bg-purple-600 text-white'
|
||||||
|
: 'bg-white/5 text-gray-400 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{MODE_LABELS[m] || m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-xs text-gray-400 mb-2 flex-shrink-0">
|
<p className="text-xs text-gray-400 mb-2 flex-shrink-0">
|
||||||
{{ plain_ocr: 'OCR Text', describe: 'Description', freeform: 'Result' }[mode] || 'Result'}
|
{MODE_LABELS[activeResultMode] || 'Result'}
|
||||||
<span className="text-purple-400 ml-1">(edit before committing)</span>
|
<span className="text-purple-400 ml-1">(edit before committing)</span>
|
||||||
</p>
|
</p>
|
||||||
|
{loading && COMMITTABLE_MODES.has(mode) ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-purple-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<textarea
|
<textarea
|
||||||
value={editedOcrText}
|
value={activeResultMode ? (editedResults[activeResultMode] ?? '') : ''}
|
||||||
onChange={e => setEditedOcrText(e.target.value)}
|
onChange={e => setEditedResults(prev => ({ ...prev, [activeResultMode]: e.target.value }))}
|
||||||
className="flex-1 w-full bg-transparent text-sm text-gray-200 font-mono resize-none focus:outline-none min-h-0"
|
className="flex-1 w-full bg-transparent text-sm text-gray-200 font-mono resize-none focus:outline-none min-h-0"
|
||||||
placeholder="OCR text appears here..."
|
placeholder="Run a mode to see results here..."
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
|
|||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
const [editedText, setEditedText] = useState('')
|
const [editedText, setEditedText] = useState('')
|
||||||
|
const [editDescribeText, setEditDescribeText] = useState('')
|
||||||
|
const [editFreeformText, setEditFreeformText] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState('ocr')
|
||||||
const [editAuthor, setEditAuthor] = useState('')
|
const [editAuthor, setEditAuthor] = useState('')
|
||||||
const [editBook, setEditBook] = useState('')
|
const [editBook, setEditBook] = useState('')
|
||||||
const [editChapter, setEditChapter] = useState('')
|
const [editChapter, setEditChapter] = useState('')
|
||||||
@@ -60,11 +63,17 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
|
|||||||
const d = res.data
|
const d = res.data
|
||||||
setJob(d)
|
setJob(d)
|
||||||
setEditedText(d.reviewed_text ?? d.ocr_text ?? '')
|
setEditedText(d.reviewed_text ?? d.ocr_text ?? '')
|
||||||
|
setEditDescribeText(d.describe_text ?? '')
|
||||||
|
setEditFreeformText(d.freeform_text ?? '')
|
||||||
setEditAuthor(d.author || '')
|
setEditAuthor(d.author || '')
|
||||||
setEditBook(d.book || '')
|
setEditBook(d.book || '')
|
||||||
setEditChapter(d.chapter || '')
|
setEditChapter(d.chapter || '')
|
||||||
setEditPage(d.page || '')
|
setEditPage(d.page || '')
|
||||||
setReviewerName(d.reviewer_name || '')
|
setReviewerName(d.reviewer_name || '')
|
||||||
|
// Default to first tab that has content
|
||||||
|
if (d.reviewed_text || d.ocr_text) setActiveTab('ocr')
|
||||||
|
else if (d.describe_text) setActiveTab('describe')
|
||||||
|
else if (d.freeform_text) setActiveTab('freeform')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
@@ -90,6 +99,8 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
|
|||||||
book: editBook,
|
book: editBook,
|
||||||
chapter: editChapter,
|
chapter: editChapter,
|
||||||
page: editPage,
|
page: editPage,
|
||||||
|
describe_text: editDescribeText || null,
|
||||||
|
freeform_text: editFreeformText || null,
|
||||||
})
|
})
|
||||||
setJob(res.data)
|
setJob(res.data)
|
||||||
setSaveResult({ success: true })
|
setSaveResult({ success: true })
|
||||||
@@ -199,17 +210,45 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="glass rounded-2xl p-4 flex flex-col h-full">
|
<div className="glass rounded-2xl p-4 flex flex-col h-full">
|
||||||
|
{/* Tabs — only show tabs that have content */}
|
||||||
|
{(() => {
|
||||||
|
const tabs = [
|
||||||
|
job.ocr_text || job.reviewed_text ? { id: 'ocr', label: 'OCR Text' } : null,
|
||||||
|
job.describe_text != null ? { id: 'describe', label: 'Description' } : null,
|
||||||
|
job.freeform_text != null ? { id: 'freeform', label: 'Freeform' } : null,
|
||||||
|
].filter(Boolean)
|
||||||
|
return tabs.length > 1 ? (
|
||||||
|
<div className="flex gap-1 mb-3 flex-shrink-0">
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setActiveTab(t.id)}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
activeTab === t.id
|
||||||
|
? 'bg-purple-600 text-white'
|
||||||
|
: 'bg-white/5 text-gray-400 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 mb-2 flex-shrink-0">
|
<p className="text-xs text-gray-400 mb-2 flex-shrink-0">
|
||||||
{isReviewed ? 'Reviewed Text' : 'OCR Text'}
|
{{ ocr: isReviewed ? 'Reviewed Text' : 'OCR Text', describe: 'Description', freeform: 'Freeform' }[activeTab]}
|
||||||
<span className="text-purple-400 ml-1">(editable)</span>
|
<span className="text-purple-400 ml-1">(editable)</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{activeTab === 'ocr' && (
|
||||||
|
<>
|
||||||
<textarea
|
<textarea
|
||||||
value={editedText}
|
value={editedText}
|
||||||
onChange={e => setEditedText(e.target.value)}
|
onChange={e => setEditedText(e.target.value)}
|
||||||
className="flex-1 w-full bg-transparent text-sm text-gray-200 font-mono resize-none focus:outline-none min-h-0"
|
className="flex-1 w-full bg-transparent text-sm text-gray-200 font-mono resize-none focus:outline-none min-h-0"
|
||||||
placeholder="Text content..."
|
placeholder="OCR text..."
|
||||||
/>
|
/>
|
||||||
{/* Original OCR text collapsed for reviewed jobs */}
|
|
||||||
{isReviewed && job.ocr_text && (
|
{isReviewed && job.ocr_text && (
|
||||||
<details className="flex-shrink-0 mt-2 border-t border-white/10 pt-2">
|
<details className="flex-shrink-0 mt-2 border-t border-white/10 pt-2">
|
||||||
<summary className="cursor-pointer text-xs text-gray-500 hover:text-gray-400 transition-colors">
|
<summary className="cursor-pointer text-xs text-gray-500 hover:text-gray-400 transition-colors">
|
||||||
@@ -220,6 +259,24 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
|
|||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{activeTab === 'describe' && (
|
||||||
|
<textarea
|
||||||
|
value={editDescribeText}
|
||||||
|
onChange={e => setEditDescribeText(e.target.value)}
|
||||||
|
className="flex-1 w-full bg-transparent text-sm text-gray-200 font-mono resize-none focus:outline-none min-h-0"
|
||||||
|
placeholder="Description text..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'freeform' && (
|
||||||
|
<textarea
|
||||||
|
value={editFreeformText}
|
||||||
|
onChange={e => setEditFreeformText(e.target.value)}
|
||||||
|
className="flex-1 w-full bg-transparent text-sm text-gray-200 font-mono resize-none focus:outline-none min-h-0"
|
||||||
|
placeholder="Freeform result..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user