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
30 changes: 29 additions & 1 deletion apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -33924,6 +33924,26 @@
"type": "string"
},
"candidateAnswer": {},
"candidateAnswerMode": {
"type": "string",
"enum": ["plain", "rich"]
},
"candidateAnswerIntegrity": {
"type": "string",
"enum": [
"empty",
"plain",
"legacy_plain",
"rich_valid",
"rich_noncanonical",
"unsupported_version",
"corrupt"
]
},
"candidateAnswerProjection": {
"type": "string",
"nullable": true
},
"standardAnswer": {},
"score": {
"type": "number",
Expand All @@ -33937,7 +33957,15 @@
"nullable": true
}
},
"required": ["order", "type", "content", "maxScore"],
"required": [
"order",
"type",
"content",
"candidateAnswerMode",
"candidateAnswerIntegrity",
"candidateAnswerProjection",
"maxScore"
],
"additionalProperties": false
}
}
Expand Down
116 changes: 116 additions & 0 deletions apps/api/src/lib/attemptExportAnswer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
classifyPersistedRichAnswer,
type PersistedRichAnswerState,
} from "@exam/contracts";
import { plainTextProjection, type QuestionType } from "@exam/domain";

/**
* Export-side answer policy (rich-content-semantic-contract.md §14).
*
* The export boundary is a READER: it may choose a representation, but it may
* never choose what a persisted Rich value means. Semantic interpretation is
* delegated to the shared persisted-answer classifier
* (`classifyPersistedRichAnswer`, @exam/contracts) and the frozen answer
* context — never derived from the runtime shape. This module decides only
* what each classified state is allowed to contribute to an export:
*
* raw evidence != semantic projection
*
* - `raw` is the untouched stored value; the JSON export route carries it as
* the authoritative evidence (no projection replaces it);
* - `projection` is the derived human-readable text. It is `null` whenever
* the state has no permitted semantic projection (`unsupported_version`,
* `corrupt`, `empty`) — a corrupt Rich value must never be silently
* exported as if it were a valid Plain answer (F-05).
*
* Export is read-only: nothing here normalizes, canonicalizes, repairs, or
* writes back the persisted value.
*/

/** Per-question export view of one persisted candidate answer. */
export interface ExportAnswerView {
/** Effective frozen mode of the slot (legacy/absent answerMode = plain). */
mode: "plain" | "rich";
/** Semantic integrity state of the stored value (`empty` when absent). */
integrity: PersistedRichAnswerState;
/** Derived human-readable text, or null when no projection is permitted. */
projection: string | null;
}

/**
* The display rule for NON-Rich-slot values: plain strings as-is, option-id
* arrays joined, everything else JSON-serialized (the pre-existing CSV
* convention for standard answers and typed objective answers).
*
* INTENTIONAL CONSTRAINT: never call this on a Rich-capable answer slot — a
* rich `text_response` candidate answer must be classified through
* `resolveExportAnswerView` so a corrupt/unsupported Rich value cannot reach
* the CSV as normal answer text. The standard answer is the one legacy slot
* this formatter still owns: it is authored plain text (or a typed objective
* answer, or null), carries no Rich/Plain duality, and has no answerMode.
*/
export function formatPlainExportValue(value: unknown): string {
if (value == null || value === "") return "";
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(String).join("; ");
return JSON.stringify(value);
}

