Skip to content

Commit e7f3542

Browse files
committed
enhance(chat): render manage proposals with the real student question preview
Proposal cards now map the validated payload to an artificial ElementInstance and render it through the shared StudentElement (preview + compact), always expanded; the raw JSON moves into a collapsed details block and the Preview toggle is gone. Parse failures fall back to the JSON view. The system prompt tells the model to answer with one short sentence after a proposal instead of restating the question. The zod proposal schemas move into a client-safe manageProposalSchema.ts: importing them from manageProposals.ts pulled that server module (and with it ioredis) into the browser bundle.
1 parent 2df859e commit e7f3542

9 files changed

Lines changed: 357 additions & 66 deletions

apps/chat/src/components/manage-proposal-card.tsx

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
'use client'
22

3-
import { CheckIcon, EyeIcon, LoaderCircleIcon } from 'lucide-react'
3+
import { CheckIcon, LoaderCircleIcon } from 'lucide-react'
44
import { useState, type FC } from 'react'
5+
import { parseManageProposalPayload } from '../services/proposalToElementInstance'
6+
import { ManageProposalPreview } from './manage-proposal-preview'
57
import { formatToolName } from './tool-labels'
68

79
export type ManageProposalResult = {
@@ -80,7 +82,7 @@ export const ManageProposalCard: FC<ManageProposalCardProps> = ({
8082
const [confirmation, setConfirmation] = useState<ConfirmationState>({
8183
type: 'idle',
8284
})
83-
const [showPreview, setShowPreview] = useState(false)
85+
const previewPayload = parseManageProposalPayload(result)
8486
const payloadText = JSON.stringify(result.payload, null, 2)
8587
const waiting = status.type === 'running'
8688
const created = confirmation.type === 'success'
@@ -149,15 +151,9 @@ export const ManageProposalCard: FC<ManageProposalCardProps> = ({
149151
</div>
150152

151153
<div className="space-y-2 px-3 py-3">
154+
{previewPayload && <ManageProposalPreview payload={previewPayload} />}
155+
152156
<div className="flex flex-wrap gap-2">
153-
<button
154-
type="button"
155-
onClick={() => setShowPreview((value) => !value)}
156-
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 text-xs font-semibold text-slate-700 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
157-
>
158-
<EyeIcon className="size-3.5" aria-hidden />
159-
Preview
160-
</button>
161157
{!created && (
162158
<button
163159
type="button"
@@ -203,11 +199,14 @@ export const ManageProposalCard: FC<ManageProposalCardProps> = ({
203199
)}
204200
</div>
205201

206-
{showPreview && (
207-
<pre className="max-h-72 overflow-auto rounded bg-slate-950 p-3 text-xs leading-5 text-slate-50">
202+
<details>
203+
<summary className="cursor-pointer text-xs font-medium text-slate-500 hover:text-slate-700">
204+
Show raw JSON
205+
</summary>
206+
<pre className="mt-2 max-h-72 overflow-auto rounded bg-slate-950 p-3 text-xs leading-5 text-slate-50">
208207
{payloadText}
209208
</pre>
210-
)}
209+
</details>
211210
</div>
212211
</div>
213212
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
'use client'
2+
3+
import { ElementType } from '@klicker-uzh/graphql/dist/ops'
4+
import useSingleStudentResponse from '@klicker-uzh/shared-components/src/hooks/useSingleStudentResponse'
5+
import StudentElement, {
6+
type InstanceStackStudentResponseType,
7+
} from '@klicker-uzh/shared-components/src/StudentElement'
8+
import { useMemo, useState, type FC } from 'react'
9+
import type { ManageProposalPayload } from '../services/proposalToElementInstance'
10+
import { proposalPayloadToElementInstance } from '../services/proposalToElementInstance'
11+
12+
type ManageProposalPreviewProps = {
13+
payload: ManageProposalPayload
14+
}
15+
16+
export const ManageProposalPreview: FC<ManageProposalPreviewProps> = ({
17+
payload,
18+
}) => {
19+
const element = useMemo(
20+
() => proposalPayloadToElementInstance(payload),
21+
[payload]
22+
)
23+
24+
const [singleStudentResponse, setSingleStudentResponse] =
25+
useState<InstanceStackStudentResponseType>({
26+
response: undefined,
27+
type: ElementType.FreeText,
28+
valid: false,
29+
})
30+
31+
useSingleStudentResponse({
32+
instance: element,
33+
setStudentResponse: setSingleStudentResponse,
34+
})
35+
36+
return (
37+
<div className="max-w-full overflow-x-auto">
38+
<StudentElement
39+
compact
40+
element={element}
41+
elementIx={0}
42+
preview
43+
setSingleStudentResponse={setSingleStudentResponse}
44+
singleStudentResponse={singleStudentResponse}
45+
/>
46+
</div>
47+
)
48+
}

apps/chat/src/services/manageAssistantRuntime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const BASE_MANAGE_ASSISTANT_PROMPT = [
1414
"Do not expose raw tool JSON or raw UUIDs unless the lecturer asks for technical detail; summarize results by human-readable name, using a question's short numeric id only when it helps the lecturer disambiguate.",
1515
'Do not persist, update, delete, publish, share, or execute anything autonomously. Persisted DRAFT creation requires a signed proposal card and explicit lecturer confirmation. Never claim a draft was created until confirmation succeeds.',
1616
'When the lecturer asks to create a DRAFT question with confirmation, use the signed proposal tool instead of the draft-only scaffolding tools. Use draft-only tools for brainstorming or non-persisted previews only.',
17+
'After the signed proposal tool returns, reply with at most one short sentence and never restate the question content, options, or JSON; the proposal card already renders them.',
1718
'When a requested object is not accessible, state that it cannot be accessed and do not try to infer hidden details.',
1819
].join('\n')
1920

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { z } from 'zod'
2+
3+
// Client-safe proposal schema module: imported by the proposal preview inside
4+
// the browser bundle, so it must not import server-only modules (the rest of
5+
// manageProposals.ts pulls in @klicker-uzh/util and with it ioredis).
6+
7+
const proposalChoiceSchema = z.object({
8+
correct: z.boolean(),
9+
feedback: z.string().trim().min(1).max(500).optional(),
10+
ix: z.number().int().min(0).optional(),
11+
value: z.string().trim().min(1).max(240),
12+
})
13+
14+
const baseProposalPayloadSchema = z.object({
15+
basePoints: z.boolean().default(true),
16+
content: z.string().trim().min(1).max(4000),
17+
explanation: z.string().trim().min(1).max(2000).optional(),
18+
name: z.string().trim().min(1).max(160),
19+
pointsMultiplier: z.number().int().min(1).max(100).default(1),
20+
status: z.literal('DRAFT'),
21+
tags: z.array(z.string().trim().min(1).max(60)).max(8).default([]),
22+
})
23+
24+
export const choicesProposalPayloadSchema = baseProposalPayloadSchema.extend({
25+
options: z.object({
26+
choices: z.array(proposalChoiceSchema).min(2).max(8),
27+
displayMode: z.literal('LIST').default('LIST'),
28+
hasAnswerFeedbacks: z.boolean().default(false),
29+
hasSampleSolution: z.boolean().default(true),
30+
}),
31+
type: z.enum(['SC', 'MC']),
32+
})
33+
34+
export const freeTextProposalPayloadSchema = baseProposalPayloadSchema.extend({
35+
options: z.object({
36+
hasSampleSolution: z.boolean().default(false),
37+
restrictions: z.object({
38+
maxLength: z.number().int().positive().optional(),
39+
}),
40+
solutions: z.array(z.string().trim().min(1).max(500)).optional(),
41+
}),
42+
type: z.literal('FREE_TEXT'),
43+
})
44+
45+
export const manageElementCreateProposalSchema = z.object({
46+
kind: z.literal('element.create.proposal'),
47+
payload: z.union([
48+
choicesProposalPayloadSchema,
49+
freeTextProposalPayloadSchema,
50+
]),
51+
requiresConfirmation: z.literal(true),
52+
summary: z.string().trim().min(1).max(240).optional(),
53+
})
54+
55+
export type ManageElementCreateProposal = z.infer<
56+
typeof manageElementCreateProposalSchema
57+
>

apps/chat/src/services/manageProposals.ts

Lines changed: 11 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,20 @@
11
import hashes from '@klicker-uzh/graphql/dist/client.json'
22
import { verifyJWT } from '@klicker-uzh/util'
33
import { z } from 'zod'
4+
import {
5+
choicesProposalPayloadSchema,
6+
freeTextProposalPayloadSchema,
7+
manageElementCreateProposalSchema,
8+
type ManageElementCreateProposal,
9+
} from './manageProposalSchema'
10+
11+
export {
12+
manageElementCreateProposalSchema,
13+
type ManageElementCreateProposal,
14+
} from './manageProposalSchema'
415

516
const MANAGE_PROPOSAL_PURPOSE = 'manage-assistant-proposal'
617

7-
const proposalChoiceSchema = z.object({
8-
correct: z.boolean(),
9-
feedback: z.string().trim().min(1).max(500).optional(),
10-
ix: z.number().int().min(0).optional(),
11-
value: z.string().trim().min(1).max(240),
12-
})
13-
14-
const baseProposalPayloadSchema = z.object({
15-
basePoints: z.boolean().default(true),
16-
content: z.string().trim().min(1).max(4000),
17-
explanation: z.string().trim().min(1).max(2000).optional(),
18-
name: z.string().trim().min(1).max(160),
19-
pointsMultiplier: z.number().int().min(1).max(100).default(1),
20-
status: z.literal('DRAFT'),
21-
tags: z.array(z.string().trim().min(1).max(60)).max(8).default([]),
22-
})
23-
24-
const choicesProposalPayloadSchema = baseProposalPayloadSchema.extend({
25-
options: z.object({
26-
choices: z.array(proposalChoiceSchema).min(2).max(8),
27-
displayMode: z.literal('LIST').default('LIST'),
28-
hasAnswerFeedbacks: z.boolean().default(false),
29-
hasSampleSolution: z.boolean().default(true),
30-
}),
31-
type: z.enum(['SC', 'MC']),
32-
})
33-
34-
const freeTextProposalPayloadSchema = baseProposalPayloadSchema.extend({
35-
options: z.object({
36-
hasSampleSolution: z.boolean().default(false),
37-
restrictions: z.object({
38-
maxLength: z.number().int().positive().optional(),
39-
}),
40-
solutions: z.array(z.string().trim().min(1).max(500)).optional(),
41-
}),
42-
type: z.literal('FREE_TEXT'),
43-
})
44-
45-
const manageElementCreateProposalSchema = z.object({
46-
kind: z.literal('element.create.proposal'),
47-
payload: z.union([
48-
choicesProposalPayloadSchema,
49-
freeTextProposalPayloadSchema,
50-
]),
51-
requiresConfirmation: z.literal(true),
52-
summary: z.string().trim().min(1).max(240).optional(),
53-
})
54-
5518
const manageProposalTokenSchema = z.object({
5619
kind: z.literal('element.create.proposal'),
5720
payload: z.union([
@@ -70,10 +33,6 @@ const confirmedElementSchema = z.object({
7033
type: z.string(),
7134
})
7235

73-
export type ManageElementCreateProposal = z.infer<
74-
typeof manageElementCreateProposalSchema
75-
>
76-
7736
type OperationName = 'ManipulateChoicesQuestion' | 'ManipulateFreeTextQuestion'
7837

7938
function getPersistedHash(operationName: OperationName): string {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {
2+
ElementDisplayMode,
3+
ElementInstanceType,
4+
ElementType,
5+
type ElementInstance,
6+
} from '@klicker-uzh/graphql/dist/ops'
7+
import {
8+
manageElementCreateProposalSchema,
9+
type ManageElementCreateProposal,
10+
} from './manageProposalSchema'
11+
12+
export type ManageProposalPayload = ManageElementCreateProposal['payload']
13+
14+
// Validate an arbitrary tool-result envelope against the signed proposal
15+
// schema before it is ever mapped to a renderable element preview. Returns
16+
// null on any mismatch so callers can fall back to the raw JSON view instead
17+
// of risking a crashed preview card.
18+
export function parseManageProposalPayload(
19+
value: unknown
20+
): ManageProposalPayload | null {
21+
const parsed = manageElementCreateProposalSchema.safeParse(value)
22+
return parsed.success ? parsed.data.payload : null
23+
}
24+
25+
// Map a validated proposal payload to the same artificial ElementInstance
26+
// shape used elsewhere to preview not-yet-persisted questions (see
27+
// useArtificialElementInstance). The top-level ElementInstance.options is
28+
// never read by StudentElement, so it is intentionally omitted.
29+
export function proposalPayloadToElementInstance(
30+
payload: ManageProposalPayload
31+
): ElementInstance {
32+
const elementType = payload.type as ElementType
33+
34+
const shared = {
35+
basePoints: payload.basePoints,
36+
content: payload.content,
37+
elementId: 0,
38+
explanation: payload.explanation,
39+
id: '0',
40+
name: payload.name,
41+
pointsMultiplier: payload.pointsMultiplier,
42+
type: elementType,
43+
}
44+
45+
const elementData =
46+
payload.type === 'FREE_TEXT'
47+
? {
48+
__typename: 'FreeTextElementData' as const,
49+
...shared,
50+
options: {
51+
hasSampleSolution: payload.options.hasSampleSolution,
52+
restrictions: payload.options.restrictions,
53+
},
54+
}
55+
: {
56+
__typename: 'ChoicesElementData' as const,
57+
...shared,
58+
options: {
59+
choices: payload.options.choices.map((choice, ix) => ({
60+
...choice,
61+
ix: choice.ix ?? ix,
62+
})),
63+
displayMode: ElementDisplayMode.List,
64+
hasAnswerFeedbacks: payload.options.hasAnswerFeedbacks,
65+
hasSampleSolution: payload.options.hasSampleSolution,
66+
},
67+
}
68+
69+
return {
70+
elementData,
71+
elementType,
72+
id: 0,
73+
type: ElementInstanceType.LiveQuiz,
74+
} as ElementInstance
75+
}

apps/chat/test/manage-proposal-card.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1-
import { describe, expect, test } from 'vitest'
1+
import { describe, expect, test, vi } from 'vitest'
22
import {
33
getManageProposalResult,
44
isManageProposalResult,
55
} from '../src/components/manage-proposal-card'
66

7+
// manage-proposal-card.tsx renders the preview through shared-components,
8+
// which pulls in @uzh-bf/design-system's CSS entrypoint. That CSS import
9+
// cannot be resolved by Vitest's node-environment module graph (Vite's CSS
10+
// handling for externalized deps only works under the vmThreads pool), so
11+
// mock the preview module before it is ever evaluated. These tests only
12+
// exercise the pure envelope-parsing helpers below, not rendering.
13+
vi.mock('../src/components/manage-proposal-preview', () => ({
14+
ManageProposalPreview: () => null,
15+
}))
16+
717
const proposalEnvelope = {
818
kind: 'element.create.proposal',
919
proposalToken: 'signed-token',

0 commit comments

Comments
 (0)