chih.yikuan Claude Opus 4.6 (1M context) commited on
Commit
5722fbf
·
1 Parent(s): 40aa09c

Add final prompt preview tab with real student data substitution

Browse files

DataPreviewModal now opens on "最終提示詞" tab first, showing the actual
filled prompt for a selected student with {wrong_questions}, {wrong_answers}
etc. replaced by real data. Users can switch students via dropdown to see
each student's specific prompt. Textareas are resizable. Backend adds
/preview-student-prompt endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

chatkit/backend/app/main.py CHANGED
@@ -31,7 +31,7 @@ from .database import (
31
  )
32
  from .auth import get_current_user, register_user, login_user
33
  from .file_processor import process_uploaded_files
34
- from .report_generator import generate_student_report, get_default_prompt, get_student_names
35
 
36
  STATIC_DIR = Path(__file__).parent.parent / "static"
37
 
@@ -228,11 +228,45 @@ async def api_delete_prompt(prompt_id: int, user=Depends(get_current_user)):
228
  # Report Generation Endpoints
229
  # =============================================================================
230
 
 
 
 
 
231
  class GenerateStudentReportRequest(BaseModel):
232
  student_index: int
233
  model: str = "gpt-5.4"
234
 
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  @app.get("/api/sessions/{session_id}/students")
237
  async def api_get_students(session_id: int, user=Depends(get_current_user)):
238
  """Return the list of student names from parsed student_answers data."""
 
31
  )
32
  from .auth import get_current_user, register_user, login_user
33
  from .file_processor import process_uploaded_files
34
+ from .report_generator import generate_student_report, get_default_prompt, get_student_names, build_student_prompt
35
 
36
  STATIC_DIR = Path(__file__).parent.parent / "static"
37
 
 
228
  # Report Generation Endpoints
229
  # =============================================================================
230
 
231
+ class PreviewStudentPromptRequest(BaseModel):
232
+ student_index: int
233
+
234
+
235
  class GenerateStudentReportRequest(BaseModel):
236
  student_index: int
237
  model: str = "gpt-5.4"
238
 
239
 
240
+ @app.post("/api/sessions/{session_id}/preview-student-prompt")
241
+ async def api_preview_student_prompt(
242
+ session_id: int, body: PreviewStudentPromptRequest, user=Depends(get_current_user)
243
+ ):
244
+ """Preview the filled prompt for a single student (with wrong answers substituted)."""
245
+ session = await get_session(session_id)
246
+ if not session or session["user_id"] != user["id"]:
247
+ raise HTTPException(status_code=404, detail="Session not found")
248
+
249
+ data = await get_parsed_data(session_id)
250
+ questions = {}
251
+ student_answers = {}
252
+ teacher_answers = {}
253
+ for item in data:
254
+ if item["data_type"] == "questions":
255
+ questions = item["structured_data"]
256
+ elif item["data_type"] == "student_answers":
257
+ student_answers = item["structured_data"]
258
+ elif item["data_type"] == "teacher_answers":
259
+ teacher_answers = item["structured_data"]
260
+
261
+ students = student_answers.get("students", [])
262
+ if body.student_index < 0 or body.student_index >= len(students):
263
+ raise HTTPException(status_code=400, detail=f"Invalid student index: {body.student_index}")
264
+
265
+ student = students[body.student_index]
266
+ result = build_student_prompt(student, questions, teacher_answers)
267
+ return result
268
+
269
+
270
  @app.get("/api/sessions/{session_id}/students")
271
  async def api_get_students(session_id: int, user=Depends(get_current_user)):
272
  """Return the list of student names from parsed student_answers data."""
chatkit/backend/app/report_generator.py CHANGED
@@ -177,6 +177,38 @@ def get_default_prompt() -> str:
177
  return STUDENT_REPORT_PROMPT
178
 
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  def get_student_names(student_answers_data: dict) -> list[dict]:
181
  """Extract student names/ids from parsed student answers data."""
182
  students = student_answers_data.get("students", [])
 
177
  return STUDENT_REPORT_PROMPT
178
 
179
 