/**
* Classifies one persisted candidate answer and derives its export view.
*
* `answerMode` MUST come from the frozen question snapshot of the attempt —
* never from the live question bank. The mode is what makes a string `plain`
* on one slot and `corrupt` on another; the value's shape alone never does.
*/
export function resolveExportAnswerView(input: {
questionType: QuestionType;
answerMode: string | null | undefined;
value: unknown;
}): ExportAnswerView {
const mode = input.answerMode === "rich" ? "rich" : "plain";

if (input.questionType !== "text_response") {
// Objective / fill_blank slots carry typed protocol answers (option ids,
// booleans, blank records); Rich/Plain duality does not exist there, so
// the persisted-Rich classifier is not invoked and cannot mislabel a
// legitimate array/boolean answer as corrupt.
return {
mode: "plain",
integrity: input.value == null ? "empty" : "plain",
projection:
input.value == null ? null : formatPlainExportValue(input.value),
};
}

const read = classifyPersistedRichAnswer({
value: input.value,
answerMode: input.answerMode,
});
switch (read.kind) {
case "empty":
return { mode, integrity: "empty", projection: null };
case "plain":
return { mode, integrity: "plain", projection: read.text };
case "legacy_plain":
// Provenance-backed legacy text stays distinguishable from ordinary
// plain and is exportable as text (no production producer exists yet).
return { mode, integrity: "legacy_plain", projection: read.text };
case "rich_valid":
case "rich_noncanonical":
// Interpretable Rich content: a derived plain-text projection is
// permitted (the same read-only display the admin/grading pages
// render). Mode and integrity label it as derived; noncanonical values
// are never relabeled canonical and never repaired.
return {
mode,
integrity: read.kind,
projection: plainTextProjection(read.document),
};
case "unsupported_version":
case "corrupt":
// F-05 trust boundary: raw evidence is preserved by the JSON export,
// but no semantic Plain projection may be fabricated here.
return { mode, integrity: read.kind, projection: null };
}
}
61 changes: 50 additions & 11 deletions apps/api/src/routes/attempts.admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import {
getRequestContext,
} from "./helpers.js";
import { cookieAuth } from "./attempts.shared.js";
import {
formatPlainExportValue,
resolveExportAnswerView,
} from "../lib/attemptExportAnswer.js";
import { recordSensitiveReadAudit } from "../audit/auditWriter.js";
import { grantWithOperationRaceRecovery } from "../orchestrators/operatorGrantExecution.js";
import { forceSubmitWithOperationRaceRecovery } from "../orchestrators/forceSubmitExecution.js";
Expand Down Expand Up @@ -388,6 +392,8 @@ export async function registerAdminAttemptRoutes(fastify: FastifyInstance) {
/**
* GET /admin/attempts/:attemptId/export — Export attempt details (answers +
* question results) as JSON. Admin-only. Audit event: attempt.exported.
* This is the RAW-evidence export: `candidateAnswer` carries the stored
* value untouched, with integrity/projection companions for interpretation.
* For CSV, see GET /admin/attempts/:attemptId/export/csv (split so each
* response has a single, self-consistent OpenAPI content type).
*/
Expand Down Expand Up @@ -431,6 +437,11 @@ export async function registerAdminAttemptRoutes(fastify: FastifyInstance) {
* UTF-8 (BOM) CSV file. Admin-only. The OpenAPI `text/csv` media type is
* applied by the spec builder's post-transform hook (`fixCsvContentTypes` in
* openapi/swagger.ts, which patches this path). Audit event: attempt.exported.
*
* The 考生答案 cell holds the CLASSIFIED semantic projection, and the
* appended 考生答案模式 / 考生答案状态 columns carry the frozen slot mode
* and the §7 integrity state; raw evidence for non-projectable states stays
* in the JSON export route. Existing columns keep their positions.
*/
fastify.get(
"/admin/attempts/:attemptId/export/csv",
Expand Down Expand Up @@ -467,6 +478,13 @@ export async function registerAdminAttemptRoutes(fastify: FastifyInstance) {
const correctLabel = "是";
// i18n-copy-allow: data-format — CSV export header/value data contract
const incorrectLabel = "否";
// The candidate answer cell is the CLASSIFIED projection: for
// `unsupported_version` / `corrupt` the projection is null, so the cell
// shows the not-applicable marker instead of the raw stored value — a
// corrupt Rich string must never read as a normal answer (F-05). Raw
// evidence stays available through the JSON export route.
const csvAnswerCell = (q: AttemptExportQuestionResult): string =>
q.candidateAnswerProjection ?? (q.candidateAnswer == null ? "" : "—");
const csvHeaders = [
// i18n-copy-allow: data-format — CSV export header/value data contract
"题号",
Expand All @@ -484,17 +502,26 @@ export async function registerAdminAttemptRoutes(fastify: FastifyInstance) {
"满分",
// i18n-copy-allow: data-format — CSV export header/value data contract
"是否正确",
// Appended after the frozen columns so positional consumers keep
// their offsets: the frozen answer mode and the semantic integrity
// of the stored value (seven §7 read states, canonical tokens).
// i18n-copy-allow: data-format — CSV export header/value data contract
"考生答案模式",
// i18n-copy-allow: data-format — CSV export header/value data contract
"考生答案状态",
];
const csvRows = exportData.questionResults.map((q) => ({
题号: q.order,
题型: q.type,
题目内容: q.content,
考生答案: formatAnswerValue(q.candidateAnswer),
标准答案: formatAnswerValue(q.standardAnswer),
考生答案: csvAnswerCell(q),
标准答案: formatPlainExportValue(q.standardAnswer),
得分: q.score ?? "—",
满分: q.maxScore,
是否正确:
q.correct == null ? "—" : q.correct ? correctLabel : incorrectLabel,
考生答案模式: q.candidateAnswerMode,
考生答案状态: q.candidateAnswerIntegrity,
}));
const csv = "\uFEFF" + generateCSV(csvHeaders, csvRows);
reply.header(
Expand All @@ -510,6 +537,14 @@ export async function registerAdminAttemptRoutes(fastify: FastifyInstance) {
* Builds the attempt export payload (answers + per-question results) shared by
* the JSON and CSV export routes. Throws NotFoundError if the attempt does not
* exist in the caller's organization.
*
* Every candidate answer is classified through the shared persisted-answer
* classifier using the FROZEN snapshot answerMode (rich-content-semantic-
* contract §7/§14): `candidateAnswer` stays the raw stored evidence, and the
* `candidateAnswerMode` / `candidateAnswerIntegrity` /
* `candidateAnswerProjection` companions expose the slot context and the
* derived — never authoritative — projection. Classification is read-only:
* no value is normalized, canonicalized, or repaired here.
*/
async function buildAttemptExport(
fastify: FastifyInstance,
Expand Down Expand Up @@ -557,11 +592,23 @@ async function buildAttemptExport(
const gResult = attempt.gradingResult?.find(
(g) => g.questionId === q.originalQuestionId,
);
const candidateAnswer = answerMap.get(q.originalQuestionId) ?? null;
// Classification context is the FROZEN snapshot answerMode — never the
// live question bank — so an existing attempt is always interpreted by
// the question version it was answered under.
const answerView = resolveExportAnswerView({
questionType: q.type,
answerMode: q.answerMode,
value: candidateAnswer,
});
return {
order: q.order,
type: q.type,
content: q.content,
candidateAnswer: answerMap.get(q.originalQuestionId) ?? null,
candidateAnswer,
candidateAnswerMode: answerView.mode,
candidateAnswerIntegrity: answerView.integrity,
candidateAnswerProjection: answerView.projection,
standardAnswer: q.standardAnswer,
score: gResult?.score ?? null,
maxScore: gResult?.maxScore ?? q.score,
Expand Down Expand Up @@ -599,11 +646,3 @@ async function recordExportAudit(
metadata: { format },
});
}

/** Formats an answer value as a display string for CSV export. */
function formatAnswerValue(value: unknown): string {
if (value == null || value === "") return "";
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(String).join("; ");
return JSON.stringify(value);
}
Loading
Loading