Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ The sample-workspace seed is not the public demo. `/demo` is component-memory-on

- Standalone projects and membership contracts/validation: `src/lib/projects/`; project HTTP routes: `src/app/api/projects/`, `src/app/api/studies/[id]/project/`
- Project storage: `src/lib/storage/redisProjects.ts`, `cloudflare/workspace/projects.ts`, with the project sub-port in `src/lib/storage/types.ts` and RPC validation in `src/lib/storage/durableObject.ts`
- Study ZIP analysis views: `src/lib/export/analysisFiles.ts`; both ZIP paths use these snapshot-only builders. Combined entries stream from repeated reads of the same fenced snapshot; turn CSVs are per-interview shards. Keep citation indices 1-based over the full stored transcript, including omitted system-message positions.
- Shared per-study transcript sources and bounded project composition: `src/lib/export/studyTranscriptsSource.ts`, `src/lib/export/projectTranscriptsMarkdown.ts`; typed browser client: `src/services/projectService.ts`
- Researcher studies/interviews and atomic Redis scripts: `src/lib/kv.ts`
- Field-level JSON patching inside Redis Lua (preserves untouched value types): `src/lib/studyJsonLua.ts`
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,19 @@ For researchers:
6. Choose a dataset, run aggregate analysis, or ask questions in **Explore**; export the study or workspace.
7. Pause and resume collection without replacing links, or delete a study in Settings when its retention period ends.

**Export this study** downloads a ZIP of raw records, transcripts, the aggregate and the notebook. **Export transcripts (.md)** downloads one Markdown file with every saved transcript of the study, for reading or for another analysis tool. Transcript text is quoted exactly as saved, and each interview lists what its participant was told about the AI. If a participant was promised that the study uses only one provider and model, sending the file to a different AI service may break that promise. A download that did not finish is refused rather than saved.
**Export this study** downloads a ZIP of raw records, transcripts, the aggregate and the notebook, plus an `analysis/` folder for coding tools and spreadsheets. The same analysis files are included in workspace ZIP exports.

The ZIP retains the per-interview `NNN_*.json` and `.md`, `summary.csv`, `aggregates/` and `explorations/`. It adds:

- `analysis/README.md`: field definitions, historical-label rules and the consent/provider sharing warning.
- `analysis/interviews.jsonl`: one interview per line, with collection context, recorded consent and analysis provenance, profile values and verbatim turns.
- `analysis/interviews.csv`: interview-level data and one column per observed profile field ID, labeled from the newest interview.
- `analysis/turns/NNN.csv`: one turn table per interview to bound export memory; indices match analysis citations, including gaps for omitted system messages.
- `analysis/profile_fields.csv`: profile values in long form with their labels at collection.

CSVs use UTF-8 with BOM and CRLF. Formula-like text is protected with an added apostrophe; use JSONL for verbatim quotes. Unknown historical fields are not filled from today’s study configuration. ZIP Markdown dates are ISO UTC.

**Export transcripts (.md)** downloads one Markdown file with every saved transcript of the study, for reading or for another analysis tool. Transcript text is quoted exactly as saved, and each interview lists what its participant was told about the AI. If a participant was promised that the study uses only one provider and model, sending the file to a different AI service may break that promise. A download that did not finish is refused rather than saved.

### Explore this study

Expand Down
16 changes: 16 additions & 0 deletions docs/releases/v5.4.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,19 @@ browser locality or language-pack support.
- Run the workers/Redis shared revision contract and browser lanes listed in
`CHANGES-device-voice.md`. No deployment or real browser verification was done
in the restricted implementation sandbox.

## Richer export

Study and workspace ZIP downloads now include an `analysis/` folder for AI coding tools and spreadsheets. No new export button or third-party adapter is required.

- `README.md` defines every field and repeats the Markdown export’s consent/provider sharing warning.
- `interviews.jsonl` holds one interview per line with its saved collection protocol, consent and provider records, synthesis, profile and verbatim turns.
- `interviews.csv` offers an interview-level table, including dynamic profile columns labeled from the newest interview.
- `turns/NNN.csv` provides a turn table per interview, keeping export memory bounded. Turn indices match analysis citations: skipped system messages leave gaps.
- `profile_fields.csv` preserves each profile value’s historical label and extraction status.

CSV files use UTF-8 with BOM and CRLF. Formula-like text receives a protective apostrophe; JSONL is the source for verbatim quotations. Missing historical configuration remains missing, never reconstructed from the current study. Per-interview Markdown timestamps in the ZIP now use ISO UTC.

Node and Cloudflare produce the same file contents. Cloudflare reads the same captured snapshot in bounded passes, and the archive is finalized only after all files and the final sequence check succeed. Existing raw JSON, Markdown, summary CSV, aggregate and notebook exports remain included.