180
+ def build_student_prompt(
181
+ student: dict,
182
+ questions_data: dict,
183
+ teacher_answers_data: dict,
184
+ ) -> dict:
185
+ """Build the filled prompt for a student (for preview).
186
+
187
+ Returns dict with filled_prompt, student_name, total_questions, wrong_count.
188
+ """
189
+ total_questions = len(questions_data.get("questions", []))
190
+ wrong_questions, wrong_answers, relevant_teacher = filter_student_wrong_answers(
191
+ student, questions_data, teacher_answers_data,
192
+ )
193
+ student_name = student.get("name", "Unknown")
194
+
195
+ filled = STUDENT_REPORT_PROMPT.format(
196
+ student_name=student_name,
197
+ total_questions=total_questions,
198
+ wrong_count=len(wrong_answers),
199
+ wrong_questions=json.dumps({"questions": wrong_questions}, ensure_ascii=False, indent=2),
200
+ wrong_answers=json.dumps(wrong_answers, ensure_ascii=False, indent=2),
201
+ teacher_answers=json.dumps(relevant_teacher, ensure_ascii=False, indent=2),
202
+ )
203
+
204
+ return {
205
+ "filled_prompt": filled,
206
+ "student_name": student_name,
207
+ "total_questions": total_questions,
208
+ "wrong_count": len(wrong_answers),
209
+ }
210
+
211
+
212
  def get_student_names(student_answers_data: dict) -> list[dict]:
213
  """Extract student names/ids from parsed student answers data."""
214
  students = student_answers_data.get("students", [])
