사례개념화 워크시트 저장 추가
This commit is contained in:
parent
7f4a2d90c7
commit
bd389a97cc
15 changed files with 614 additions and 42 deletions
|
|
@ -204,10 +204,16 @@ class ReviewWorksheetSection(BaseModel):
|
|||
|
||||
|
||||
class ReviewCaseWorksheet(BaseModel):
|
||||
status: Literal["empty", "draft_from_transcript"] = "empty"
|
||||
status: Literal["empty", "draft_from_transcript", "saved_by_learner"] = "empty"
|
||||
generatedBy: str = "rule-based transcript extractor"
|
||||
sections: list[ReviewWorksheetSection] = Field(default_factory=list)
|
||||
limitations: list[str] = Field(default_factory=list)
|
||||
savedAt: Optional[str] = None
|
||||
|
||||
|
||||
class ReviewCaseWorksheetSaveRequest(BaseModel):
|
||||
sections: list[ReviewWorksheetSection] = Field(default_factory=list)
|
||||
limitations: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SessionReviewResponse(BaseModel):
|
||||
|
|
@ -851,6 +857,16 @@ def _case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet:
|
|||
)
|
||||
|
||||
|
||||
def _saved_case_worksheet_from_payload(payload: dict[str, object] | None) -> ReviewCaseWorksheet | None:
|
||||
if not payload:
|
||||
return None
|
||||
try:
|
||||
worksheet = ReviewCaseWorksheet.model_validate(payload)
|
||||
except Exception:
|
||||
return None
|
||||
return worksheet.model_copy(update={"status": "saved_by_learner"})
|
||||
|
||||
|
||||
def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]:
|
||||
if not record:
|
||||
return {}
|
||||
|
|
@ -1105,7 +1121,7 @@ def _session_detail(
|
|||
SessionDetailTurn(
|
||||
turn_seq=turn.turn_seq,
|
||||
speaker="learner" if turn.speaker == "counselor" else "client",
|
||||
stage=turn.stage,
|
||||
stage=_stage_label(turn.stage),
|
||||
text=turn.text_masked,
|
||||
created_at=_iso(turn.created_at) or "",
|
||||
)
|
||||
|
|
@ -1249,7 +1265,7 @@ async def get_session_review(
|
|||
client_initial = client_name[:1] or "내"
|
||||
|
||||
reached_phase = _stage_label(sess.state.stage)
|
||||
stage_labels = [turn.stage for turn in visible_turns] or [reached_phase]
|
||||
stage_labels = [_stage_label(turn.stage) for turn in visible_turns] or [reached_phase]
|
||||
axis = ["0:00"]
|
||||
if duration_seconds > 0:
|
||||
axis.append(_offset_label(duration_seconds))
|
||||
|
|
@ -1335,6 +1351,13 @@ async def get_session_review(
|
|||
if evaluation_record and not hidden_turns and not evaluation_durable:
|
||||
summary += " 현재 평가는 런타임 캐시에서 복원되었습니다."
|
||||
|
||||
generated_worksheet = _case_worksheet_from_turns(turns)
|
||||
saved_worksheet_payload, _ = await session_persistence.load_case_worksheet(
|
||||
session_id,
|
||||
principal,
|
||||
)
|
||||
case_worksheet = _saved_case_worksheet_from_payload(saved_worksheet_payload) or generated_worksheet
|
||||
|
||||
return SessionReviewResponse(
|
||||
session_id=session_id,
|
||||
client=ReviewClient(
|
||||
|
|
@ -1359,7 +1382,7 @@ async def get_session_review(
|
|||
rubric=rubric,
|
||||
goodMoments=good_moments,
|
||||
growthPoints=growth_points,
|
||||
caseWorksheet=_case_worksheet_from_turns(turns),
|
||||
caseWorksheet=case_worksheet,
|
||||
nextLine=next_line,
|
||||
clientFeedback=client_feedback,
|
||||
audioUrl=None,
|
||||
|
|
@ -1369,6 +1392,39 @@ async def get_session_review(
|
|||
)
|
||||
|
||||
|
||||
@router.put("/{session_id}/review/worksheet", response_model=ReviewCaseWorksheet)
|
||||
async def save_session_review_worksheet(
|
||||
session_id: str,
|
||||
body: ReviewCaseWorksheetSaveRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> ReviewCaseWorksheet:
|
||||
"""Persist the learner's edited case formulation worksheet for this session."""
|
||||
_ensure_learner(principal)
|
||||
await _load_session_or_404(
|
||||
session_id,
|
||||
principal,
|
||||
allow_ended=True,
|
||||
include_turn_evaluation=False,
|
||||
)
|
||||
worksheet = ReviewCaseWorksheet(
|
||||
status="saved_by_learner",
|
||||
generatedBy="learner-edited worksheet",
|
||||
sections=body.sections,
|
||||
limitations=body.limitations,
|
||||
)
|
||||
ok = await session_persistence.save_case_worksheet(
|
||||
session_id=session_id,
|
||||
learner_id=principal.user_id,
|
||||
payload=worksheet.model_dump(mode="json"),
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="case worksheet persistence unavailable",
|
||||
)
|
||||
return worksheet
|
||||
|
||||
|
||||
@router.post("/{session_id}/turn", response_model=TurnResponse)
|
||||
async def submit_turn(
|
||||
session_id: str,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from .services.persona import PersonaCard
|
|||
from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
|
||||
|
||||
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
|
||||
_APPROPRIATENESS_SCORE = {
|
||||
"warn": 1.0,
|
||||
|
|
@ -741,6 +742,66 @@ async def ensure_review_tables() -> None:
|
|||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app.case_worksheet (
|
||||
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
ALTER TABLE app.case_worksheet ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS p_case_worksheet_select ON app.case_worksheet;
|
||||
DROP POLICY IF EXISTS p_case_worksheet_insert ON app.case_worksheet;
|
||||
DROP POLICY IF EXISTS p_case_worksheet_update ON app.case_worksheet;
|
||||
DROP POLICY IF EXISTS p_case_worksheet_delete ON app.case_worksheet;
|
||||
|
||||
CREATE POLICY p_case_worksheet_select
|
||||
ON app.case_worksheet FOR SELECT USING (
|
||||
app.current_role_name() IN ('admin','instructor')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.case_worksheet.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_case_worksheet_insert
|
||||
ON app.case_worksheet FOR INSERT WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.case_worksheet.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_case_worksheet_update
|
||||
ON app.case_worksheet FOR UPDATE USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.case_worksheet.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
) WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.case_worksheet.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_case_worksheet_delete
|
||||
ON app.case_worksheet FOR DELETE USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.case_worksheet.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
|
@ -836,6 +897,65 @@ async def load_session_evaluation(
|
|||
return _EVALUATION_CACHE.get(session_id), False
|
||||
|
||||
|
||||
async def save_case_worksheet(
|
||||
*,
|
||||
session_id: str,
|
||||
learner_id: str,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
if runtime_fallback_allowed():
|
||||
_CASE_WORKSHEET_CACHE[session_id] = payload
|
||||
try:
|
||||
get_pool()
|
||||
async with acquire(role="learner", user_id=learner_id) as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.case_worksheet (session_id, payload, created_at, updated_at)
|
||||
VALUES ($1::uuid, $2::jsonb, now(), now())
|
||||
ON CONFLICT (session_id) DO UPDATE SET
|
||||
payload = EXCLUDED.payload,
|
||||
updated_at = now()
|
||||
""",
|
||||
session_id,
|
||||
payload,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("case worksheet save")
|
||||
return False
|
||||
|
||||
|
||||
async def load_case_worksheet(
|
||||
session_id: str,
|
||||
principal: Principal,
|
||||
) -> tuple[dict[str, Any] | None, bool]:
|
||||
try:
|
||||
get_pool()
|
||||
async with acquire(
|
||||
role=principal.role.value,
|
||||
user_id=principal.user_id,
|
||||
cohort_ids=principal.cohort_ids,
|
||||
) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT payload, updated_at
|
||||
FROM app.case_worksheet
|
||||
WHERE session_id = $1::uuid
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
if row is None:
|
||||
return (
|
||||
_CASE_WORKSHEET_CACHE.get(session_id) if runtime_fallback_allowed() else None
|
||||
), False
|
||||
payload = dict(row["payload"] or {})
|
||||
payload.setdefault("savedAt", _iso_dt(row["updated_at"]))
|
||||
return payload, True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("case worksheet load")
|
||||
return _CASE_WORKSHEET_CACHE.get(session_id), False
|
||||
|
||||
|
||||
def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | None:
|
||||
card = _card_from_joined_session_row(row)
|
||||
if card is None:
|
||||
|
|
|
|||
|
|
@ -677,6 +677,107 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(risk.evidence, [])
|
||||
self.assertIn("명시 근거", risk.emptyReason or "")
|
||||
|
||||
async def test_review_prefers_saved_case_formulation_worksheet(self) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
sess.turns.append(
|
||||
TurnRecord(
|
||||
turn_seq=1,
|
||||
speaker="client",
|
||||
stage=sess.state.stage.value,
|
||||
text="자동 초안 대신 저장본을 확인합니다.",
|
||||
text_masked="자동 초안 대신 저장본을 확인합니다.",
|
||||
created_at=sess.created_at + 1,
|
||||
)
|
||||
)
|
||||
saved_payload = {
|
||||
"status": "saved_by_learner",
|
||||
"generatedBy": "learner-edited worksheet",
|
||||
"sections": [
|
||||
{
|
||||
"key": "exploration_11",
|
||||
"title": "탐색 11항목",
|
||||
"items": [
|
||||
{
|
||||
"key": "presenting_complaint",
|
||||
"label": "주호소",
|
||||
"value": "학습자가 저장한 주호소",
|
||||
"evidence": [],
|
||||
"confidence": "medium",
|
||||
"emptyReason": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"limitations": ["학습자 저장본"],
|
||||
"savedAt": "2026-06-27T10:00:00+00:00",
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
sessions.session_persistence,
|
||||
"load_case_worksheet",
|
||||
AsyncMock(return_value=(saved_payload, True)),
|
||||
):
|
||||
response = await sessions.get_session_review(sess.session_id, principal)
|
||||
|
||||
worksheet = response.caseWorksheet
|
||||
self.assertEqual(worksheet.status, "saved_by_learner")
|
||||
self.assertEqual(worksheet.generatedBy, "learner-edited worksheet")
|
||||
self.assertEqual(worksheet.sections[0].items[0].value, "학습자가 저장한 주호소")
|
||||
self.assertEqual(worksheet.limitations, ["학습자 저장본"])
|
||||
self.assertEqual(worksheet.savedAt, "2026-06-27T10:00:00+00:00")
|
||||
|
||||
async def test_learner_can_save_case_formulation_worksheet(self) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
request = sessions.ReviewCaseWorksheetSaveRequest(
|
||||
sections=[
|
||||
sessions.ReviewWorksheetSection(
|
||||
key="exploration_11",
|
||||
title="탐색 11항목",
|
||||
items=[
|
||||
sessions.ReviewWorksheetItem(
|
||||
key="presenting_complaint",
|
||||
label="주호소",
|
||||
value="수정한 주호소",
|
||||
confidence="medium",
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
limitations=["임상 루브릭 전"],
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
sessions,
|
||||
"_load_session_or_404",
|
||||
AsyncMock(return_value=sess),
|
||||
) as load_session, patch.object(
|
||||
sessions.session_persistence,
|
||||
"save_case_worksheet",
|
||||
AsyncMock(return_value=True),
|
||||
) as save_worksheet:
|
||||
response = await sessions.save_session_review_worksheet(
|
||||
sess.session_id,
|
||||
request,
|
||||
principal,
|
||||
)
|
||||
|
||||
load_session.assert_awaited_once_with(
|
||||
sess.session_id,
|
||||
principal,
|
||||
allow_ended=True,
|
||||
include_turn_evaluation=False,
|
||||
)
|
||||
save_worksheet.assert_awaited_once()
|
||||
save_kwargs = save_worksheet.await_args.kwargs
|
||||
self.assertEqual(save_kwargs["session_id"], sess.session_id)
|
||||
self.assertEqual(save_kwargs["learner_id"], principal.user_id)
|
||||
self.assertEqual(save_kwargs["payload"]["status"], "saved_by_learner")
|
||||
self.assertEqual(save_kwargs["payload"]["sections"][0]["items"][0]["value"], "수정한 주호소")
|
||||
self.assertEqual(response.status, "saved_by_learner")
|
||||
self.assertEqual(response.sections[0].items[0].value, "수정한 주호소")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -21,13 +21,18 @@ _STAGE_LABELS = {
|
|||
"EXPLORE": "탐색",
|
||||
"INTERVENE": "개입",
|
||||
"CLOSE": "정리",
|
||||
"rapport": "라포",
|
||||
"explore": "탐색",
|
||||
"intervene": "개입",
|
||||
"close": "정리",
|
||||
}
|
||||
|
||||
|
||||
def stage_label(stage: object) -> str:
|
||||
"""Stage enum과 문자열 값을 같은 한글 라벨로 정규화한다."""
|
||||
name = getattr(stage, "name", "")
|
||||
return _STAGE_LABELS.get(name, str(getattr(stage, "value", stage)))
|
||||
raw = str(getattr(stage, "value", stage))
|
||||
return _STAGE_LABELS.get(name) or _STAGE_LABELS.get(raw) or raw
|
||||
|
||||
|
||||
class SessionAccessError(str, Enum):
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue