kiyer Claude Fable 5 commited on
Commit
b902f4e
·
1 Parent(s): 28add9e

feat: upload and pipeline screens wired to real backend

Browse files

Ports UploadScreen and PipelineScreen from prototype/pipeline.jsx as
React components; replaces App.jsx placeholder with a state machine
(upload → pipeline → reader) connected to startParse/jobEvents/importSession.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

frontend/src/App.jsx CHANGED
@@ -1,4 +1,11 @@
 
 
 
 
 
 
1
  export default function App() {
 
2
  const rootStyle = {
3
  '--paper': '#f6f1e6',
4
  '--ink': '#2b2418',
@@ -6,11 +13,85 @@ export default function App() {
6
  '--body-size': '19px',
7
  };
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  return (
10
  <div className="ap-root" style={rootStyle}>
11
- <div className="ap-upload">
12
- <div className="ap-wordmark">astroparse</div>
13
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  </div>
15
  );
16
  }
 
1
+ import React from 'react';
2
+ import Upload from './screens/Upload';
3
+ import Pipeline from './screens/Pipeline';
4
+ import { startParse, jobEvents } from './lib/api';
5
+ import { hashPdf, importSession } from './lib/session';
6
+
7
  export default function App() {
8
+ // CSS custom properties — palette; must stay on ap-root for the entire tree.
9
  const rootStyle = {
10
  '--paper': '#f6f1e6',
11
  '--ink': '#2b2418',
 
13
  '--body-size': '19px',
14
  };
15
 
16
+ const [screen, setScreen] = React.useState('upload');
17
+ const [fileName, setFileName] = React.useState('');
18
+ const [stageEvents, setStageEvents] = React.useState([]);
19
+ const [result, setResult] = React.useState(null);
20
+ // pdfHash stored for session export (used by Task 17).
21
+ const [pdfHash, setPdfHash] = React.useState('');
22
+
23
+ async function onPdf(file) {
24
+ setFileName(file.name);
25
+ setScreen('pipeline');
26
+ setStageEvents([]);
27
+ setResult(null);
28
+ try {
29
+ setPdfHash(await hashPdf(await file.arrayBuffer()));
30
+ const jobId = await startParse(file);
31
+ for await (const ev of jobEvents(jobId)) {
32
+ if (ev.event === 'stage') {
33
+ setStageEvents((s) => [...s, ev.data]);
34
+ } else if (ev.event === 'result') {
35
+ setResult(ev.data);
36
+ setScreen('reader');
37
+ } else if (ev.event === 'error') {
38
+ setStageEvents((s) => {
39
+ const failedIndex = s.length ? Math.max(...s.map((x) => x.index)) : 0;
40
+ return [...s, { index: failedIndex, name: '', status: 'failed', log: ev.data.message }];
41
+ });
42
+ }
43
+ }
44
+ } catch (e) {
45
+ setStageEvents((s) => [
46
+ ...s,
47
+ { index: 0, name: '', status: 'failed', log: String(e.message || e) },
48
+ ]);
49
+ }
50
+ }
51
+
52
+ // onSessionText: import a saved session JSON and jump straight to reader.
53
+ // Task 16 adds proper toasts — for now, alert on failure.
54
+ // TODO(Task 16): replace alert with toast notification on import failure.
55
+ function onSessionText(text) {
56
+ try {
57
+ const s = importSession(text);
58
+ setFileName(s.fileName || '');
59
+ setResult({ paper: s.paper, litPapers: s.litPapers, litByPara: s.litByPara });
60
+ setPdfHash(s.pdfHash || '');
61
+ setScreen('reader');
62
+ } catch {
63
+ alert('could not read that session file');
64
+ }
65
+ }
66
+
67
+ // onOpenLast: fully implemented in Task 17 (re-opens last PDF from cache/hash).
68
+ // Rendered only when lastFileName is non-null — currently null so button is hidden.
69
+ function onOpenLast() {
70
+ // Task 17 completes this.
71
+ }
72
+
73
  return (
74
  <div className="ap-root" style={rootStyle}>
75
+ {screen === 'upload' && (
76
+ <Upload
77
+ onPdf={onPdf}
78
+ lastFileName={null}
79
+ onOpenLast={onOpenLast}
80
+ onSession={onSessionText}
81
+ />
82
+ )}
83
+ {screen === 'pipeline' && (
84
+ <Pipeline
85
+ fileName={fileName}
86
+ events={stageEvents}
87
+ onSkip={() => result && setScreen('reader')}
88
+ onReset={() => setScreen('upload')}
89
+ />
90
+ )}
91
+ {screen === 'reader' && (
92
+ // Task 15 replaces this placeholder with the full Reader screen.
93
+ <pre style={{ padding: 40 }}>{result?.paper?.title}</pre>
94
+ )}
95
  </div>
96
  );
97
  }
frontend/src/screens/Pipeline.jsx ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ // Pipeline stage metadata — ported verbatim from prototype/data.js pipelineStages.
4
+ // Stage names and tools MUST match the backend stage names exactly.
5
+ const PIPELINE_STAGES = [
6
+ { id: 'parse', num: 'i', name: 'Reading manuscript', tool: 'pymupdf4llm' },
7
+ { id: 'clean', num: 'ii', name: 'Cleaning the leaves', tool: 'header/footer removal' },
8
+ { id: 'segment', num: 'iii', name: 'Ruling the columns', tool: 'paragraph segmentation' },
9
+ { id: 'embed', num: 'iv', name: 'Committing to memory', tool: 'SPECTER2 embeddings' },
10
+ { id: 'index', num: 'v', name: 'Indexing the lexicon', tool: 'BM25' },
11
+ { id: 'retrieve', num: 'vi', name: 'Consulting the library', tool: 'Pathfinder' },
12
+ ];
13
+
14
+ // Pipeline screen — faithful port of PipelineScreen from prototype/pipeline.jsx.
15
+ // Differences from prototype:
16
+ // 1. NO scripted setTimeout animation — stages move only when events arrive via props.
17
+ // 2. Props: fileName, events (array of stage events received so far), onSkip, onReset.
18
+ // 3. Stage status derived from the LAST event for each stage index (default "pending").
19
+ // 4. Log lines collected from every non-null log field per stage in arrival order.
20
+ // 5. status:"failed" shows "✗" mark in rubric colour and replaces skip with reset footer.
21
+ export default function Pipeline({ fileName, events, onSkip, onReset }) {
22
+ // Build per-stage status and log arrays from event stream.
23
+ // Key by stage index; last event wins for status; all non-null logs accumulated.
24
+ const stageData = React.useMemo(() => {
25
+ const byIndex = {};
26
+ for (const ev of events) {
27
+ const idx = ev.index;
28
+ if (!byIndex[idx]) byIndex[idx] = { status: 'pending', logs: [] };
29
+ byIndex[idx].status = ev.status;
30
+ if (ev.log != null) byIndex[idx].logs.push(ev.log);
31
+ }
32
+ return byIndex;
33
+ }, [events]);
34
+
35
+ // Determine if any stage has failed.
36
+ const hasFailed = Object.values(stageData).some((s) => s.status === 'failed');
37
+
38
+ // The highest stage index that has received events determines "current" position.
39
+ const activeIndices = Object.keys(stageData).map(Number);
40
+ const maxActive = activeIndices.length > 0 ? Math.max(...activeIndices) : -1;
41
+
42
+ // Header status text.
43
+ const allDone =
44
+ activeIndices.length === PIPELINE_STAGES.length &&
45
+ PIPELINE_STAGES.every((_, i) => {
46
+ const d = stageData[i];
47
+ return d && d.status === 'done';
48
+ });
49
+ const headerStatus = hasFailed ? 'error' : allDone ? 'ready' : 'in preparation';
50
+
51
+ return (
52
+ <div className="ap-screen ap-pipeline" data-screen-label="Pipeline">
53
+ <div className="ap-wordmark">astroparse</div>
54
+ <div className="ap-pipe-card">
55
+ <div className="ap-pipe-head">
56
+ <span className="ap-pipe-file">{fileName}</span>
57
+ <span className="ap-pipe-status">{headerStatus}</span>
58
+ </div>
59
+ <ol className="ap-stages">
60
+ {PIPELINE_STAGES.map((s, i) => {
61
+ const data = stageData[i];
62
+ const status = data ? data.status : 'pending';
63
+ const logs = data ? data.logs : [];
64
+
65
+ // Derive display state:
66
+ // done — status === 'done'
67
+ // active — status === 'active' (or the maxActive index with any status)
68
+ // failed — status === 'failed'
69
+ // pending — not yet reached
70
+ let displayState;
71
+ if (status === 'done') {
72
+ displayState = 'done';
73
+ } else if (status === 'failed') {
74
+ displayState = 'failed';
75
+ } else if (status === 'active' || (data && i === maxActive && status !== 'done')) {
76
+ displayState = 'active';
77
+ } else if (i < maxActive) {
78
+ // Received events but not explicitly done — treat as active
79
+ displayState = 'active';
80
+ } else {
81
+ displayState = 'pending';
82
+ }
83
+
84
+ // Mark character: ✓ done, ✗ failed, … active, blank pending
85
+ let mark = '';
86
+ if (displayState === 'done') mark = '✓';
87
+ else if (displayState === 'failed') mark = '✗';
88
+ else if (displayState === 'active') mark = '…';
89
+
90
+ const showLogs = displayState !== 'pending' && logs.length > 0;
91
+
92
+ return (
93
+ <li key={s.id} className={'ap-stage is-' + displayState}>
94
+ <div className="ap-stage-row">
95
+ <span className="ap-stage-num">{s.num}.</span>
96
+ <span className="ap-stage-name">{s.name}</span>
97
+ <span className="ap-stage-tool">{s.tool}</span>
98
+ <span className="ap-stage-mark">{mark}</span>
99
+ </div>
100
+ {showLogs && (
101
+ <div className="ap-stage-logs">
102
+ {logs.map((l, j) => (
103
+ <div key={j} className="ap-log-line">{l}</div>
104
+ ))}
105
+ </div>
106
+ )}
107
+ </li>
108
+ );
109
+ })}
110
+ </ol>
111
+ <div className="ap-pipe-foot">
112
+ {hasFailed ? (
113
+ <button className="ap-textbtn" onClick={onReset}>
114
+ return to the shelf &rarr;
115
+ </button>
116
+ ) : (
117
+ <button className="ap-textbtn" onClick={onSkip}>
118
+ skip to the manuscript &rarr;
119
+ </button>
120
+ )}
121
+ </div>
122
+ </div>
123
+ </div>
124
+ );
125
+ }
frontend/src/screens/Upload.jsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ // Upload screen — faithful port of UploadScreen from prototype/pipeline.jsx.
4
+ // Differences from prototype:
5
+ // 1. onFile(f.name) → onPdf(file): passes the full File object (not just the name).
6
+ // 2. The hard-coded "open Mowla & Iyer (2024)" button is replaced by a dynamic
7
+ // "open ‹lastFileName›" button that renders ONLY when lastFileName is non-null.
8
+ // 3. The bottom caption about prototype limitations is DROPPED (not in production build).
9
+ // 4. Session import reads file text and calls onSession(text) instead of onImportSession.
10
+ export default function Upload({ onPdf, lastFileName, onOpenLast, onSession }) {
11
+ const [dragging, setDragging] = React.useState(false);
12
+ const fileRef = React.useRef(null);
13
+ const sessionRef = React.useRef(null);
14
+
15
+ const handleDrop = (e) => {
16
+ e.preventDefault();
17
+ setDragging(false);
18
+ const f = e.dataTransfer.files && e.dataTransfer.files[0];
19
+ if (f) onPdf(f);
20
+ };
21
+
22
+ return (
23
+ <div className="ap-screen ap-upload" data-screen-label="Upload">
24
+ <div className="ap-wordmark">astroparse</div>
25
+ <div
26
+ className={'ap-folio-drop' + (dragging ? ' is-dragging' : '')}
27
+ onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
28
+ onDragLeave={() => setDragging(false)}
29
+ onDrop={handleDrop}
30
+ onClick={() => fileRef.current && fileRef.current.click()}
31
+ >
32
+ <div className="ap-folio-pilcrow">&para;</div>
33
+ <div className="ap-folio-title">Place a manuscript here</div>
34
+ <div className="ap-folio-sub">
35
+ drop an astronomy PDF, or click to browse
36
+ </div>
37
+ <div className="ap-folio-meta">
38
+ parsed with pymupdf4llm &middot; annotated against the literature via Pathfinder
39
+ </div>
40
+ <input
41
+ ref={fileRef}
42
+ type="file"
43
+ accept="application/pdf"
44
+ style={{ display: 'none' }}
45
+ onChange={(e) => { const f = e.target.files[0]; if (f) onPdf(f); }}
46
+ />
47
+ </div>
48
+
49
+ <div className="ap-upload-alt">
50
+ {lastFileName != null && (
51
+ <>
52
+ <button className="ap-textbtn" onClick={onOpenLast}>
53
+ open {lastFileName}
54
+ </button>
55
+ <span className="ap-alt-sep">&middot;</span>
56
+ </>
57
+ )}
58
+ <button
59
+ className="ap-textbtn"
60
+ onClick={() => sessionRef.current && sessionRef.current.click()}
61
+ >
62
+ reload a saved session (.json)
63
+ </button>
64
+ <input
65
+ ref={sessionRef}
66
+ type="file"
67
+ accept=".json,application/json"
68
+ style={{ display: 'none' }}
69
+ onChange={(e) => {
70
+ const f = e.target.files[0];
71
+ if (!f) return;
72
+ const r = new FileReader();
73
+ r.onload = () => onSession(r.result);
74
+ r.readAsText(f);
75
+ }}
76
+ />
77
+ </div>
78
+ </div>
79
+ );
80
+ }
frontend/src/styles/astroparse.css CHANGED
@@ -80,8 +80,11 @@
80
  .ap-stage-name { font-size: 17px; font-weight: 500; letter-spacing: 0.01em; }
81
  .ap-stage-tool { font-family: var(--mono); font-size: 10.5px; color: color-mix(in oklab, var(--ink) 50%, var(--paper)); margin-left: auto; }
82
  .ap-stage-mark { font-family: var(--mono); font-size: 12px; color: var(--rubric); width: 16px; text-align: center; flex: none; }
 
83
  .ap-stage.is-pending { opacity: 0.38; }
 
84
  .ap-stage.is-active .ap-stage-name { color: var(--rubric); }
 
85
  .ap-stage-logs { padding: 6px 0 2px 36px; display: flex; flex-direction: column; gap: 3px; }
86
  .ap-log-line { font-family: var(--mono); font-size: 11px; color: color-mix(in oklab, var(--ink) 58%, var(--paper)); animation: ap-fade .3s ease; }
87
  .ap-pipe-foot { text-align: right; padding-top: 16px; }
 
80
  .ap-stage-name { font-size: 17px; font-weight: 500; letter-spacing: 0.01em; }
81
  .ap-stage-tool { font-family: var(--mono); font-size: 10.5px; color: color-mix(in oklab, var(--ink) 50%, var(--paper)); margin-left: auto; }
82
  .ap-stage-mark { font-family: var(--mono); font-size: 12px; color: var(--rubric); width: 16px; text-align: center; flex: none; }
83
+ .ap-screen { display: contents; }
84
  .ap-stage.is-pending { opacity: 0.38; }
85
+ .ap-stage.is-done { opacity: 1; }
86
  .ap-stage.is-active .ap-stage-name { color: var(--rubric); }
87
+ .ap-stage.is-failed .ap-stage-name { color: var(--rubric); }
88
  .ap-stage-logs { padding: 6px 0 2px 36px; display: flex; flex-direction: column; gap: 3px; }
89
  .ap-log-line { font-family: var(--mono); font-size: 11px; color: color-mix(in oklab, var(--ink) 58%, var(--paper)); animation: ap-fade .3s ease; }
90
  .ap-pipe-foot { text-align: right; padding-top: 16px; }