chatkit/frontend/src/App.tsx CHANGED
@@ -272,6 +272,7 @@ ${doneReports
272
  onGenerate={handleGenerateReports}
273
  isGenerating={isGenerating}
274
  selectedCount={selectedIndices.length}
 
275
  parsedData={parsedData}
276
  onParsedDataUpdate={handleParsedDataUpdate}
277
  />
 
272
  onGenerate={handleGenerateReports}
273
  isGenerating={isGenerating}
274
  selectedCount={selectedIndices.length}
275
+ selectedIndices={selectedIndices}
276
  parsedData={parsedData}
277
  onParsedDataUpdate={handleParsedDataUpdate}
278
  />
chatkit/frontend/src/components/step2/DataPreviewModal.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { useState, useEffect } from "react";
 
2
 
3
  interface DataPreviewModalProps {
4
  parsedData: Record<string, unknown>;
@@ -6,44 +7,108 @@ interface DataPreviewModalProps {
6
  onGenerate: () => void;
7
  onClose: () => void;
8
  isGenerating: boolean;
 
 
9
  }
10
 
11
- type TabKey = "questions" | "student_answers" | "teacher_answers";
12
 
13
- const TABS: { key: TabKey; label: string; icon: string }[] = [
14
  { key: "questions", label: "考試題目", icon: "📝" },
15
  { key: "student_answers", label: "學生答案", icon: "👨‍🎓" },
16
  { key: "teacher_answers", label: "標準答案", icon: "✅" },
17
  ];
18
 
 
 
 
 
 
19
  export function DataPreviewModal({
20
  parsedData,
21
  onParsedDataUpdate,
22
  onGenerate,
23
  onClose,
24
  isGenerating,
 
 
25
  }: DataPreviewModalProps) {
26
- const [activeTab, setActiveTab] = useState<TabKey>("questions");
27
- const [editTexts, setEditTexts] = useState<Record<TabKey, string>>({
28
  questions: "",
29
  student_answers: "",
30
  teacher_answers: "",
31
  });
32
  const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  // Initialize edit texts from parsedData
35
  useEffect(() => {
36
- const texts = {} as Record<TabKey, string>;
37
- for (const tab of TABS) {
38
  const data = parsedData[tab.key];
39
  texts[tab.key] = data ? JSON.stringify(data, null, 2) : "";
40
  }
41
  setEditTexts(texts);
42
  }, []);
43
 
44
- const handleTextChange = (key: TabKey, value: string) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  setEditTexts((prev) => ({ ...prev, [key]: value }));
46
- // Clear error on edit
47
  setParseErrors((prev) => {
48
  const next = { ...prev };
49
  delete next[key];
@@ -51,7 +116,7 @@ export function DataPreviewModal({
51
  });
52
  };
53
 
54
- const handleSave = (key: TabKey) => {
55
  try {
56
  const parsed = JSON.parse(editTexts[key]);
57
  onParsedDataUpdate(key, parsed);
@@ -66,9 +131,8 @@ export function DataPreviewModal({
66
  };
67
 
68
  const handleGenerate = () => {
69
- // Try saving all tabs first
70
  let hasError = false;
71
- for (const tab of TABS) {
72
  if (!editTexts[tab.key].trim()) continue;
73
  try {
74
  const parsed = JSON.parse(editTexts[tab.key]);
@@ -83,7 +147,7 @@ export function DataPreviewModal({
83
  if (!hasError) onGenerate();
84
  };
85
 
86
- const getItemCount = (key: TabKey): number => {
87
  try {
88
  const data = JSON.parse(editTexts[key]);
89
  if (key === "questions") return data?.questions?.length ?? 0;
@@ -118,7 +182,21 @@ export function DataPreviewModal({
118
 
119
  {/* Tabs */}
120
  <div className="flex border-b border-[var(--color-border)]">
121
- {TABS.map((tab) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  const count = getItemCount(tab.key);
123
  const hasError = !!parseErrors[tab.key];
124
  return (
@@ -143,33 +221,83 @@ export function DataPreviewModal({
143
  })}
144
  </div>
145
 
146
- {/* Editor */}
147
  <div className="flex-1 overflow-hidden flex flex-col p-5 min-h-0">
148
- <div className="flex items-center justify-between mb-3">
149
- <p className="text-sm text-[var(--color-text-muted)]">
150
- 可直接編輯下方 JSON 資料,修改後點擊「套用修改」儲存
151
- </p>
152
- <button
153
- onClick={() => handleSave(activeTab)}
154
- className="btn btn-outline text-sm !py-1.5 !px-3"
155
- >
156
- 套用修改
157
- </button>
158
- </div>
159
-
160
- {parseErrors[activeTab] && (
161
- <div className="mb-3 px-3 py-2 rounded-lg bg-red-500/10 text-red-500 text-sm">
162
- {parseErrors[activeTab]}
163
- </div>
164
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
- <textarea
167
- value={editTexts[activeTab]}
168
- onChange={(e) => handleTextChange(activeTab, e.target.value)}
169
- className="input !rounded-xl font-mono text-sm leading-relaxed resize-none flex-1"
170
- placeholder={`尚未上傳${TABS.find((t) => t.key === activeTab)?.label ?? ""}資料`}
171
- spellCheck={false}
172
- />
 
 
 
 
 
 
 
 
173
  </div>
174
 
175
  {/* Footer */}
 
1
  import { useState, useEffect } from "react";
2
+ import { apiPost } from "../../lib/api";
3
 
4
  interface DataPreviewModalProps {
5
  parsedData: Record<string, unknown>;
 
7
  onGenerate: () => void;
8
  onClose: () => void;
9
  isGenerating: boolean;
10
+ sessionId: number;
11
+ selectedIndices: number[];
12
  }
13
 
14
+ type TabKey = "questions" | "student_answers" | "teacher_answers" | "final_prompt";
15
 
16
+ const DATA_TABS: { key: Exclude<TabKey, "final_prompt">; label: string; icon: string }[] = [
17
  { key: "questions", label: "考試題目", icon: "📝" },
18
  { key: "student_answers", label: "學生答案", icon: "👨‍🎓" },
19
  { key: "teacher_answers", label: "標準答案", icon: "✅" },
20
  ];
21
 
22
+ interface StudentInfo {
23
+ index: number;
24
+ name: string;
25
+ }
26
+
27
  export function DataPreviewModal({
28
  parsedData,
29
  onParsedDataUpdate,
30
  onGenerate,
31
  onClose,
32
  isGenerating,
33
+ sessionId,
34
+ selectedIndices,
35
  }: DataPreviewModalProps) {
36
+ const [activeTab, setActiveTab] = useState<TabKey>("final_prompt");
37
+ const [editTexts, setEditTexts] = useState<Record<string, string>>({
38
  questions: "",
39
  student_answers: "",
40
  teacher_answers: "",
41
  });
42
  const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
43
 
44
+ // Final prompt preview state
45
+ const [previewStudentIdx, setPreviewStudentIdx] = useState<number>(
46
+ selectedIndices.length > 0 ? selectedIndices[0] : 0
47
+ );
48
+ const [filledPrompt, setFilledPrompt] = useState("");
49
+ const [promptLoading, setPromptLoading] = useState(false);
50
+ const [promptMeta, setPromptMeta] = useState<{
51
+ student_name: string;
52
+ total_questions: number;
53
+ wrong_count: number;
54
+ } | null>(null);
55
+
56
+ // Build students list from parsedData
57
+ const students: StudentInfo[] = (() => {
58
+ const sa = parsedData.student_answers as { students?: { name?: string }[] } | undefined;
59
+ return (sa?.students || []).map((s, i) => ({
60
+ index: i,
61
+ name: s.name || `Student ${i + 1}`,
62
+ }));
63
+ })();
64
+
65
+ const selectedStudents = students.filter((s) => selectedIndices.includes(s.index));
66
+
67
  // Initialize edit texts from parsedData
68
  useEffect(() => {
69
+ const texts: Record<string, string> = {};
70
+ for (const tab of DATA_TABS) {
71
  const data = parsedData[tab.key];
72
  texts[tab.key] = data ? JSON.stringify(data, null, 2) : "";
73
  }
74
  setEditTexts(texts);
75
  }, []);
76
 
77
+ // Load filled prompt when tab is active or student changes
78
+ useEffect(() => {
79
+ if (activeTab !== "final_prompt") return;
80
+ loadFilledPrompt(previewStudentIdx);
81
+ }, [activeTab, previewStudentIdx]);
82
+
83
+ const loadFilledPrompt = async (studentIndex: number) => {
84
+ setPromptLoading(true);
85
+ setFilledPrompt("");
86
+ setPromptMeta(null);
87
+ try {
88
+ const res = await apiPost<{
89
+ filled_prompt: string;
90
+ student_name: string;
91
+ total_questions: number;
92
+ wrong_count: number;
93
+ }>(`/api/sessions/${sessionId}/preview-student-prompt`, {
94
+ student_index: studentIndex,
95
+ });
96
+ setFilledPrompt(res.filled_prompt);
97
+ setPromptMeta({
98
+ student_name: res.student_name,
99
+ total_questions: res.total_questions,
100
+ wrong_count: res.wrong_count,
101
+ });
102
+ } catch (err) {
103
+ setFilledPrompt(
104
+ `Error: ${err instanceof Error ? err.message : "Failed to load preview"}`
105
+ );
106
+ }
107
+ setPromptLoading(false);
108
+ };
109
+
110
+ const handleTextChange = (key: string, value: string) => {
111
  setEditTexts((prev) => ({ ...prev, [key]: value }));
 
112
  setParseErrors((prev) => {
113
  const next = { ...prev };
114
  delete next[key];
 
116
  });
117
  };
118
 
119
+ const handleSave = (key: string) => {
120
  try {
121
  const parsed = JSON.parse(editTexts[key]);
122
  onParsedDataUpdate(key, parsed);
 
131
  };
132
 
133
  const handleGenerate = () => {
 
134
  let hasError = false;
135
+ for (const tab of DATA_TABS) {
136
  if (!editTexts[tab.key].trim()) continue;
137
  try {
138
  const parsed = JSON.parse(editTexts[tab.key]);
 
147
  if (!hasError) onGenerate();
148
  };
149
 
150
+ const getItemCount = (key: string): number => {
151
  try {
152
  const data = JSON.parse(editTexts[key]);
153
  if (key === "questions") return data?.questions?.length ?? 0;
 
182
 
183
  {/* Tabs */}
184
  <div className="flex border-b border-[var(--color-border)]">
185
+ {/* Final prompt tab first */}
186
+ <button
187
+ onClick={() => setActiveTab("final_prompt")}
188
+ className={`px-4 py-3 text-sm font-semibold transition-colors relative ${
189
+ activeTab === "final_prompt"
190
+ ? "text-[var(--color-accent)] bg-[var(--color-accent)]/5"
191
+ : "text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface)]"
192
+ }`}
193
+ >
194
+ <span>🔍 最終提示詞</span>
195
+ {activeTab === "final_prompt" && (
196
+ <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-accent)]" />
197
+ )}
198
+ </button>
199
+ {DATA_TABS.map((tab) => {
200
  const count = getItemCount(tab.key);
201
  const hasError = !!parseErrors[tab.key];
202
  return (
 
221
  })}
222
  </div>
223
 
224
+ {/* Content */}
225
  <div className="flex-1 overflow-hidden flex flex-col p-5 min-h-0">
226
+ {activeTab === "final_prompt" ? (
227
+ /* Final prompt preview */
228
+ <>
229
+ <div className="flex items-center gap-3 mb-3 flex-wrap">
230
+ <label className="text-sm font-semibold text-[var(--color-text)]">
231
+ 預覽學生:
232
+ </label>
233
+ <select
234
+ value={previewStudentIdx}
235
+ onChange={(e) => setPreviewStudentIdx(Number(e.target.value))}
236
+ className="input !w-auto !py-1.5 text-sm"
237
+ >
238
+ {selectedStudents.map((s) => (
239
+ <option key={s.index} value={s.index}>
240
+ {s.name}
241
+ </option>
242
+ ))}
243
+ </select>
244
+ {promptMeta && (
245
+ <div className="flex gap-2 text-xs">
246
+ <span className="badge badge-success">
247
+ 總題數 {promptMeta.total_questions}
248
+ </span>
249
+ <span className="badge badge-warning">
250
+ 答錯 {promptMeta.wrong_count}
251
+ </span>
252
+ </div>
253
+ )}
254
+ </div>
255
+ <p className="text-xs text-[var(--color-text-muted)] mb-3">
256
+ 以下是實際送給 LLM 的完整提示詞,變數已替換為該學生的錯題資料
257
+ </p>
258
+ {promptLoading ? (
259
+ <div className="flex-1 flex items-center justify-center">
260
+ <div className="w-6 h-6 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
261
+ </div>
262
+ ) : (
263
+ <textarea
264
+ value={filledPrompt}
265
+ readOnly
266
+ className="input !rounded-xl font-mono text-sm leading-relaxed resize-y flex-1"
267
+ spellCheck={false}
268
+ />
269
+ )}
270
+ </>
271
+ ) : (
272
+ /* Data editing tabs */
273
+ <>
274
+ <div className="flex items-center justify-between mb-3">
275
+ <p className="text-sm text-[var(--color-text-muted)]">
276
+ 可直接編輯下方 JSON 資料,修改後點擊「套用修改」儲存
277
+ </p>
278
+ <button
279
+ onClick={() => handleSave(activeTab)}
280
+ className="btn btn-outline text-sm !py-1.5 !px-3"
281
+ >
282
+ 套用修改
283
+ </button>
284
+ </div>
285
 
286
+ {parseErrors[activeTab] && (
287
+ <div className="mb-3 px-3 py-2 rounded-lg bg-red-500/10 text-red-500 text-sm">
288
+ {parseErrors[activeTab]}
289
+ </div>
290
+ )}
291
+
292
+ <textarea
293
+ value={editTexts[activeTab] || ""}
294
+ onChange={(e) => handleTextChange(activeTab, e.target.value)}
295
+ className="input !rounded-xl font-mono text-sm leading-relaxed resize-y flex-1"
296
+ placeholder={`尚未上傳${DATA_TABS.find((t) => t.key === activeTab)?.label ?? ""}資料`}
297
+ spellCheck={false}
298
+ />
299
+ </>
300
+ )}
301
  </div>
302
 
303
  {/* Footer */}
chatkit/frontend/src/components/step2/PromptEditor.tsx CHANGED
@@ -18,6 +18,7 @@ interface PromptEditorProps {
18
  onGenerate: (model: string) => void;
19
  isGenerating: boolean;
20
  selectedCount: number;
 
21
  parsedData: Record<string, unknown>;
22
  onParsedDataUpdate: (dataType: string, data: unknown) => void;
23
  }
@@ -29,6 +30,7 @@ export function PromptEditor({
29
  onGenerate,
30
  isGenerating,
31
  selectedCount,
 
32
  parsedData,
33
  onParsedDataUpdate,
34
  }: PromptEditorProps) {
@@ -196,6 +198,8 @@ export function PromptEditor({
196
  }}
197
  onClose={() => setShowPreview(false)}
198
  isGenerating={isGenerating}
 
 
199
  />
200
  )}
201
  </div>
 
18
  onGenerate: (model: string) => void;
19
  isGenerating: boolean;
20
  selectedCount: number;
21
+ selectedIndices: number[];
22
  parsedData: Record<string, unknown>;
23
  onParsedDataUpdate: (dataType: string, data: unknown) => void;
24
  }
 
30
  onGenerate,
31
  isGenerating,
32
  selectedCount,
33
+ selectedIndices,
34
  parsedData,
35
  onParsedDataUpdate,
36
  }: PromptEditorProps) {
 
198
  }}
199
  onClose={() => setShowPreview(false)}
200
  isGenerating={isGenerating}
201
+ sessionId={sessionId}
202
+ selectedIndices={selectedIndices}
203
  />
204
  )}
205
  </div>