TLF Studio
A prototyping UI for TLF generation — Tables, Listings, and Figures destined for FDA submission packets (eCTD Module 5.3.5, ICH E3 §14). Paste a SAP shell or screenshot, get back R code, an executed RTF, and a PDF preview in ~5 seconds for the demographics happy path.
This is a prototyping and learning tool, not a submission-grade pipeline. See What this is suitable for.
User Guide
What is a TLF?
TLF = Tables, Listings, and Figures — the standardized clinical-trial outputs that pharmaceutical sponsors submit to regulators (FDA, EMA, PMDA) as part of an NDA / BLA. In a typical eCTD submission these live in Module 5.3.5 (Reports of Efficacy and Safety Studies), structured per ICH E3 §14 with sponsor-defined naming and content per the Statistical Analysis Plan (SAP).
Production workflow looks like:
- Stat programmer reads the SAP shell
- Writes SAS or R against ADaM datasets (CDISC ADaMIG v1.3)
- Generates an RTF for the submission packet
- A second programmer double-programs the same output for QC (independent verification)
- Numbers reconciled, locked, shipped
TLF Studio automates steps 2–3 for prototyping. It does not replace step 4 (QC) or step 5 (validation against locked data).
What this is suitable for
good for
- Rapid prototyping of R from a TLF shell
- Smoke-testing v2's generated code against synthetic ADaM
- Learning ADaM conventions and gt/dplyr patterns
- Exploring how a SAP shell maps to executable R
NOT for direct FDA submission output:
- Numbers come from v2's dummy data, not your locked study data
- No double-programming / QC step
- No ICH E3 page-break logic, define.xml linkage, or sponsor TLF SOP formatting
- ISS-014 (open) shows v2's templates can produce R that runs and computes incorrectly — exactly the failure QC catches in real workflows
Quick start
- Open https://skills.learnhub101.com/tlf-studio/.
- Paste or type your TLF shell into the left pane (free-form text describing what you want — columns, rows, statistics).
- Pick a Table type from the dropdown (currently only
demographicsgenerates clean dummy data; see Limits). - Set a Table label (e.g.
Table 14.1.1) — appears in the heading. - Hit Generate. Wait ~3–5 s. The middle pane fills with R code; the right pane shows the rendered PDF preview.
- Use the Download .R / .RTF / .PDF buttons on each pane to grab artifacts.
Form fields
| Field | What it does |
|---|---|
shell_text | Free text passed to v2 as the human description. Be specific about columns, statistics, population. |
table_type | Hint for v2's macro selection. Currently reliable: demographics. Others (disposition, ae_summary, exposure) hit ISS-013 due to v2 dummy data gaps. |
table_label | Heading text in the generated table (e.g. "Table 14.1.1"). |
datasets | Comma-separated list of ADaM datasets the program needs. adsl is always in scope; add adae, adlb, etc. as needed. v2 auto-generates dummy data per dataset name. |
code_type | r (default) or sas. SAS programs are generated but not currently executed by TLF Studio (R only). |
Output panes
- Generated code (middle). The full R program v2 produced. Read-only Monaco editor, syntax-highlighted. Download as
.R. - RTF preview (right). An iframe showing the PDF that LibreOffice rendered from the gt-generated RTF. Download
.RTFfor Word / regulatory submission, or.PDFfor inline review. - stderr / stdout panel (bottom, on failure). Opens automatically when R returns non-zero. Both tabs accessible. This is where you'll see real error messages like
object 'eosstt' not found.
Limits and known quirks
object not found. See ISS-013 for details and roadmap.
- Rate limits (per IP, nginx-enforced): 60 requests/min general, 10/min for
/api/generate, 20/min auth. - Generation timeout: 60 s upstream, 30 s for the R subprocess.
- Job retention: jobs sit in
data/jobs/<job_id>/indefinitely — no automatic cleanup yet. - Sass cache warning in stderr (
cannot create dir /root/.cache/R/sass) is cosmetic — gt falls back to/tmpand the table still renders. See ISS-007. - "Unknown column" warnings are also cosmetic if the table renders. See ISS-006.
Upload & paste a TLF shell
You don't have to type the shell. Beneath the textarea is a dropzone offering two paths:
- 📎 Upload PDF / RTF / DOCX / image — pick a file. PDF/DOCX/RTF go through text extraction; PNG/JPG/WebP through OCR. Extracted text lands in the shell textarea automatically.
- 📋 Paste a screenshot (Ctrl+V) — copy a TLF shell image from anywhere, paste on the page; clipboard handler runs OCR.
Both routes call POST /api/extract, which proxies to ClinAssist v1 (port 8899). After extraction, edit if needed, then hit Generate.
FAQ
Why do I see "R execution failed"?
Either the generated R references columns that don't exist in v2's dummy data (most common — see ISS-013), or v2 emitted code with gtsave() commented out (ISS-004 — TLF Studio's fallback.R normally catches this, but corner cases may leak through). Open the stderr panel to see the actual error.
Can I bring my own ADaM data?
Not yet. Phase 3 will add per-user dataset uploads. Today, all jobs use v2's auto-generated dummy data.
Where is the code that runs all this?
/root/tlf_studio/ on the VPS. Five git commits on main. See File layout below.
Why no auth?
Auth is intentionally deferred. The decision: prove the pipeline works for all table types first, then layer JWT cookies, per-user rate limits, and audit logs (Phase 3). Today's nginx-level per-IP rate limits keep abuse bounded.
Architecture
Stack
- Web framework: FastAPI 0.115 + uvicorn (2 workers, listening on 127.0.0.1:8970).
- Reverse proxy: nginx with three location blocks under
skills.learnhub101.com, prefix/tlf-studiostripped before upstream. TLS via Let's Encrypt (existing cert). - Upstream: ClinAssist v2 on 127.0.0.1:8950, called via
/api/workflow/complete-workflow. - R: 4.3.3 (system), packages dplyr / gt / tidyr / labelled / haven (15 pharma packages preinstalled).
- RTF→PDF: LibreOffice 24.2.7.2 in headless mode.
- Process supervisor: systemd unit
tlf-studio.service(enabled, hardened withProtectHome=read-only,PrivateTmp=yes). - Frontend: vanilla HTML + a tiny vanilla-JS SPA, Monaco editor loaded from CDN, no build step.
Request flow
Browser
│ POST /tlf-studio/api/generate {shell_text, table_type, ...}
▼
nginx (strips /tlf-studio prefix, applies rate limit)
│ POST /api/generate
▼
TLF Studio (FastAPI) — api/tlf.py:generate()
│ 1. mint job_id, mkdir data/jobs/<job_id>/
│ 2. POST → ClinAssist v2 /api/workflow/complete-workflow
│ 3. receive {code, dummy_datasets}, write code.R + data/*.csv
│ 4. wrap code.R into script.R (preamble: setwd, library checks, gt-fallback)
│ 5. Rscript --vanilla script.R (timeout 30 s)
│ 6. capture stdout, stderr, returncode, find any *.rtf in output/
│ 7. soffice --headless --convert-to pdf output/*.rtf
│ 8. return GenerateResponse{code, execution{success,stderr,...}, preview_pdf_url}
▼
Browser renders code in Monaco, loads PDF in iframe.
Components
| File | Role |
|---|---|
main.py | FastAPI app, root_path="/tlf-studio", mounts router, serves frontend, exposes /health and /guide. |
api/tlf.py | The router. POST /generate, GET /preview/{job}.pdf, GET /download/{job}.rtf, GET /download/{job}.R, GET /health. |
services/v2_client.py | httpx client to ClinAssist v2. One method: complete_workflow(shell_text, table_type, …) → {code, datasets, workflow_id}. |
services/r_executor.py | Wraps user code in script.R, runs Rscript --vanilla, captures output, locates RTFs. Includes the gt-fallback shim that detects forgotten gtsave() calls. |
services/rtf_to_pdf.py | Spawns LibreOffice headless to convert RTF → PDF. |
services/r/fallback.R | The gt-object-detection shim sourced into every script.R. Catches gt objects that v2 forgot to gtsave. |
frontend/index.html | Single-file 3-pane SPA — input form, Monaco editor, PDF iframe. Vanilla JS, no build. |
frontend/guide.html | This page. The in-app reference. |
config.py | Reads .env: PORT, JWT_SECRET (unused yet), TG_TOKEN, CLINASSIST_V2_URL. |
HTTP endpoints
| Method | Path (public) | Purpose |
|---|---|---|
| GET | /tlf-studio/ | Serves frontend/index.html. |
| GET | /tlf-studio/guide | This guide page. |
| GET | /tlf-studio/health | Liveness — returns service version. |
| GET | /tlf-studio/api/health | Liveness + upstream v2 reachability. |
| POST | /tlf-studio/api/generate | End-to-end pipeline. Body: GenerateRequest. Returns GenerateResponse. |
| GET | /tlf-studio/api/preview/{job_id}.pdf | The rendered PDF for a job. |
| GET | /tlf-studio/api/download/{job_id}.rtf | Original RTF. |
| GET | /tlf-studio/api/download/{job_id}.R | The R program. |
| GET | /tlf-studio/docs | Swagger UI. |
| GET | /tlf-studio/openapi.json | OpenAPI spec. |
File layout
/root/tlf_studio/
├── main.py # FastAPI app + routes
├── config.py # env loader
├── api/
│ ├── __init__.py
│ └── tlf.py # router with /api/* endpoints
├── services/
│ ├── v2_client.py # httpx → ClinAssist v2
│ ├── r_executor.py # Rscript wrapper
│ ├── rtf_to_pdf.py # LibreOffice wrapper
│ └── r/
│ └── fallback.R # gt-object detection shim
├── frontend/
│ ├── index.html # 3-pane SPA (this is the app)
│ └── guide.html # this page
├── docs/ # canonical markdown sources
│ ├── ARCHITECTURE.md
│ ├── USER_GUIDE.md
│ ├── ISSUES_LOG.md
│ ├── TEST_PLAN.md
│ └── .notion_ids.json
├── scripts/
│ └── sync_to_notion.py # docs/*.md → Notion
├── data/jobs/<job_id>/ # one dir per generation
│ ├── code.R # raw v2 output
│ ├── script.R # wrapped + executable
│ ├── data/*.csv # dummy datasets
│ ├── output/*.rtf # gt outputs
│ ├── output/*.pdf # converted previews
│ └── meta.json # job metadata
├── deploy/
│ ├── tlf-studio.service # systemd unit
│ └── tlf.skills… # nginx snippet
└── .venv/ # Python virtualenv
Configuration
Single source: /root/tlf_studio/.env (chmod 600). Read by config.py:
TLF_PORT=8970
TLF_HOST=127.0.0.1
JWT_SECRET=<reserved for Phase 3>
TG_TOKEN=8383210316:…
CLINASSIST_V2_URL=http://127.0.0.1:8950
Service control:
systemctl status tlf-studio.service
systemctl restart tlf-studio.service
journalctl -u tlf-studio -f
Issues Log
Every problem we hit during build and how we resolved it. Numbered chronologically.
ISS-001 — Subdomain CNAME couldn't point to IP resolved
Symptom
Tried to set up tlf.skills.learnhub101.com. The DNS provider only allowed CNAME-to-hostname, not CNAME-to-IP, and the parent zone wasn't mine to add A records to.
Resolution
Pivoted to path-based deploy under the existing skills.learnhub101.com zone, mounting at /tlf-studio. nginx strips the prefix; FastAPI uses root_path="/tlf-studio" so OpenAPI links remain correct.
Lesson
Don't fight DNS. Path-based deploy under an existing TLS-terminated domain is faster and avoids new certs.
ISS-002 — nginx parsed a .bak file in sites-enabled resolved
Symptom
nginx -t reported "duplicate location" errors after I left a .bak copy of the site config in sites-enabled/ while editing the live one.
Root cause
nginx loads every file in sites-enabled/, regardless of extension.
Resolution
Created /root/nginx_backups/ outside any nginx-watched dir. New rule: backups never live under sites-enabled/, even briefly.
ISS-003 — v2 CodeExecutor returns success=False unconditionally resolved
Symptom
v2's /api/workflow/complete-workflow always returned "execution_success": false, even when the generated R was perfectly fine. The execution_log said v2's CodeExecutor wasn't wired up.
Resolution
Stopped trusting v2's execution status. TLF Studio now extracts the generated code field, writes it to code.R, wraps it in script.R, and runs Rscript --vanilla ourselves. v2 becomes a pure code-generation service.
Lesson
Treat upstream services as a contract: take what's reliable, replace what isn't.
ISS-004 — v2 generates R with gtsave() commented out resolved
Symptom
About 1 in 4 generations produced R code that built a gt object but had gtsave(…) commented out (or missing entirely), so no RTF was written.
Resolution
services/r/fallback.R sources into every script and runs after the user code. It walks the global env, finds any object inheriting from gt_tbl, and saves it to output/<name>.rtf. Catches what v2 forgets.
ISS-005 — v2 R code uses hardcoded output paths resolved
Symptom
v2 generates code with outpath <- "./output" baked in, sometimes inside the table-build pipeline (so a header-level override doesn't reach it).
Resolution
Each job runs in a per-job working directory data/jobs/<job_id>/ with the exact ./output/ and ./data/ subdirs v2 expects. We chdir there before Rscript, which makes v2's hardcoded paths land in the right place.
ISS-006 — v2 mixes upper- and lowercase column names resolved (cosmetic)
Symptom
R warnings: Unknown or uninitialised column: 'category'. v2 sometimes generates code that mixes SEX (CDISC convention) with lowercase aliases.
Resolution
Cosmetic — gt is forgiving and the table still renders. We surface the warnings in the stderr panel so the user sees them. A real fix lives upstream in v2's prompt template.
ISS-007 — /root/.cache/R/sass write fails under systemd hardening resolved
Symptom
Stderr always includes:cannot create dir '/root/.cache/R/sass', reason 'Read-only file system'
Root cause
The systemd unit hardens /root with ProtectHome=read-only. gt's sass cache wants to write there.
Resolution
gt's own fallback to tempdir() works fine — the warning is harmless. We considered loosening systemd hardening but kept it: defense in depth wins over a clean stderr.
ISS-008 — Python heredoc + bash + R triple-escape failure resolved
Symptom
Trying to ship R snippets via cat <<EOF | python3 -c "..." mangled backslashes and quotes catastrophically.
Resolution
R always lives in real .R files (services/r/fallback.R, generated code.R). Python writes them with open(…).write(…), never via heredoc-inside-heredoc.
ISS-009 — uvicorn workers don't auto-reload on code change resolved
Symptom
Edited main.py, did git commit, expected the service to pick it up. It didn't — workers were running stale code.
Resolution
The service runs uvicorn without --reload in production. Every code change ends with systemctl restart tlf-studio.service. This is correct for production, but easy to forget mid-build.
ISS-010 — main.py overwritten mid-session, regressed wiring resolved
Symptom
After a context compaction, I rewrote main.py from a stale memory of "phase 0" — losing the router import and the frontend mount that Phase 1 and 2 had added.
Resolution
First step on resume is now git log + cat main.py before any edit. Trust the working tree, not memory.
ISS-011 — Bash variable mangled JSON response resolved
Symptom
Capturing curl output into a bash variable then piping to python3 -c "json.load(sys.stdin)" failed with "invalid control character" because em-dashes and embedded newlines confused the shell.
Resolution
Always curl -o /tmp/resp.json … and then python3 <<'PYEOF' with open('/tmp/resp.json'). Bash never touches the JSON.
ISS-012 — Large heredoc commands trip destructive-detector resolved
Symptom
Trying to ship a 700-line guide.html in a single bash heredoc tripped Telegram's destructive-detector approval gate (~90 s timeout) and the command died half-written.
Resolution
Cap any single command at ~200 lines. For larger files, write in stages, or write a create_file.py that opens and writes the content in pure Python (which doesn't go through bash).
ISS-013 — disposition table fails: object 'eosstt' not found open
Symptom
Submitting a Subject Disposition table (table_type=disposition) returns success=False, returncode=1 after ~1.4 s. Stderr:
Error in `mutate()`:
ℹ In argument: `disp_cat = case_when(...)`
Caused by error in `case_when()`:
! Failed to evaluate the left-hand side of formula 1.
Caused by error:
! object 'eosstt' not found
Root cause
v2 emits R that references CDISC disposition variables (EOSSTT, DCSREAS) — correct against real ADaM data. But v2's built-in dummy ADSL only has demographic columns (USUBJID, AGE, AGEGR1, SEX, RACE, treatment). EOSSTT/DCSREAS aren't generated. So generated R works on real data and dies on dummy data.
Same class as ISS-006 (column mismatch): generated code expects columns the dummy doesn't provide. ISS-006 was naming; ISS-013 is missing.
Why it didn't surface earlier
All prior smoke tests used table_type=demographics, which only needs columns the dummy already provides.
Resolution options
- Patch v2's dummy data generator to include EOSSTT/DCSREAS. Right fix; lives in v2.
- TLF Studio fallback dataset — ship a more complete
sample_data/adsl.csvand override v2's dummy when table_type ∈ {disposition, exposure, ae_summary}. Cheap; could land in Phase 1.5. - UI warning — surface "v2 currently generates dummy data only for demographics-style tables" in the form. Pure UX. Landed in this commit.
Lesson
Smoke tests must cover every table_type the form exposes, not just the easy one.
ISS-014 — v2 disposition R overwrites numeric columns with strings before arithmetic v2 prompt-template bug
Symptom
After ISS-013's augmenter resolved the missing-column issue, disposition advances further but now fails with:
Error in mutate():
ℹ In argument: Total = sprintf(...)
Caused by error in Placebo + `Drug Low`:
! non-numeric argument to binary operator
Root cause
v2's R does mutate(Placebo = sprintf("%d (%.1f)", Placebo, …)), overwriting the numeric column with a string. Then a later mutate(Total = Placebo + \`Drug Low\`) tries arithmetic on strings.
Why augmentation can't fix this
Pure template-logic bug. No data shape makes string addition work. The fix lives upstream in v2's prompt: keep numeric counts in a separate column from the formatted display string.
Lesson
Augmentation handles missing-column failures cleanly. Template-logic bugs in generated R have to be fixed at the source (v2 prompts). The clean separation matters: data-shape problems → augmenter; logic problems → upstream.
Test Plan
T-01 · Baseline smoke passing
Steps
- POST /api/generate with demographics shell.
- Expect HTTP 200 in < 5 s.
- Expect
execution.success=true,returncode=0, RTF size ≥ 8000 bytes. - GET /api/preview/<job_id>.pdf — expect HTTP 200, valid PDF v1.7, ≥ 30 KB.
Last result: 4 s end-to-end, RTF 8847 B, PDF 45 648 B. ✓
T-07 · Per-table-type smoke matrix partial
Coverage targets
| table_type | Status | Blocker |
|---|---|---|
| demographics | PASS | — |
| disposition | PARTIAL | ISS-013 fixed by augmenter; new ISS-014 (v2 template bug) blocks |
| ae_summary | untested | likely same family as ISS-013 (needs ADAE columns) |
| exposure | untested | likely same family (needs ADEX/EXTRT) |
| lab_summary | untested | likely same family (needs ADLB/PARAMCD) |
| vital_signs | untested | likely same family |
Exit criterion
Every table_type in the form's dropdown produces execution.success=true on the canonical happy-path shell. Once green, gate auth (Phase 3).
T-02 through T-06
Standard checks: rate-limit enforcement (429 after 10 generates/min), preview/download URLs accessible without authentication (intentional, public mode), Swagger reachable, health endpoints return 200, uvicorn restarts cleanly under systemctl. All passing as of 2026-04-28 19:46 UTC. Full details in Notion · Test Plan.