사례개념화 워크시트 저장 추가

This commit is contained in:
Yun Chan 2026-06-27 19:27:34 +09:00
parent 7f4a2d90c7
commit bd389a97cc
15 changed files with 614 additions and 42 deletions

View file

@ -698,6 +698,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/sessions/{session_id}/review/worksheet": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Save Session Review Worksheet
* @description Persist the learner's edited case formulation worksheet for this session.
*/
put: operations["save_session_review_worksheet_sessions__session_id__review_worksheet_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/sessions/{session_id}/stream": {
parameters: {
query?: never;
@ -1594,14 +1614,23 @@ export interface components {
generatedBy: string;
/** Limitations */
limitations?: string[];
/** Savedat */
savedAt?: string | null;
/** Sections */
sections?: components["schemas"]["ReviewWorksheetSection"][];
sections?: components["schemas"]["ReviewWorksheetSection-Output"][];
/**
* Status
* @default empty
* @enum {string}
*/
status: "empty" | "draft_from_transcript";
status: "empty" | "draft_from_transcript" | "saved_by_learner";
};
/** ReviewCaseWorksheetSaveRequest */
ReviewCaseWorksheetSaveRequest: {
/** Limitations */
limitations?: string[];
/** Sections */
sections?: components["schemas"]["ReviewWorksheetSection-Input"][];
};
/** ReviewClient */
ReviewClient: {
@ -1738,7 +1767,16 @@ export interface components {
value?: string | null;
};
/** ReviewWorksheetSection */
ReviewWorksheetSection: {
"ReviewWorksheetSection-Input": {
/** Items */
items?: components["schemas"]["ReviewWorksheetItem"][];
/** Key */
key: string;
/** Title */
title: string;
};
/** ReviewWorksheetSection */
"ReviewWorksheetSection-Output": {
/** Items */
items?: components["schemas"]["ReviewWorksheetItem"][];
/** Key */
@ -3546,6 +3584,44 @@ export interface operations {
};
};
};
save_session_review_worksheet_sessions__session_id__review_worksheet_put: {
parameters: {
query?: never;
header?: never;
path: {
session_id: string;
};
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["ReviewCaseWorksheetSaveRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReviewCaseWorksheet"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
stream_turn_sessions__session_id__stream_post: {
parameters: {
query?: never;

View file

@ -209,8 +209,9 @@ export type ReviewPoint = ApiSchema<"ReviewPoint">;
export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">;
export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">;
export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection">;
export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection-Output">;
export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">;
export type ReviewCaseWorksheetSaveRequest = ApiSchema<"ReviewCaseWorksheetSaveRequest">;
export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">;
/* =====================================================================
@ -411,6 +412,11 @@ export const sessionApi = {
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
review: (sessionId: string) =>
api.get<SessionReviewResponse>(`/sessions/${encodeURIComponent(sessionId)}/review`),
saveWorksheet: (sessionId: string, payload: ReviewCaseWorksheetSaveRequest) =>
api.put<ReviewCaseWorksheet>(
`/sessions/${encodeURIComponent(sessionId)}/review/worksheet`,
payload,
),
stream: openSessionStream,
};

View file

@ -18,6 +18,7 @@ import {
type ReviewPoint,
type ReviewTechnique,
type ReviewTurn,
type ReviewWorksheetSection,
type SessionReviewResponse,
} from "../lib/api";
import { ValenceChart } from "./session-review/ValenceChart";
@ -130,21 +131,102 @@ function confidenceLabel(confidence: "none" | "low" | "medium") {
return "빈칸";
}
function cloneWorksheetSections(sections: ReviewWorksheetSection[]): ReviewWorksheetSection[] {
return sections.map((section) => ({
...section,
items: (section.items ?? []).map((item) => ({
...item,
evidence: [...(item.evidence ?? [])],
})),
}));
}
function CaseWorksheetCard({
sessionId,
worksheet,
onJump,
onSaved,
}: {
sessionId: string;
worksheet: ReviewCaseWorksheet | null | undefined;
onJump: (id: string) => void;
onSaved: (worksheet: ReviewCaseWorksheet) => void;
}) {
const sections = worksheet?.sections ?? [];
const limitations = worksheet?.limitations ?? [];
const [draftSections, setDraftSections] = useState<ReviewWorksheetSection[]>(() =>
cloneWorksheetSections(sections),
);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const isSaved = worksheet?.status === "saved_by_learner";
useEffect(() => {
setDraftSections(cloneWorksheetSections(sections));
setDirty(false);
setSaveError(null);
}, [worksheet]);
const updateValue = (sectionKey: string, itemKey: string, value: string) => {
setDraftSections((current) =>
current.map((section) =>
section.key !== sectionKey
? section
: {
...section,
items: (section.items ?? []).map((item) =>
item.key !== itemKey
? item
: {
...item,
value,
confidence: value.trim() ? item.confidence === "none" ? "low" : item.confidence : "none",
emptyReason: value.trim() ? null : item.emptyReason,
},
),
},
),
);
setDirty(true);
};
const saveWorksheet = async () => {
setSaving(true);
setSaveError(null);
try {
const saved = await sessionApi.saveWorksheet(sessionId, {
sections: draftSections,
limitations,
});
onSaved(saved);
setDirty(false);
} catch {
setSaveError("워크시트를 저장하지 못했습니다.");
} finally {
setSaving(false);
}
};
return (
<Card className="sr-card sr-card--side sr-card--worksheet">
<Kicker> </Kicker>
<div className="sr-ws-toolbar">
<div>
<Kicker> </Kicker>
<span className="sr-ws-state">{isSaved ? "저장된 학습자 제출본" : "축어록 기반 자동 초안"}</span>
</div>
<Button
variant="secondary"
size="sm"
disabled={!sections.length || !dirty || saving}
onClick={saveWorksheet}
>
{saving ? "저장 중" : dirty ? "저장" : "저장됨"}
</Button>
</div>
<div className="sr-worksheet">
{sections.length > 0 ? (
sections.map((section) => (
{draftSections.length > 0 ? (
draftSections.map((section) => (
<section className="sr-ws-section" key={section.key}>
<h3>{section.title}</h3>
<div className="sr-ws-items">
@ -158,7 +240,15 @@ function CaseWorksheetCard({
{confidenceLabel(item.confidence)}
</span>
</div>
<p>{item.value || item.emptyReason || "근거 대기"}</p>
<textarea
className="sr-ws-input"
value={item.value ?? ""}
placeholder={item.emptyReason || "근거 대기"}
rows={3}
onChange={(event) =>
updateValue(section.key, item.key, event.currentTarget.value)
}
/>
{evidence ? (
<button
type="button"
@ -181,6 +271,7 @@ function CaseWorksheetCard({
/>
)}
</div>
{saveError ? <div className="sr-ws-save-error">{saveError}</div> : null}
{limitations.length > 0 ? (
<div className="sr-ws-limitations">
{limitations.slice(0, 2).map((item) => (
@ -201,6 +292,10 @@ export default function SessionReview() {
const [activeTurn, setActiveTurn] = useState<string | null>(null);
const turnRefs = useRef<Record<string, HTMLDivElement | null>>({});
const handleWorksheetSaved = (worksheet: ReviewCaseWorksheet) => {
setData((current) => (current ? { ...current, caseWorksheet: worksheet } : current));
};
useEffect(() => {
let alive = true;
if (!sessionId) {
@ -613,7 +708,12 @@ export default function SessionReview() {
) : null}
</Card>
<CaseWorksheetCard worksheet={data.caseWorksheet} onJump={jumpToTurn} />
<CaseWorksheetCard
sessionId={data.session_id}
worksheet={data.caseWorksheet}
onJump={jumpToTurn}
onSaved={handleWorksheetSaved}
/>
<div className={`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`}>
<div className="sr-feedback__kicker">

View file

@ -720,6 +720,23 @@
padding-right: 4px;
scrollbar-gutter: stable;
}
.sr-ws-toolbar {
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
}
.sr-ws-toolbar > div {
min-width: 0;
display: grid;
gap: 4px;
}
.sr-ws-state {
color: var(--text-muted);
font-size: 11px;
line-height: 1.35;
}
.sr-ws-section {
min-width: 0;
display: grid;
@ -780,13 +797,27 @@
color: var(--text-muted);
background: var(--paper-2);
}
.sr-ws-item p {
margin: 0;
.sr-ws-input {
width: 100%;
min-height: 78px;
resize: vertical;
padding: 8px 9px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-body);
font-family: var(--font-sans);
font-size: var(--fs-xs);
line-height: 1.55;
overflow-wrap: anywhere;
}
.sr-ws-input:focus {
outline: 2px solid var(--accent-tint);
border-color: var(--accent);
}
.sr-ws-input::placeholder {
color: var(--text-muted);
}
.sr-ws-evidence {
min-width: 0;
width: 100%;
@ -805,6 +836,12 @@
.sr-ws-evidence:hover {
text-decoration: underline;
}
.sr-ws-save-error {
margin-top: var(--sp-3);
color: var(--crit-text);
font-size: 12px;
line-height: 1.45;
}
.sr-ws-limitations {
display: grid;
gap: 5px;
@ -1106,6 +1143,13 @@
.sr-worksheet {
max-height: 460px;
}
.sr-ws-toolbar {
align-items: stretch;
flex-direction: column;
}
.sr-ws-toolbar .vg-btn {
width: 100%;
}
.sr-chart__plot {
height: 156px;
}