diff --git a/client/src/App.js b/client/src/App.js index a70cc97..2b2c40b 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -5,6 +5,8 @@ import io from 'socket.io-client'; import DocumentUpload from './components/DocumentUpload'; import TranslationProgress from './components/TranslationProgress'; import DocumentPreview from './components/DocumentPreview'; +import SideBySideViewer from './components/SideBySideViewer'; +import UsagePanel from './components/UsagePanel'; // API origin: пусто => тот же origin, что и страница (прод, через Traefik). // В dev задаётся через client/.env.development (REACT_APP_API_URL=http://localhost:3001). @@ -20,6 +22,8 @@ function App() { }); const [socket, setSocket] = React.useState(null); + const [translationDoc, setTranslationDoc] = React.useState(null); + const [resultToken, setResultToken] = React.useState(null); // Стабильный идентификатор сессии: события прогресса приходят только в нашу комнату const sessionId = useMemo( @@ -56,6 +60,15 @@ function App() { progress: 100, documentUrl: data.downloadUrl })); + + setResultToken(data.resultToken || null); + + if (data.resultToken) { + fetch(`${API_URL}/api/result/${data.resultToken}`) + .then(r => r.ok ? r.json() : null) + .then(doc => { if (doc) setTranslationDoc(doc); }) + .catch(() => {}); + } }); socket.on('translation:error', (data) => { @@ -75,6 +88,8 @@ function App() { const handleFileUpload = async (file, targetLang) => { try { + setTranslationDoc(null); + setResultToken(null); setTranslationState({ status: 'uploading', progress: 0, @@ -122,26 +137,24 @@ function App() { } }; - const handleDownload = async () => { + const handleDownload = () => { if (!translationState.documentUrl) return; - - try { - const response = await fetch(translationState.documentUrl); - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `translated_${translationState.originalName}`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - } catch (error) { - console.error('Download error:', error); - } + // Direct anchor to the server's attachment URL (server sends + // Content-Disposition: attachment). More reliable than a blob+download on + // iOS Safari, which ignores the download attribute for blob: URLs and opens + // them inline instead. + const a = document.createElement('a'); + a.href = `${API_URL}${translationState.documentUrl}`; + a.download = ''; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); }; const handleReset = () => { + setTranslationDoc(null); + setResultToken(null); setTranslationState({ status: 'idle', progress: 0, @@ -183,6 +196,10 @@ function App() { onDownload={handleDownload} /> )} + + + + {translationDoc && } diff --git a/client/src/App.test.js b/client/src/App.test.js index 1f03afe..1cf9dec 100644 --- a/client/src/App.test.js +++ b/client/src/App.test.js @@ -1,8 +1,16 @@ import { render, screen } from '@testing-library/react'; + +// socket.io-client is mocked so the test never opens a real connection. +jest.mock('socket.io-client', () => ({ + __esModule: true, + default: () => ({ on: jest.fn(), off: jest.fn(), close: jest.fn() }), +})); + import App from './App'; -test('renders learn react link', () => { +test('App mounts without crashing', () => { render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); + expect( + screen.getByText(/Переводчик документов с иврита/i) + ).toBeInTheDocument(); }); diff --git a/client/src/components/SideBySideViewer.js b/client/src/components/SideBySideViewer.js new file mode 100644 index 0000000..8c4f2cc --- /dev/null +++ b/client/src/components/SideBySideViewer.js @@ -0,0 +1,168 @@ +import React, { useState, useCallback } from 'react'; +import { Box, Grid, Paper, Typography } from '@mui/material'; + +// Build a Set-membership key for a token span. +const tokenKey = (side, blockId, sentenceId, tokenIdx) => + `${side}:${blockId}:${sentenceId}:${tokenIdx}`; + +const HL_COLOR = '#fff59d'; // light yellow highlight + +/** + * SideBySideViewer + * Renders a TranslationDocument as two synchronized panes: + * left = source (dir="rtl") + * right = target (dir="ltr") + * + * Security: ALL token/sentence text is rendered as React text children. + * dangerouslySetInnerHTML is never used, so any HTML inside a token shows + * up as literal text and cannot be injected into the DOM. + * + * Highlighting: hover/click a token to highlight aligned token(s) on the + * other side (word-level when `align` is present, otherwise whole-sentence + * fallback). Clicking the sentence wrapper highlights the paired sentence + * on both sides. + */ +function SideBySideViewer({ doc }) { + // Set of highlighted token keys (see tokenKey()). + const [highlighted, setHighlighted] = useState(() => new Set()); + + // Highlight every token of a sentence on both sides. + const highlightWholeSentence = useCallback((blockId, sentence) => { + const next = new Set(); + (sentence.srcTokens || []).forEach((_, i) => + next.add(tokenKey('src', blockId, sentence.id, i)) + ); + (sentence.tgtTokens || []).forEach((_, i) => + next.add(tokenKey('tgt', blockId, sentence.id, i)) + ); + setHighlighted(next); + }, []); + + // Highlight from a single hovered/clicked token. + const highlightFromToken = useCallback( + (side, blockId, sentence, tokenIdx) => { + const next = new Set(); + // The token itself is always highlighted. + next.add(tokenKey(side, blockId, sentence.id, tokenIdx)); + + const align = sentence.align; + const otherSide = side === 'src' ? 'tgt' : 'src'; + + if (!align || align.length === 0) { + // Word-level alignment unavailable: fall back to whole paired sentence. + highlightWholeSentence(blockId, sentence); + // Also re-include the hovered token (already in set above) so the + // hovered side keeps its own highlight. + return; + } + + const fromKey = side === 'src' ? 'src' : 'tgt'; + const toKey = side === 'src' ? 'tgt' : 'src'; + align.forEach((group) => { + const fromIdxs = group[fromKey] || []; + if (fromIdxs.includes(tokenIdx)) { + (group[toKey] || []).forEach((j) => + next.add(tokenKey(otherSide, blockId, sentence.id, j)) + ); + } + }); + + setHighlighted(next); + }, + [highlightWholeSentence] + ); + + const clearHighlight = useCallback(() => { + setHighlighted((prev) => (prev.size === 0 ? prev : new Set())); + }, []); + + const renderTokens = (side, block, sentence) => { + const tokens = side === 'src' ? sentence.srcTokens : sentence.tgtTokens; + return (tokens || []).map((tok, i) => { + const key = tokenKey(side, block.id, sentence.id, i); + const isHl = highlighted.has(key); + return ( + + {i > 0 ? ' ' : null} + highlightFromToken(side, block.id, sentence, i)} + onMouseLeave={clearHighlight} + onClick={(e) => { + // Token click acts like hover-highlight; stop it from also + // triggering the sentence-level click handler. + e.stopPropagation(); + highlightFromToken(side, block.id, sentence, i); + }} + > + {/* Rendered as a text child => XSS-safe, never HTML. */} + {tok} + + + ); + }); + }; + + const renderPane = (side) => { + const dir = side === 'src' ? 'rtl' : 'ltr'; + return ( + + {(doc?.blocks || []).map((block) => ( + + {(block.sentences || []).map((sentence) => ( + highlightWholeSentence(block.id, sentence)} + style={{ + // unicodeBidi: plaintext keeps mixed Hebrew + Latin/numbers + // rendering in correct visual order. + unicodeBidi: 'plaintext', + }} + > + {renderTokens(side, block, sentence)}{' '} + + ))} + + ))} + + ); + }; + + return ( + + + + + {doc?.sourceLang ? doc.sourceLang.toUpperCase() : 'Source'} + + {renderPane('src')} + + + + {doc?.targetLang ? doc.targetLang.toUpperCase() : 'Target'} + + {renderPane('tgt')} + + + + ); +} + +export default SideBySideViewer; diff --git a/client/src/components/UsagePanel.js b/client/src/components/UsagePanel.js new file mode 100644 index 0000000..dcf969d --- /dev/null +++ b/client/src/components/UsagePanel.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { Paper, Typography, Box } from '@mui/material'; + +// Re-derived here so the component is self-contained (same value as App.js). +const API_URL = process.env.REACT_APP_API_URL || ''; + +/** + * UsagePanel — admin-only cost/usage readout for a finished translation job. + * + * Security: renders NOTHING unless an admin key is present (localStorage + * 'adminKey' or ?adminKey= query param). The public /api/result payload has + * usage stripped, so cost data is fetched from the admin-gated endpoint and + * must never be shown to public users. + */ +function UsagePanel({ resultToken }) { + const params = new URLSearchParams(window.location.search); + const adminKey = params.get('adminKey') || localStorage.getItem('adminKey'); + + const [job, setJob] = React.useState(null); + + React.useEffect(() => { + if (!adminKey || !resultToken) { + setJob(null); + return; + } + let cancelled = false; + fetch(`${API_URL}/api/admin/usage`, { headers: { 'x-admin-key': adminKey } }) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (cancelled || !data || !Array.isArray(data.jobs)) return; + const match = data.jobs.find((j) => j.token === resultToken) || null; + setJob(match); + }) + .catch(() => { + // Errors → render nothing for this fetch. + if (!cancelled) setJob(null); + }); + return () => { + cancelled = true; + }; + }, [adminKey, resultToken]); + + // Gate: no admin key or no token → render nothing (public users). + if (!adminKey || !resultToken) return null; + + const totals = job && job.totals ? job.totals : null; + const byModel = job && job.byModel ? job.byModel : {}; + + return ( + + + Обработка (admin) + + {!totals ? ( + + ) : ( + + + Токены: in {totals.in} · out {totals.out} · total {totals.total} + + + Стоимость: ${Number(totals.costUsd || 0).toFixed(4)} + + + {Object.entries(byModel).map(([model, m]) => ( + + {model} · {m.calls} calls · ${Number(m.costUsd || 0).toFixed(4)} + + ))} + + + )} + + ); +} + +export default UsagePanel; diff --git a/client/src/components/__tests__/SideBySideViewer.test.js b/client/src/components/__tests__/SideBySideViewer.test.js new file mode 100644 index 0000000..f6a5c2a --- /dev/null +++ b/client/src/components/__tests__/SideBySideViewer.test.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import SideBySideViewer from '../SideBySideViewer'; + +const doc = { + schemaVersion:1, sourceLang:'he', targetLang:'en', + blocks:[{ id:'b0', type:'paragraph', sentences:[ + { id:'b0s0', source:'שלום עולם', target:'Hello world', + srcTokens:['שלום','עולם'], tgtTokens:['Hello','world'], + align:[{src:[0],tgt:[0]},{src:[1],tgt:[1]}] } + ]}] +}; + +test('renders source and target tokens', () => { + render(); + expect(screen.getByText('שלום')).toBeInTheDocument(); + expect(screen.getByText('Hello')).toBeInTheDocument(); +}); + +test('XSS: a token with HTML is rendered as literal text, not injected', () => { + const evil = { schemaVersion:1, sourceLang:'he', targetLang:'en', blocks:[{ id:'b0', type:'paragraph', sentences:[ + { id:'b0s0', source:'x', target:'', + srcTokens:['x'], tgtTokens:[''], align:[] } + ]}]}; + const { container } = render(); + expect(container.querySelector('img')).toBeNull(); // no element injected + expect(screen.getByText(' { + const { container } = render(); + const srcTok0 = container.querySelector('[data-side="src"][data-block="b0"][data-sentence="b0s0"][data-token="0"]'); + fireEvent.mouseEnter(srcTok0); + const tgtTok0 = container.querySelector('[data-side="tgt"][data-block="b0"][data-sentence="b0s0"][data-token="0"]'); + // highlighted token gets a non-empty backgroundColor (or hl class) + expect(tgtTok0.className.includes('hl') || tgtTok0.style.backgroundColor).toBeTruthy(); +}); diff --git a/client/src/components/__tests__/UsagePanel.test.js b/client/src/components/__tests__/UsagePanel.test.js new file mode 100644 index 0000000..464ff4b --- /dev/null +++ b/client/src/components/__tests__/UsagePanel.test.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import UsagePanel from '../UsagePanel'; + +afterEach(() => { localStorage.clear(); jest.restoreAllMocks(); }); + +test('renders nothing without admin key', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); +}); + +test('shows usage when admin key present', async () => { + localStorage.setItem('adminKey', 'secret'); + global.fetch = jest.fn(async () => ({ ok:true, json: async () => ({ + byOwner:{}, jobs:[{ token:'tok1', owner:'anon', jobId:'1', + totals:{calls:2,in:150,out:50,total:200,costUsd:0.0015}, + byModel:{ 'gemini/gemini-2.0-flash': {calls:2,in:150,out:50,total:200,costUsd:0.0015} } }] }) })); + render(); + await waitFor(() => expect(screen.getByText(/200/)).toBeInTheDocument()); + expect(screen.getByText(/gemini/i)).toBeInTheDocument(); +}); diff --git a/docs/plans/2026-06-03-hebrew-translator-phase1-design.md b/docs/plans/2026-06-03-hebrew-translator-phase1-design.md new file mode 100644 index 0000000..28055a9 --- /dev/null +++ b/docs/plans/2026-06-03-hebrew-translator-phase1-design.md @@ -0,0 +1,88 @@ +# Hebrew Translator — Phase 1 Design: segment-aligned model + side-by-side viewer + +**Date:** 2026-06-03 +**Status:** Approved (design-lock) +**Builds on:** `2026-06-01-hebrew-translator-production-design.md` (Phase 0 live). + +## Scope (locked) +Phase 1 delivers the **alignment foundation + a synchronized side-by-side viewer**, plus per-document budget tracking and the iOS download fix. Pixel-faithful file output (PDF overlay, DOCX in-place) is the **next** phase. + +| Decision | Value | +|----------|-------| +| Formats (fidelity files) | both PDF+DOCX — but **next phase**; Phase 1 = viewer | +| Highlight granularity | all three: block → sentence → word | +| Word alignment source | LLM, **precomputed in the batch pass** (not lazy) — required by <1s click latency | +| Viewer rendering | HTML panes (source RTL / target LTR), not pixel-faithful | +| Budget tracking | per-document tokens + cost + actual model, admin-gated | + +## 1. Foundation — `TranslationDocument` (canonical intermediate) +Pipeline becomes extract → structure → batch-translate(+align) → `TranslationDocument` JSON: +```jsonc +{ schemaVersion: 1, sourceLang, targetLang, + usage: { byModel: { "gemini-2.0-flash": {calls,in,out,total,costUsd} }, totals: {...} }, + blocks: [ { id:"b1", type:"paragraph", sentences:[ + { id:"b1s1", source:"שלום עולם", target:"Hello world", + srcTokens:["שלום","עולם"], tgtTokens:["Hello","world"], + align:[ {src:[0],tgt:[0]}, {src:[1],tgt:[1]} ] } ] } ] } +``` + +**Pipeline:** +1. Extract (pdf-parse / mammoth) → blocks. +2. Split blocks → sentences via `Intl.Segmenter` (sentence granularity); NFC-normalize Hebrew first. +3. Tokenize source per sentence via `Intl.Segmenter` (word granularity) → `srcTokens` (server is the source of truth for tokens). +4. **Batch translate + align**: group sentences into chunks (≈15-20 segments / ~1.5k tokens, respecting block boundaries). One LLM call per chunk returns `[{id, target, align}, …]`. A few calls per document, not per-sentence. +5. Assemble `TranslationDocument`; store by random token (TTL) in the result store. + +**Why precompute align:** <1s click latency forbids per-click LLM round-trips (first click would miss cache). Block/sentence highlight is free (shared ids, 1:1); word-align rides along in the same batch calls. + +**Caching:** per chunk/sentence, key = `model + promptVersion + schemaVersion + langs + hash(text)`. Distinct namespace from Phase-0 string cache (value shape changed → collision = crash). + +## 2. Viewer (frontend — new result screen) +- API: `GET /api/result/:token` → `TranslationDocument` (minus admin-only `usage`). `Cache-Control: private, no-store`. +- Two scrollable panes: **left = source** (`dir="rtl"`, `unicode-bidi: plaintext` per segment), **right = target** (LTR). Each word is a span carrying `data-block / data-sentence / data-token`. +- Interaction (bidirectional, from either pane): + - hover/click a **word** → highlight aligned word(s) in the other pane via `align`; + - click a **sentence** → highlight the paired sentence both sides; + - **block** → highlight the block. +- **Rendered only via React text nodes** — never `dangerouslySetInnerHTML` for document content (XSS). +- Client renders the server's `srcTokens`/`tgtTokens` **as-is** (never re-tokenizes) so align indices line up. +- Keeps a **Download** button. + +## 3. Budget tracking (admin) +- `LiteLLMProvider` returns `usage {model, promptTokens, completionTokens, totalTokens, costUsd}` from the response (`usage` + `x-litellm-response-cost` / `_hidden_params`). Capture the **actual** model (fallback-aware), not the alias. +- Worker aggregates across batch calls → `usage.byModel` + totals, stored with the result. +- Admin: per-document panel (`model · in/out tokens · $cost`) + `GET /api/admin/usage` (recent jobs). Gated by `ADMIN_KEY` env (minimal gate until real auth exists). Usage stripped from the public result payload. +- **Forward-compatible for multi-user:** each usage record carries an `owner` field (`anon`/null now; a real userId once auth exists) plus `jobId` and timestamp, stored append-only so a **future admin panel can aggregate consumption per user / per period** when accounts arrive. The admin usage endpoint is built to group by `owner` (today: one bucket). No user system in Phase 1 — only the data shape + aggregation are future-proofed (YAGNI: no auth UI now). + +## 4. iOS download fix +Replace blob + `a.download` with a direct anchor to the attachment URL (server already sends `Content-Disposition: attachment`) — reliable on iOS Safari. + +## 5. Error handling (graceful degradation) +- Malformed batch JSON → per-item parse; bad item degrades to sentence-level (no crash), job continues. +- align out-of-range / not covering tokens → drop word-level for that sentence; sentence/block highlight still works. +- Sentence segmentation failure → whole block as one sentence. +- Keep Phase-0 guardrails (refusal/length-ratio). + +## 6. Testing +Unit: `Intl.Segmenter` sentence/word wrappers, align validator (out-of-range→drop), `TranslationDocument` builder, usage aggregator. Adapter: structured-output mock (incl. malformed item). Integration: fixture → valid `TranslationDocument`. Frontend: clicking a word highlights the paired token; XSS fixture (text with `