Before sharing these files with another AI service, check each participant’s recorded provider commitment. Exporting data does not change that promise.
12 changes: 11 additions & 1 deletion src/app/api/interviews/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
MAX_OWNED_STUDIES,
} from '@/lib/ownedStudies';
import JSZip from 'jszip';
import { analysisCsvHeader, analysisInterviewsHeader, analysisInterviewsRow, analysisJsonLine, analysisProfileRows, analysisReadme, analysisTurnsCsv, analysisTurnsName, collectProfileColumns, PROFILE_COLUMNS, sortedProfileColumns, type ProfileColumns } from '@/lib/export/analysisFiles';
import { StoredInterview, StoredAggregateSynthesis, type StoredStudy } from '@/types';
import { logRequestFailure } from '@/lib/requestLog';
import {
Expand Down Expand Up @@ -116,13 +117,17 @@ async function buildExportResponse(
beforeReturn?: () => Promise<Response | null>,
): Promise<Response> {
const zip = new JSZip();
zip.folder('analysis/turns');
const columns: ProfileColumns = new Map();

// Entry names and contents come from the builders the Cloudflare stream
// uses, so the two archives cannot drift apart.
interviews.forEach((interview, index) => {
const baseName = interviewEntryBaseName(index, interview);
zip.file(`${baseName}.json`, interviewJson(interview));
zip.file(`${baseName}.md`, interviewTranscriptMarkdown(interview));
zip.file(analysisTurnsName(index), analysisTurnsCsv(interview));
collectProfileColumns(columns, interview);
});

for (const [studyId, aggregate] of aggregates) {
Expand All @@ -134,6 +139,11 @@ async function buildExportResponse(

const csvLines = [SUMMARY_CSV_HEADER, ...interviews.map(summaryCsvRow)];
zip.file(SUMMARY_CSV_NAME, csvLines.join('\n'));
const orderedColumns = sortedProfileColumns(columns);
zip.file('analysis/interviews.jsonl', interviews.map(analysisJsonLine).join(''));
zip.file('analysis/interviews.csv', analysisInterviewsHeader(orderedColumns) + interviews.map(interview => analysisInterviewsRow(interview, orderedColumns)).join(''));
zip.file('analysis/profile_fields.csv', analysisCsvHeader(PROFILE_COLUMNS) + interviews.map(analysisProfileRows).join(''));
zip.file('analysis/README.md', analysisReadme(orderedColumns));

const zipBlob = await zip.generateAsync({ type: 'blob' });
const refusal = await beforeReturn?.();
Expand Down Expand Up @@ -169,7 +179,7 @@ async function streamDurableExport(store: DurableWorkspaceStorePort, studyId?: s
// Every page carries at least one row or aggregate, which bounds the walk.
const maximumPages = begun.count + begun.studyIds.length + MAX_EXPLORATION_ANSWERS + 1;
const body = createInterviewExportStream({
pages: remainingExportPages(store, sequence, first, maximumPages, studyId),
pages: () => remainingExportPages(store, sequence, first, maximumPages, studyId),
beforeFinish: async () => {
const verified = await store.verifyExportSequence({ sequence, ...scope });
if (verified === 'changed') throw new ExportSnapshotChangedError();
Expand Down
1 change: 1 addition & 0 deletions src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export default function Dashboard() {
<Button
type="button"
variant="primary"
title="ZIP with raw records, transcripts, saved analyses, and analysis JSONL/CSV files with a data dictionary"
onClick={() => void handleExportAll()}
disabled={exporting || blockedByOperation}
>
Expand Down
2 changes: 1 addition & 1 deletion src/components/StudyDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1133,7 +1133,7 @@ const StudyDetail: React.FC<StudyDetailProps> = ({ studyId }) => {
<p className="mt-2 max-w-measure text-[13px] text-ink-700">A collection-configuration edit advances the revision and invalidates earlier participant authority. Retained interviews remain available; choose their revisions explicitly for analysis. Pausing access does not change the protocol revision.</p>
<div className="mt-3 flex flex-wrap gap-3">
<Button variant="primary" disabled={operationPending} onClick={() => router.push(`/setup?prefill=edit&studyId=${encodeURIComponent(studyId)}`)}>Edit study</Button>
<Button variant="quiet" disabled={operationPending || isExporting} onClick={() => void handleExportStudy()}>{isExporting ? 'Preparing study export…' : 'Export this study'}</Button>
<Button variant="quiet" disabled={operationPending || isExporting} onClick={() => void handleExportStudy()} title="ZIP with raw records, transcripts, saved analyses, and analysis JSONL/CSV files with a data dictionary">{isExporting ? 'Preparing study export…' : 'Export this study'}</Button>
<Button variant="quiet" disabled={operationPending || isExportingTranscripts || study.interviewCount === 0} onClick={() => void handleExportTranscripts()} title="All transcripts in one Markdown file, with what each participant was told about the AI">{isExportingTranscripts ? 'Preparing transcripts…' : 'Export transcripts (.md)'}</Button>
</div>
</section>
Expand Down
Loading
Loading