Skip to content

Commit 6926596

Browse files
committed
kk
1 parent a74cf2a commit 6926596

8 files changed

Lines changed: 167 additions & 74 deletions

File tree

frontend/src/views/PaperEditor.tsx

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from "react";
2-
import { AlertCircle, CheckCircle2, FileWarning, Hammer, Lock, Save, ShieldCheck } from "lucide-react";
2+
import { AlertCircle, CheckCircle2, ExternalLink, FileText, FileWarning, Hammer, Lock, Save, ShieldCheck } from "lucide-react";
33
import { useQueryClient } from "@tanstack/react-query";
44
import { ApiError, api } from "../api/client";
55
import type { CompileResult, PaperStatus } from "../api/client";
@@ -20,6 +20,13 @@ export function PaperEditor({ projectId, isRunning }: Props) {
2020
const [compiling, setCompiling] = useState(false);
2121
const [strictCompiling, setStrictCompiling] = useState(false);
2222
const [compileResult, setCompileResult] = useState<CompileResult | null>(null);
23+
// "source" = LaTeX editor; "pdf" = inline-rendered paper.pdf preview.
24+
// The two views share the compile + save buttons in the footer so the
25+
// user can flip between them without losing context.
26+
const [view, setView] = useState<"source" | "pdf">("source");
27+
// Bumped after every successful compile to bust the iframe cache so the
28+
// PDF view shows the freshly built file instead of a stale cached page.
29+
const [pdfVersion, setPdfVersion] = useState(0);
2330
const queryClient = useQueryClient();
2431

2532
useEffect(() => {
@@ -68,6 +75,9 @@ export function PaperEditor({ projectId, isRunning }: Props) {
6875
const result = await api.compilePaper(projectId, { strict });
6976
setCompileResult(result);
7077
queryClient.invalidateQueries({ queryKey: queryKeys.paperStatus(projectId) });
78+
// Bump the PDF version so any open preview iframe reloads against the
79+
// newly-built paper.pdf instead of showing a stale cached version.
80+
if (result.passed) setPdfVersion((v) => v + 1);
7181
} catch (e) {
7282
setCompileResult({
7383
passed: false,
@@ -82,6 +92,8 @@ export function PaperEditor({ projectId, isRunning }: Props) {
8292
}
8393
}
8494

95+
const pdfSrc = `/api/projects/${encodeURIComponent(projectId)}/paper/pdf?v=${pdfVersion}`;
96+
8597
return (
8698
<div className="paperPane">
8799
<header className="paperHeader">
@@ -91,6 +103,22 @@ export function PaperEditor({ projectId, isRunning }: Props) {
91103
{data.pi_locks.length} locks
92104
{dirty && <span className="dirtyBadge"> · unsaved</span>}
93105
</span>
106+
<div className="paperViewToggle">
107+
<button
108+
className={view === "source" ? "active" : ""}
109+
onClick={() => setView("source")}
110+
title="Edit paper.tex source"
111+
>
112+
<FileText size={13} /> Source
113+
</button>
114+
<button
115+
className={view === "pdf" ? "active" : ""}
116+
onClick={() => setView("pdf")}
117+
title="View compiled paper.pdf inline"
118+
>
119+
<Hammer size={13} /> PDF
120+
</button>
121+
</div>
94122
</header>
95123

96124
{data.pi_locks.length > 0 && (
@@ -106,18 +134,42 @@ export function PaperEditor({ projectId, isRunning }: Props) {
106134

107135
{status && <ConstructionStatusPanel status={status} />}
108136

109-
<textarea
110-
className="paperEditor"
111-
value={draft}
112-
onChange={(event) => setDraft(event.target.value)}
113-
spellCheck={false}
114-
disabled={isRunning}
115-
placeholder={
116-
isRunning
117-
? "Iteration running; paper.tex is locked until it finishes."
118-
: "Edit paper.tex. Use % PI-LOCK BEGIN id=\"...\" / END to protect regions."
119-
}
120-
/>
137+
{view === "source" ? (
138+
<textarea
139+
className="paperEditor"
140+
value={draft}
141+
onChange={(event) => setDraft(event.target.value)}
142+
spellCheck={false}
143+
disabled={isRunning}
144+
placeholder={
145+
isRunning
146+
? "Iteration running; paper.tex is locked until it finishes."
147+
: "Edit paper.tex. Use % PI-LOCK BEGIN id=\"...\" / END to protect regions."
148+
}
149+
/>
150+
) : (
151+
<div className="paperPdfWrapper">
152+
<object
153+
key={pdfVersion}
154+
data={pdfSrc}
155+
type="application/pdf"
156+
className="paperPdfFrame"
157+
>
158+
<div className="advisorEmpty">
159+
<p>
160+
Your browser cannot embed PDFs.{" "}
161+
<a href={pdfSrc} target="_blank" rel="noopener noreferrer">
162+
Open paper.pdf <ExternalLink size={12} />
163+
</a>{" "}
164+
in a new tab instead.
165+
</p>
166+
<p className="paperFooter">
167+
If the file 404s, click "Compile draft" to build it.
168+
</p>
169+
</div>
170+
</object>
171+
</div>
172+
)}
121173

122174
{saveError && <div className="dialogError">{saveError}</div>}
123175
{compileResult && (
@@ -151,7 +203,7 @@ export function PaperEditor({ projectId, isRunning }: Props) {
151203
</button>
152204
<button
153205
className="primaryButton"
154-
disabled={!dirty || saving || isRunning}
206+
disabled={!dirty || saving || isRunning || view !== "source"}
155207
onClick={handleSave}
156208
>
157209
<Save size={14} /> {saving ? "Saving..." : "Save"}

frontend/src/views/ProjectDetailView.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,6 @@ export function ProjectDetailView({ projectId, onBack }: Props) {
8585
<button className={tab === "paper" ? "active" : ""} onClick={() => setTab("paper")}>
8686
<FileText size={14} /> Paper
8787
</button>
88-
<button className={tab === "pdf" ? "active" : ""} onClick={() => setTab("pdf")}>
89-
<Files size={14} /> PDF
90-
</button>
9188
<button className={tab === "slides" ? "active" : ""} onClick={() => setTab("slides")}>
9289
<Monitor size={14} /> Slides
9390
</button>
@@ -149,9 +146,6 @@ export function ProjectDetailView({ projectId, onBack }: Props) {
149146
{tab === "paper" && (
150147
<PaperEditor projectId={projectId} isRunning={project.is_running} />
151148
)}
152-
{tab === "pdf" && (
153-
<PdfView projectId={projectId} iterationCount={project.iteration_count} />
154-
)}
155149
{tab === "slides" && (
156150
<SlidesEditor projectId={projectId} isRunning={project.is_running} />
157151
)}

paper.tex

Lines changed: 5 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -219,77 +219,35 @@ \section{Introduction}
219219

220220
\section{Setup and Assumptions}
221221

222-
\paragraph{1.} Smooth fields
223-
224-
\paragraph{2.} Compact support
222+
\halfseedTODO[derive]{id="bootstrap-setup", what="Define notation and assumptions for this project.", needs="Writer prose grounded in current research material"}
225223

226224

227225
\section{Analytical Results}
228226

229-
\subsection{Result 1}
230-
The variance scales as 1/D.
231-
232-
\paragraph{Evidence.} computation
233-
234-
\paragraph{Confidence.} 0.70
235-
236-
237-
238-
\begin{equation*}
239-
\halfseedeq{\sigma^2 \sim 1/D}
240-
\end{equation*}
241-
\begin{equation*}
242-
\halfseedeq{D = 2^N}
243227
\end{equation*}
228+
\halfseedTODO[derive]{id="bootstrap-analytical", what="Write the analytical results section as integrated prose.", needs="Writer prose grounded in current research material"}
244229

245230

246231
\section{Numerical Investigations}
247232

248-
\todo{No numerical artifacts have reached the paper yet.}
233+
\halfseedTODO[data]{id="bootstrap-numerical", what="Write the numerical investigations section as integrated prose.", needs="Writer prose grounded in current research material"}
249234

250235

251236

252237

253238
\section{Prior Art}
254239

255-
\todo{Literature pass has not yet identified concrete prior work.}
240+
\halfseedTODO[derive]{id="bootstrap-literature", what="Survey relevant prior work and place this paper in context.", needs="Writer prose grounded in current literature artifacts"}
256241

257242

258243
\section{Discussion}
259-
The action variation closes.
260-
261-
262-
\subsection{Open risks}
263-
\begin{itemize}
264-
\item Boundary terms not yet checked.
265-
\end{itemize}
266244

267-
\subsection{Suggested next steps}
268-
\begin{itemize}
269-
\item Implement the convergence check.
270-
\end{itemize}
245+
\halfseedTODO[derive]{id="bootstrap-discussion", what="Discuss implications, limitations, and open questions.", needs="Writer prose grounded in current research material"}
271246

272247

273248
% PI-LOCK DISCUSSION
274249
% (no human content under PI-LOCK id="pi-discussion" yet)
275250

276-
\section*{Open Questions}
277-
No open \verb|\todo| items.
278-
279-
280-
\appendix
281-
\section{Supporting Artifacts}
282-
This appendix lists the structured research artifacts the iteration drew on,
283-
in case a reader wants to verify how the body of the paper was synthesized
284-
from internal claims.
285-
286-
\paragraph{A-1 (analytical, conf 0.70).} The variance scales as 1/D.
287-
\textit{Evidence:} computation.
288-
289-
290-
291-
292-
293251
\bibliographystyle{plain}
294252
% Bibliography is generated by the citation gate; see citations.bib once enabled.
295253

src/halfseed/publish/paper_edits.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,35 @@ class _EditFailure(RuntimeError):
189189
# so the placeholder stays visible until real content arrives.
190190
MIN_REPLACE_PLACEHOLDER_CHARS = 80
191191

192+
# Guard against the LLM "wiping a section with a placeholder macro" anti-
193+
# pattern: replace_section text='\\halfseedTODO{X pending}' as the entire
194+
# new body. That destroys the section's existing placeholder (with its id)
195+
# and replaces it with a no-id placeholder shell. The right move when a
196+
# section has no content yet is NO EDIT — leave the existing TODO alone.
197+
_PLACEHOLDER_SHELL_RE = re.compile(
198+
r"^\s*\\halfseedTODO[^a-zA-Z]",
199+
re.IGNORECASE,
200+
)
201+
192202

193203
def _dispatch(text: str, edit: PaperEdit) -> tuple[str, str]:
194204
if edit.op == "replace_section":
205+
body = edit.text.strip()
206+
if not body:
207+
raise _EditFailure(
208+
"replace_section rejected: empty body. If the section has "
209+
"nothing to update, simply emit no edit for it (the "
210+
"existing skeleton placeholder will remain)."
211+
)
212+
if _PLACEHOLDER_SHELL_RE.match(body) and len(body) < 200:
213+
raise _EditFailure(
214+
"replace_section rejected: body is just a \\halfseedTODO "
215+
"macro shell. Replacing a section with a bare TODO macro "
216+
"wipes the section's existing placeholder ID and content. "
217+
"If the section's content is genuinely not ready, emit no "
218+
"edit — the skeleton placeholder is already present. To "
219+
"ADD a new placeholder use op=add_placeholder."
220+
)
195221
return _replace_section(text, edit.section, edit.text)
196222
if edit.op == "append_to_section":
197223
return _append_to_section(text, edit.section, edit.text)
@@ -425,8 +451,14 @@ def _insert_after_section(
425451

426452
def _set_command(text: str, command: str, new_value: str) -> tuple[str, str]:
427453
pattern = re.compile(rf"{command}\s*\{{[^{{}}]*\}}", re.DOTALL)
428-
replacement = f"{command.replace(chr(92) * 2, chr(92))}" + "{" + new_value.strip() + "}"
429-
new_text, count = pattern.subn(replacement, text, count=1)
454+
# Build the replacement as a literal string. We pass it through
455+
# pattern.sub via a lambda — using the string directly would cause
456+
# `re` to interpret backslash escapes (`\h`, `\1`, ...) inside the
457+
# LLM-supplied new_value, which is regex-syntax we DO NOT want here.
458+
final = (
459+
command.replace(chr(92) * 2, chr(92)) + "{" + new_value.strip() + "}"
460+
)
461+
new_text, count = pattern.subn(lambda _m: final, text, count=1)
430462
if count == 0:
431463
raise _EditFailure(f"{command} not found in document")
432464
return new_text, f"set {command} to {new_value!r}"
@@ -438,7 +470,12 @@ def _replace_environment(text: str, env: str, new_body: str) -> tuple[str, str]:
438470
re.DOTALL,
439471
)
440472
body = "\n" + new_body.strip("\n") + "\n"
441-
new_text, count = pattern.subn(rf"\1{body}\3", text, count=1)
473+
# Same regex-interprets-backslash trap as _set_command above. We
474+
# capture begin/end via groups (\1, \3) but the body must be literal,
475+
# so build the full replacement string and lambda it through.
476+
def _mk(m: re.Match[str]) -> str:
477+
return m.group(1) + body + m.group(3)
478+
new_text, count = pattern.subn(_mk, text, count=1)
442479
if count == 0:
443480
raise _EditFailure(f"environment {env!r} not found")
444481
return new_text, f"replaced \\begin{{{env}}}...\\end{{{env}}} body"

src/halfseed/server/routes/paper.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,19 @@ def get_paper_pdf(
104104
"gate enabled (and latexmk installed) to produce it."
105105
),
106106
)
107+
# Serve INLINE so the browser's native PDF viewer renders inside the
108+
# app's <object> embed instead of triggering a download. Passing
109+
# FastAPI's `filename=` kwarg sets Content-Disposition=attachment,
110+
# which is why the previous build forced a download every time the
111+
# user clicked the PDF tab. The user can still save explicitly via
112+
# right-click → Save As / Cmd-S.
107113
return FileResponse(
108114
pdf_path,
109115
media_type="application/pdf",
110-
filename="paper.pdf",
111-
headers={"Cache-Control": "no-store"},
116+
headers={
117+
"Cache-Control": "no-store",
118+
"Content-Disposition": 'inline; filename="paper.pdf"',
119+
},
112120
)
113121

114122

src/halfseed/server/routes/slides.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,13 @@ def get_slides_pdf(
101101
"(latexmk + xelatex must be installed)."
102102
),
103103
)
104+
# Inline rendering — see paper.py get_paper_pdf for why we don't pass
105+
# FastAPI's filename= kwarg (it forces Content-Disposition=attachment).
104106
return FileResponse(
105107
pdf_path,
106108
media_type="application/pdf",
107-
filename="slides.pdf",
108-
headers={"Cache-Control": "no-store"},
109+
headers={
110+
"Cache-Control": "no-store",
111+
"Content-Disposition": 'inline; filename="slides.pdf"',
112+
},
109113
)

src/halfseed/workflow.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1416,6 +1416,18 @@ def _digest(items: list[ResearchArtifact], limit: int = 6) -> list[dict]:
14161416
" - The Numerical / Results section in particular almost "
14171417
"always needs at least one `data` or `figure` placeholder unless "
14181418
"a sandbox-produced figure already covers the claim.\n"
1419+
" - ANTI-PATTERN, do NOT do this: emitting a replace_section "
1420+
"edit whose `text` is just a single \\halfseedTODO macro like "
1421+
"'\\halfseedTODO{Intro pending}'. If you have nothing to add to "
1422+
"a section, EMIT NO EDIT for that section — the existing "
1423+
"placeholder already in the paper is what should remain.\n"
1424+
" - ANTI-PATTERN, do NOT do this: replace_section text='' or "
1425+
"text shorter than 100 chars of real prose — that's wiping a "
1426+
"section's content with empty noise. Use replace_placeholder "
1427+
"(filling) or no edit (leaving the existing placeholder).\n"
1428+
" - ANTI-PATTERN, do NOT use set_title / set_abstract to put "
1429+
"a \\halfseedTODO macro in the title or abstract. The skeleton "
1430+
"already has placeholder TODOs there; leave them.\n"
14191431
"4. NOTES (% [HALFSEED-NOTE: kind] ... blocks) are LaTeX comments "
14201432
"INVISIBLE to readers; use them to record blockers, decisions in "
14211433
"flight, 'tried X but Y' rationale. Do NOT put project-status "

tests/test_paper_edits.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,3 +298,31 @@ def test_set_abstract_replaces_inside_environment() -> None:
298298
assert "A fresh abstract." in new_text
299299
assert "\\begin{abstract}" in new_text
300300
assert "\\end{abstract}" in new_text
301+
302+
303+
def test_set_title_with_backslash_text_does_not_crash() -> None:
304+
"""Regression: an LLM-supplied title containing backslash escapes
305+
(e.g. '\\halfseedTODO{...}') must not be interpreted as regex
306+
syntax by _set_command. The historic bug was a re.PatternError
307+
'bad escape \\h' which crashed the whole iteration."""
308+
edit = PaperEdit(
309+
op="set_title",
310+
text="\\halfseedTODO[human]{id=\"title\", what=\"Title pending\"}",
311+
)
312+
new_text, report = apply_edits(SAMPLE_PAPER, [edit])
313+
assert report.applied == 1
314+
assert "\\halfseedTODO" in new_text
315+
assert "Title pending" in new_text
316+
317+
318+
def test_set_abstract_with_backslash_text_does_not_crash() -> None:
319+
"""Same regression as test_set_title_with_backslash_text_does_not_crash
320+
but for set_abstract / _replace_environment."""
321+
edit = PaperEdit(
322+
op="set_abstract",
323+
text="\\halfseedTODO{Abstract pending} — see Director's plan for round 2.",
324+
)
325+
new_text, report = apply_edits(SAMPLE_PAPER, [edit])
326+
assert report.applied == 1
327+
assert "\\halfseedTODO" in new_text
328+
assert "Old abstract." not in new_text

0 commit comments

Comments
 (0)