사례개념화 워크시트 저장 추가
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):
|
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"
|
generatedBy: str = "rule-based transcript extractor"
|
||||||
sections: list[ReviewWorksheetSection] = Field(default_factory=list)
|
sections: list[ReviewWorksheetSection] = Field(default_factory=list)
|
||||||
limitations: list[str] = 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):
|
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]:
|
def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]:
|
||||||
if not record:
|
if not record:
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -1105,7 +1121,7 @@ def _session_detail(
|
||||||
SessionDetailTurn(
|
SessionDetailTurn(
|
||||||
turn_seq=turn.turn_seq,
|
turn_seq=turn.turn_seq,
|
||||||
speaker="learner" if turn.speaker == "counselor" else "client",
|
speaker="learner" if turn.speaker == "counselor" else "client",
|
||||||
stage=turn.stage,
|
stage=_stage_label(turn.stage),
|
||||||
text=turn.text_masked,
|
text=turn.text_masked,
|
||||||
created_at=_iso(turn.created_at) or "",
|
created_at=_iso(turn.created_at) or "",
|
||||||
)
|
)
|
||||||
|
|
@ -1249,7 +1265,7 @@ async def get_session_review(
|
||||||
client_initial = client_name[:1] or "내"
|
client_initial = client_name[:1] or "내"
|
||||||
|
|
||||||
reached_phase = _stage_label(sess.state.stage)
|
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"]
|
axis = ["0:00"]
|
||||||
if duration_seconds > 0:
|
if duration_seconds > 0:
|
||||||
axis.append(_offset_label(duration_seconds))
|
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:
|
if evaluation_record and not hidden_turns and not evaluation_durable:
|
||||||
summary += " 현재 평가는 런타임 캐시에서 복원되었습니다."
|
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(
|
return SessionReviewResponse(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
client=ReviewClient(
|
client=ReviewClient(
|
||||||
|
|
@ -1359,7 +1382,7 @@ async def get_session_review(
|
||||||
rubric=rubric,
|
rubric=rubric,
|
||||||
goodMoments=good_moments,
|
goodMoments=good_moments,
|
||||||
growthPoints=growth_points,
|
growthPoints=growth_points,
|
||||||
caseWorksheet=_case_worksheet_from_turns(turns),
|
caseWorksheet=case_worksheet,
|
||||||
nextLine=next_line,
|
nextLine=next_line,
|
||||||
clientFeedback=client_feedback,
|
clientFeedback=client_feedback,
|
||||||
audioUrl=None,
|
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)
|
@router.post("/{session_id}/turn", response_model=TurnResponse)
|
||||||
async def submit_turn(
|
async def submit_turn(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from .services.persona import PersonaCard
|
||||||
from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
|
from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
|
||||||
|
|
||||||
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
|
_CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
|
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
|
||||||
_APPROPRIATENESS_SCORE = {
|
_APPROPRIATENESS_SCORE = {
|
||||||
"warn": 1.0,
|
"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:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -836,6 +897,65 @@ async def load_session_evaluation(
|
||||||
return _EVALUATION_CACHE.get(session_id), False
|
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:
|
def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | None:
|
||||||
card = _card_from_joined_session_row(row)
|
card = _card_from_joined_session_row(row)
|
||||||
if card is None:
|
if card is None:
|
||||||
|
|
|
||||||
|
|
@ -677,6 +677,107 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertEqual(risk.evidence, [])
|
self.assertEqual(risk.evidence, [])
|
||||||
self.assertIn("명시 근거", risk.emptyReason or "")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,18 @@ _STAGE_LABELS = {
|
||||||
"EXPLORE": "탐색",
|
"EXPLORE": "탐색",
|
||||||
"INTERVENE": "개입",
|
"INTERVENE": "개입",
|
||||||
"CLOSE": "정리",
|
"CLOSE": "정리",
|
||||||
|
"rapport": "라포",
|
||||||
|
"explore": "탐색",
|
||||||
|
"intervene": "개입",
|
||||||
|
"close": "정리",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def stage_label(stage: object) -> str:
|
def stage_label(stage: object) -> str:
|
||||||
"""Stage enum과 문자열 값을 같은 한글 라벨로 정규화한다."""
|
"""Stage enum과 문자열 값을 같은 한글 라벨로 정규화한다."""
|
||||||
name = getattr(stage, "name", "")
|
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):
|
class SessionAccessError(str, Enum):
|
||||||
|
|
|
||||||
|
|
@ -698,6 +698,26 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/sessions/{session_id}/stream": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -1594,14 +1614,23 @@ export interface components {
|
||||||
generatedBy: string;
|
generatedBy: string;
|
||||||
/** Limitations */
|
/** Limitations */
|
||||||
limitations?: string[];
|
limitations?: string[];
|
||||||
|
/** Savedat */
|
||||||
|
savedAt?: string | null;
|
||||||
/** Sections */
|
/** Sections */
|
||||||
sections?: components["schemas"]["ReviewWorksheetSection"][];
|
sections?: components["schemas"]["ReviewWorksheetSection-Output"][];
|
||||||
/**
|
/**
|
||||||
* Status
|
* Status
|
||||||
* @default empty
|
* @default empty
|
||||||
* @enum {string}
|
* @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 */
|
||||||
ReviewClient: {
|
ReviewClient: {
|
||||||
|
|
@ -1738,7 +1767,16 @@ export interface components {
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
};
|
};
|
||||||
/** ReviewWorksheetSection */
|
/** ReviewWorksheetSection */
|
||||||
ReviewWorksheetSection: {
|
"ReviewWorksheetSection-Input": {
|
||||||
|
/** Items */
|
||||||
|
items?: components["schemas"]["ReviewWorksheetItem"][];
|
||||||
|
/** Key */
|
||||||
|
key: string;
|
||||||
|
/** Title */
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
/** ReviewWorksheetSection */
|
||||||
|
"ReviewWorksheetSection-Output": {
|
||||||
/** Items */
|
/** Items */
|
||||||
items?: components["schemas"]["ReviewWorksheetItem"][];
|
items?: components["schemas"]["ReviewWorksheetItem"][];
|
||||||
/** Key */
|
/** 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: {
|
stream_turn_sessions__session_id__stream_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -209,8 +209,9 @@ export type ReviewPoint = ApiSchema<"ReviewPoint">;
|
||||||
export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">;
|
export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">;
|
||||||
|
|
||||||
export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">;
|
export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">;
|
||||||
export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection">;
|
export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection-Output">;
|
||||||
export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">;
|
export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">;
|
||||||
|
export type ReviewCaseWorksheetSaveRequest = ApiSchema<"ReviewCaseWorksheetSaveRequest">;
|
||||||
export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">;
|
export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">;
|
||||||
|
|
||||||
/* =====================================================================
|
/* =====================================================================
|
||||||
|
|
@ -411,6 +412,11 @@ export const sessionApi = {
|
||||||
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
|
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
|
||||||
review: (sessionId: string) =>
|
review: (sessionId: string) =>
|
||||||
api.get<SessionReviewResponse>(`/sessions/${encodeURIComponent(sessionId)}/review`),
|
api.get<SessionReviewResponse>(`/sessions/${encodeURIComponent(sessionId)}/review`),
|
||||||
|
saveWorksheet: (sessionId: string, payload: ReviewCaseWorksheetSaveRequest) =>
|
||||||
|
api.put<ReviewCaseWorksheet>(
|
||||||
|
`/sessions/${encodeURIComponent(sessionId)}/review/worksheet`,
|
||||||
|
payload,
|
||||||
|
),
|
||||||
stream: openSessionStream,
|
stream: openSessionStream,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
type ReviewPoint,
|
type ReviewPoint,
|
||||||
type ReviewTechnique,
|
type ReviewTechnique,
|
||||||
type ReviewTurn,
|
type ReviewTurn,
|
||||||
|
type ReviewWorksheetSection,
|
||||||
type SessionReviewResponse,
|
type SessionReviewResponse,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { ValenceChart } from "./session-review/ValenceChart";
|
import { ValenceChart } from "./session-review/ValenceChart";
|
||||||
|
|
@ -130,21 +131,102 @@ function confidenceLabel(confidence: "none" | "low" | "medium") {
|
||||||
return "빈칸";
|
return "빈칸";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneWorksheetSections(sections: ReviewWorksheetSection[]): ReviewWorksheetSection[] {
|
||||||
|
return sections.map((section) => ({
|
||||||
|
...section,
|
||||||
|
items: (section.items ?? []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
evidence: [...(item.evidence ?? [])],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function CaseWorksheetCard({
|
function CaseWorksheetCard({
|
||||||
|
sessionId,
|
||||||
worksheet,
|
worksheet,
|
||||||
onJump,
|
onJump,
|
||||||
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
|
sessionId: string;
|
||||||
worksheet: ReviewCaseWorksheet | null | undefined;
|
worksheet: ReviewCaseWorksheet | null | undefined;
|
||||||
onJump: (id: string) => void;
|
onJump: (id: string) => void;
|
||||||
|
onSaved: (worksheet: ReviewCaseWorksheet) => void;
|
||||||
}) {
|
}) {
|
||||||
const sections = worksheet?.sections ?? [];
|
const sections = worksheet?.sections ?? [];
|
||||||
const limitations = worksheet?.limitations ?? [];
|
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 (
|
return (
|
||||||
<Card className="sr-card sr-card--side sr-card--worksheet">
|
<Card className="sr-card sr-card--side sr-card--worksheet">
|
||||||
|
<div className="sr-ws-toolbar">
|
||||||
|
<div>
|
||||||
<Kicker>사례개념화 워크시트</Kicker>
|
<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">
|
<div className="sr-worksheet">
|
||||||
{sections.length > 0 ? (
|
{draftSections.length > 0 ? (
|
||||||
sections.map((section) => (
|
draftSections.map((section) => (
|
||||||
<section className="sr-ws-section" key={section.key}>
|
<section className="sr-ws-section" key={section.key}>
|
||||||
<h3>{section.title}</h3>
|
<h3>{section.title}</h3>
|
||||||
<div className="sr-ws-items">
|
<div className="sr-ws-items">
|
||||||
|
|
@ -158,7 +240,15 @@ function CaseWorksheetCard({
|
||||||
{confidenceLabel(item.confidence)}
|
{confidenceLabel(item.confidence)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 ? (
|
{evidence ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -181,6 +271,7 @@ function CaseWorksheetCard({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{saveError ? <div className="sr-ws-save-error">{saveError}</div> : null}
|
||||||
{limitations.length > 0 ? (
|
{limitations.length > 0 ? (
|
||||||
<div className="sr-ws-limitations">
|
<div className="sr-ws-limitations">
|
||||||
{limitations.slice(0, 2).map((item) => (
|
{limitations.slice(0, 2).map((item) => (
|
||||||
|
|
@ -201,6 +292,10 @@ export default function SessionReview() {
|
||||||
const [activeTurn, setActiveTurn] = useState<string | null>(null);
|
const [activeTurn, setActiveTurn] = useState<string | null>(null);
|
||||||
const turnRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const turnRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
|
const handleWorksheetSaved = (worksheet: ReviewCaseWorksheet) => {
|
||||||
|
setData((current) => (current ? { ...current, caseWorksheet: worksheet } : current));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
|
|
@ -613,7 +708,12 @@ export default function SessionReview() {
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</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${data.clientFeedback ? " sr-feedback--filled" : ""}`}>
|
||||||
<div className="sr-feedback__kicker">
|
<div className="sr-feedback__kicker">
|
||||||
|
|
|
||||||
|
|
@ -720,6 +720,23 @@
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
scrollbar-gutter: stable;
|
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 {
|
.sr-ws-section {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -780,13 +797,27 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--paper-2);
|
background: var(--paper-2);
|
||||||
}
|
}
|
||||||
.sr-ws-item p {
|
.sr-ws-input {
|
||||||
margin: 0;
|
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);
|
color: var(--text-body);
|
||||||
|
font-family: var(--font-sans);
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
overflow-wrap: anywhere;
|
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 {
|
.sr-ws-evidence {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -805,6 +836,12 @@
|
||||||
.sr-ws-evidence:hover {
|
.sr-ws-evidence:hover {
|
||||||
text-decoration: underline;
|
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 {
|
.sr-ws-limitations {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
|
|
@ -1106,6 +1143,13 @@
|
||||||
.sr-worksheet {
|
.sr-worksheet {
|
||||||
max-height: 460px;
|
max-height: 460px;
|
||||||
}
|
}
|
||||||
|
.sr-ws-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sr-ws-toolbar .vg-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.sr-chart__plot {
|
.sr-chart__plot {
|
||||||
height: 156px;
|
height: 156px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Vignette Handoff
|
# Vignette Handoff
|
||||||
|
|
||||||
> Updated: 2026-06-27 18:48 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다.
|
> Updated: 2026-06-27 19:10 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다.
|
||||||
|
|
||||||
## 현재 상태
|
## 현재 상태
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
- Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr`
|
- Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr`
|
||||||
- 최신 백엔드 회귀: `python -m pytest app/ -q` → `130 passed`
|
- 최신 백엔드 회귀: `python -m pytest app/ -q` → `130 passed`
|
||||||
- X1 재귀학습 export 1차: `scripts/export-recursive-dataset.py` 기본 read-only dry-run, `--write-dataset` 명시 시에만 `ds.*` write, approved export는 steward/legal/IAA gate 없으면 거부.
|
- X1 재귀학습 export 1차: `scripts/export-recursive-dataset.py` 기본 read-only dry-run, `--write-dataset` 명시 시에만 `ds.*` write, approved export는 steward/legal/IAA gate 없으면 거부.
|
||||||
- C1 사례개념화 워크시트 1차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 read-only 카드가 축어록 근거 기반 초안을 제공한다. 편집·DB 저장, 임상 루브릭, AI 추출/채점은 후속.
|
- C1 사례개념화 워크시트 2차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 편집 카드가 축어록 근거 기반 초안을 제공한다. 학습자 저장본은 `PUT /sessions/{id}/review/worksheet`로 `app.case_worksheet`에 저장되고, 이후 `GET /review`는 `saved_by_learner` 저장본을 자동 초안보다 우선 반환한다. 검증: focused backend 25 passed, API typecheck/build, 로컬 API smoke `saved_by_learner:local smoke saved worksheet`. 임상 루브릭, AI 추출/채점, 교수자 검수는 후속.
|
||||||
- C3 이론모드 1차: `theory_mode`가 세션·평가·생성 프롬프트까지 흐르고, `build_turn_messages`는 인간중심/CBT/통합 프레이밍을 엔진 메시지에 넣는다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속.
|
- C3 이론모드 1차: `theory_mode`가 세션·평가·생성 프롬프트까지 흐르고, `build_turn_messages`는 인간중심/CBT/통합 프레이밍을 엔진 메시지에 넣는다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속.
|
||||||
- H2 평가 정규화: 턴 평가의 대안발화도 `app.alternative_utterance`에 적재하고 리뷰 hydrate 시 `alternative_utterances`로 복원한다.
|
- H2 평가 정규화: 턴 평가의 대안발화도 `app.alternative_utterance`에 적재하고 리뷰 hydrate 시 `alternative_utterances`로 복원한다.
|
||||||
- X2 예산 경고/저비용 평가 라우팅: `ADMIN_USAGE_BUDGET_USD`가 0보다 크면 `/admin/usage`가 budget 상태(ok/warn/exceeded)를 반환하고 `/admin`이 예산 배너를 표시한다. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL`을 설정하면 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달한다. 비우면 기존 gateway default 라우팅을 유지한다.
|
- X2 예산 경고/저비용 평가 라우팅: `ADMIN_USAGE_BUDGET_USD`가 0보다 크면 `/admin/usage`가 budget 상태(ok/warn/exceeded)를 반환하고 `/admin`이 예산 배너를 표시한다. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL`을 설정하면 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달한다. 비우면 기존 gateway default 라우팅을 유지한다.
|
||||||
|
|
|
||||||
|
|
@ -572,8 +572,8 @@
|
||||||
<div class="tg-head"><h3>원천문서 갭 분석</h3><span class="tg-note">산학협력 원천문서 5종 정독 + 코드 교차조사 (2026-06-26). 근거: source-docs-gap-analysis-2026-06-26.md · critical 3 / high 4 / medium+ 6</span></div>
|
<div class="tg-head"><h3>원천문서 갭 분석</h3><span class="tg-note">산학협력 원천문서 5종 정독 + 코드 교차조사 (2026-06-26). 근거: source-docs-gap-analysis-2026-06-26.md · critical 3 / high 4 / medium+ 6</span></div>
|
||||||
<div class="scards">
|
<div class="scards">
|
||||||
<article class="scard" data-status="done" data-cat="콘텐츠·산출물" data-owner="0">
|
<article class="scard" data-status="done" data-cat="콘텐츠·산출물" data-owner="0">
|
||||||
<button class="scard-head" aria-expanded="false"><span class="chip c-done">C1 1차 DONE</span><span class="scard-mid"><span class="scard-title">사례개념화·치료계획 워크시트 1차 구조<span class="critbadge">critical</span></span><span class="scard-sum">리뷰 API <code>caseWorksheet</code>와 read-only UI 카드로 구조 전무를 해소했다. 임상 루브릭은 외부 의존.</span></span><span class="caret" aria-hidden="true"></span></button>
|
<button class="scard-head" aria-expanded="false"><span class="chip c-done">C1 2차 DONE</span><span class="scard-mid"><span class="scard-title">사례개념화·치료계획 워크시트 저장형 구조<span class="critbadge">critical</span></span><span class="scard-sum">축어록 기반 초안을 학습자가 편집·저장하는 <code>app.case_worksheet</code> 경로까지 연결했다. 임상 루브릭은 외부 의존.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||||
<div class="scard-body"><div class="kv"><b>현재</b><p>doc1/4/5가 1급 산출물로 요구하는 워크시트 축(탐색 11항목·호소 5영역·인지삼제·1/2차 감정·보호/방해 4사분면·생물심리사회 목표)을 <code>SessionReviewResponse.caseWorksheet</code>로 노출한다. 서버는 저장된 축어록만 근거로 규칙 기반 초안을 만들고, 프론트 리뷰 화면은 근거 턴으로 점프 가능한 read-only 워크시트 카드를 표시한다.</p></div><div class="kv"><b>검증</b><p><code>pytest app/test_session_turn_persistence.py -q</code> 15 passed, <code>pytest app/ -q</code> 119 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>session-review.spec.ts</code> 1 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>편집·DB 저장형 워크시트, 임상팀 루브릭, AI 추출/채점, 교수자 검수 플로우는 후속이다. 현재 초안은 숨은 추론이나 임상 점수 없이 축어록 근거가 있는 항목만 채운다.</p></div></div>
|
<div class="scard-body"><div class="kv"><b>현재</b><p>doc1/4/5가 1급 산출물로 요구하는 워크시트 축(탐색 11항목·호소 5영역·인지삼제·1/2차 감정·보호/방해 4사분면·생물심리사회 목표)을 <code>SessionReviewResponse.caseWorksheet</code>로 노출한다. 서버는 저장된 축어록만 근거로 규칙 기반 초안을 만들고, 프론트 리뷰 화면은 근거 턴으로 점프 가능한 워크시트를 편집할 수 있다. 저장본은 <code>PUT /sessions/{id}/review/worksheet</code> → <code>app.case_worksheet</code>에 저장되고, 이후 <code>GET /review</code>는 <code>saved_by_learner</code> 워크시트를 자동 초안보다 우선 반환한다.</p></div><div class="kv"><b>검증</b><p><code>pytest app/test_session_turn_persistence.py app/test_rbac_idor.py -q</code> 25 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code> passed. 로컬 API smoke: 세션 생성→워크시트 저장→<code>GET /review</code>가 <code>saved_by_learner:local smoke saved worksheet</code> 반환.</p></div><div class="kv k-warn"><b>잔여</b><p>임상팀 루브릭, AI 추출/채점 고도화, 교수자 검수 플로우는 후속이다. 현재 초안/저장본은 숨은 정답키나 임상 점수 없이 학습자 편집 산출물로만 취급한다.</p></div></div>
|
||||||
</article>
|
</article>
|
||||||
<article class="scard" data-status="done" data-cat="안전·위기" data-owner="0">
|
<article class="scard" data-status="done" data-cat="안전·위기" data-owner="0">
|
||||||
<button class="scard-head" aria-expanded="false"><span class="chip c-done">C2 구조 DONE</span><span class="scard-mid"><span class="scard-title">위기 에스컬레이션·safety_events 배선</span><span class="scard-sum">crisis.risk_level→ideation_observed 전달 + escalate 시 safety_events 적재. 라이브 검증.</span></span><span class="caret" aria-hidden="true"></span></button>
|
<button class="scard-head" aria-expanded="false"><span class="chip c-done">C2 구조 DONE</span><span class="scard-mid"><span class="scard-title">위기 에스컬레이션·safety_events 배선</span><span class="scard-sum">crisis.risk_level→ideation_observed 전달 + escalate 시 safety_events 적재. 라이브 검증.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||||
|
|
@ -609,7 +609,7 @@
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-blocks">
|
<div class="detail-blocks">
|
||||||
<div class="detail-block"><h4>즉시 착수 가능(내부 코드, 외부 합의 불요)</h4><ul><li>C1/C2/C3/H2/H3은 1차 구조 완료. 후속은 저장형 워크시트, 임상 스크립트, CBT 체인, 2열 리뷰 UI, 페르소나 항목형 저작 UI처럼 데이터·정책·UI 고도화 영역이다.</li><li>H4(부분): 한국어 정규식 마스킹, metadata-only LLM call audit 경로, 로컬 live row proof, learner 동의 수락/철회/회기 시작 하드게이트 골격은 완료. 남은 내부 후보는 이름/기관 NER이고, guardian/legal 서명 동의 evidence는 외부 게이트다. X1 dry-run export 1차와 X2 비용 관측·예산 경고/evaluator 모델 override 1차는 완료, X2 semantic cache는 후속. M1/M2/M3 1차는 완료, provider 기반 한숨·울음 감지와 case_digest/pinned_fact 실적재, 운영 SAML 서명검증은 후속.</li></ul></div>
|
<div class="detail-block"><h4>즉시 착수 가능(내부 코드, 외부 합의 불요)</h4><ul><li>C1은 저장형 워크시트까지 2차 완료. C2/C3/H2/H3은 1차 구조 완료. 후속은 임상 스크립트, CBT 체인, 2열 리뷰 UI, 페르소나 항목형 저작 UI처럼 데이터·정책·UI 고도화 영역이다.</li><li>H4(부분): 한국어 정규식 마스킹, metadata-only LLM call audit 경로, 로컬 live row proof, learner 동의 수락/철회/회기 시작 하드게이트 골격은 완료. 남은 내부 후보는 이름/기관 NER이고, guardian/legal 서명 동의 evidence는 외부 게이트다. X1 dry-run export 1차와 X2 비용 관측·예산 경고/evaluator 모델 override 1차는 완료, X2 semantic cache는 후속. M1/M2/M3 1차는 완료, provider 기반 한숨·울음 감지와 case_digest/pinned_fact 실적재, 운영 SAML 서명검증은 후속.</li></ul></div>
|
||||||
<div class="detail-block"><h4>소유자 결정 / 외부(임상팀·기관) 의존</h4><ul><li>임상팀 산출물: C1 워크시트 항목·루브릭, C3 CBT 콘텐츠·체인, C2 위기 스크립트·서약 문안, H2 골든셋 코딩.</li><li>소유자 평가설계: H1 실험/통제군·3척도 문항·50분 흐름, κ/ICC·환각률 목표(doc4 미명시).</li><li>기관(한신 IT): M3 SSO IdP·test tenant·claim 스키마·인증서, 거버넌스 증거.</li><li>거버넌스: L1 스택 정합성 방향·저작권 등재 문서화·IP 협의.</li></ul></div>
|
<div class="detail-block"><h4>소유자 결정 / 외부(임상팀·기관) 의존</h4><ul><li>임상팀 산출물: C1 워크시트 항목·루브릭, C3 CBT 콘텐츠·체인, C2 위기 스크립트·서약 문안, H2 골든셋 코딩.</li><li>소유자 평가설계: H1 실험/통제군·3척도 문항·50분 흐름, κ/ICC·환각률 목표(doc4 미명시).</li><li>기관(한신 IT): M3 SSO IdP·test tenant·claim 스키마·인증서, 거버넌스 증거.</li><li>거버넌스: L1 스택 정합성 방향·저작권 등재 문서화·IP 협의.</li></ul></div>
|
||||||
<div class="detail-block"><h4>판독 한계 (정직성)</h4><ul><li>핵심 축어록 2단 표는 PDF 시각 판독이 1차 근거(txt 일부 누락), OCR 잔재 존재.</li><li>doc4 κ/ICC·환각률은 신청서 본문 미명시 — 계약 확정 지표로 단정 금지.</li><li>'현재상태'는 grep/코드 사실 기반. C1~C3·H2·H3은 직접 재확인(✓), 그 외는 착수 전 코드 1차 재확인 권고.</li></ul></div>
|
<div class="detail-block"><h4>판독 한계 (정직성)</h4><ul><li>핵심 축어록 2단 표는 PDF 시각 판독이 1차 근거(txt 일부 누락), OCR 잔재 존재.</li><li>doc4 κ/ICC·환각률은 신청서 본문 미명시 — 계약 확정 지표로 단정 금지.</li><li>'현재상태'는 grep/코드 사실 기반. C1~C3·H2·H3은 직접 재확인(✓), 그 외는 착수 전 코드 1차 재확인 권고.</li></ul></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -774,7 +774,7 @@
|
||||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>레이아웃 감사팀 / 전면 레이아웃 개선</b><p>서브에이전트 전담으로 1차 overflow/clipping 수리 후 2차 전면 재설계를 진행했고, 이번 라운드에서 7개 권장 너비 시각 수용까지 닫았다. 세션, 학습자 홈/리뷰, 교수 콘솔, 관리자, 설정 화면을 작업 화면 기준으로 재배치했고, 과한 빈칸·튀어나감·버튼/텍스트 잘림·모바일 테이블 사용성을 줄였다. 엄격한 시각 게이트 + 화면별 적대적 시각 재검수에서 7개 화면 모두 accept(critical/major 0, 회귀 0)를 받아 DONE으로 닫는다.</p></div><div><b>산출물</b><p><code>docs/ops/layout-redesign-handoff-2026-06-26.md</code>, page-by-page redesign patches, <code>apps/web/e2e/layout-visual-gate.spec.ts</code></p></div><div><b>검증</b><p><code>npm run typecheck</code> OK, 엄격 시각 게이트 7 passed, layout focused E2E desktop/mobile 54 passed, 적대적 시각 재검수 7/7 accept·회귀 0.</p></div></div>
|
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>레이아웃 감사팀 / 전면 레이아웃 개선</b><p>서브에이전트 전담으로 1차 overflow/clipping 수리 후 2차 전면 재설계를 진행했고, 이번 라운드에서 7개 권장 너비 시각 수용까지 닫았다. 세션, 학습자 홈/리뷰, 교수 콘솔, 관리자, 설정 화면을 작업 화면 기준으로 재배치했고, 과한 빈칸·튀어나감·버튼/텍스트 잘림·모바일 테이블 사용성을 줄였다. 엄격한 시각 게이트 + 화면별 적대적 시각 재검수에서 7개 화면 모두 accept(critical/major 0, 회귀 0)를 받아 DONE으로 닫는다.</p></div><div><b>산출물</b><p><code>docs/ops/layout-redesign-handoff-2026-06-26.md</code>, page-by-page redesign patches, <code>apps/web/e2e/layout-visual-gate.spec.ts</code></p></div><div><b>검증</b><p><code>npm run typecheck</code> OK, 엄격 시각 게이트 7 passed, layout focused E2E desktop/mobile 54 passed, 적대적 시각 재검수 7/7 accept·회귀 0.</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>엄격한 레이아웃 시각 게이트 구축 · 병렬 수정 · 적대적 재검수</b><p>핸드오프가 요구한 7개 권장 너비(390/720/861/900/1024/1280/1440) 시각 수용을 자동 게이트로 고정했다. 7개 재설계 화면을 각 너비에서 렌더링해 가로 overflow 0, 컨트롤 하드클립/텍스트클립 0을 강제하고 화면당 7장 총 49장 풀페이지 스크린샷을 남긴다. 화면별 1에이전트 병렬 시각 리뷰로 49장 직접 판독 → 6개 병렬 수정 에이전트(파일 비중첩)가 결함 수정 → 화면별 적대적 재검수로 해소·무회귀 확정.</p></div><div><b>산출물</b><p><code>apps/web/e2e/layout-visual-gate.spec.ts</code>, <code>node_modules/.tmp/layout-gate/*.png</code> 49장, 2× workflow(review/verify)·6× fix agent</p></div><div><b>검증</b><p>게이트 7 passed(재스크린샷). 해소된 핵심 결함: 학습자홈 1280/1440 3열 가운데 컬럼 붕괴(critical → 2열 분기 상향 + word-break:keep-all), 교수 'API 404' raw 배너 제거, 리뷰 빈상태 위계 역전, 관리자 스켈레톤, 설정 와이드 좌측 데드존 제거, 세션 모바일 44px 터치타깃. 적대적 재검수 7/7 accept, regression 0.</p></div></div>
|
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>엄격한 레이아웃 시각 게이트 구축 · 병렬 수정 · 적대적 재검수</b><p>핸드오프가 요구한 7개 권장 너비(390/720/861/900/1024/1280/1440) 시각 수용을 자동 게이트로 고정했다. 7개 재설계 화면을 각 너비에서 렌더링해 가로 overflow 0, 컨트롤 하드클립/텍스트클립 0을 강제하고 화면당 7장 총 49장 풀페이지 스크린샷을 남긴다. 화면별 1에이전트 병렬 시각 리뷰로 49장 직접 판독 → 6개 병렬 수정 에이전트(파일 비중첩)가 결함 수정 → 화면별 적대적 재검수로 해소·무회귀 확정.</p></div><div><b>산출물</b><p><code>apps/web/e2e/layout-visual-gate.spec.ts</code>, <code>node_modules/.tmp/layout-gate/*.png</code> 49장, 2× workflow(review/verify)·6× fix agent</p></div><div><b>검증</b><p>게이트 7 passed(재스크린샷). 해소된 핵심 결함: 학습자홈 1280/1440 3열 가운데 컬럼 붕괴(critical → 2열 분기 상향 + word-break:keep-all), 교수 'API 404' raw 배너 제거, 리뷰 빈상태 위계 역전, 관리자 스켈레톤, 설정 와이드 좌측 데드존 제거, 세션 모바일 44px 터치타깃. 적대적 재검수 7/7 accept, regression 0.</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>레이아웃 cosmetic minor 폴리시 일괄 처리</b><p>적대적 재검수 잔여 cosmetic minor를 5개 병렬 폴리시 에이전트로 처리했다. 학습자홈 헬퍼문구 고아 글자, 세션 1024 일시정지 아이콘 정리 + 보내기 버튼 대비 강화, 설정 2x2 칩 행 높이 균일, 교수 검토 큐 카드 갭 제거, 관리자 2열 높이 동기화를 적용했다.</p></div><div><b>산출물</b><p>page-by-page polish patches(LearnerHome/Session/Settings/Professor/Admin)</p></div><div><b>검증</b><p><code>npm run typecheck</code> OK, 시각 게이트 7 passed, 레이아웃 E2E desktop/mobile 54 passed, session-layout 8/8 — 회귀 0.</p></div></div>
|
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>레이아웃 cosmetic minor 폴리시 일괄 처리</b><p>적대적 재검수 잔여 cosmetic minor를 5개 병렬 폴리시 에이전트로 처리했다. 학습자홈 헬퍼문구 고아 글자, 세션 1024 일시정지 아이콘 정리 + 보내기 버튼 대비 강화, 설정 2x2 칩 행 높이 균일, 교수 검토 큐 카드 갭 제거, 관리자 2열 높이 동기화를 적용했다.</p></div><div><b>산출물</b><p>page-by-page polish patches(LearnerHome/Session/Settings/Professor/Admin)</p></div><div><b>검증</b><p><code>npm run typecheck</code> OK, 시각 게이트 7 passed, 레이아웃 E2E desktop/mobile 54 passed, session-layout 8/8 — 회귀 0.</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>세션 종료 UX와 다크 테마 SSOT</b><p>세션 하단의 드래그형 종료 컴포넌트를 명시 확인 다이얼로그로 바꿔 모바일 오발동과 제스처 실패를 줄였다. Topbar/Settings의 theme 저장·초기화는 <code>lib/theme.ts</code> 단일 경로로 합치고, 저장값이 없으면 dark 기본값을 앱 부팅 전에 적용한다. API 기본 preference <code>system</code>은 Settings에서 light로 오해하지 않고 현재 초기 테마를 따르며, 회기 리뷰·설정 포함 주요 페이지의 흰 섹션 잔재를 dark 작업면으로 맞췄다.</p></div><div><b>산출물</b><p><code>Session.tsx</code>, <code>session.css</code>, <code>session-review.css</code>, <code>Settings.tsx</code>, <code>settings.css</code>, <code>lib/theme.ts</code>, <code>settings/useTheme.ts</code>, <code>session-layout.spec.ts</code></p></div><div><b>검증</b><p><code>npm run typecheck</code>, <code>npm run build</code>, <code>npx playwright test e2e/layout-visual-gate.spec.ts e2e/settings.spec.ts e2e/session-review.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1</code> 22 passed.</p></div></div>
|
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>세션 종료 UX와 다크 테마 SSOT</b><p>세션 하단의 드래그형 종료 컴포넌트를 명시 확인 다이얼로그로 바꿔 모바일 오발동과 제스처 실패를 줄였다. Topbar/Settings의 theme 저장·초기화는 <code>lib/theme.ts</code> 단일 경로로 합치고, 저장값이 없으면 dark 기본값을 앱 부팅 전에 적용한다. API 기본 preference <code>system</code>은 Settings에서 light로 오해하지 않고 현재 초기 테마를 따르며, 회기 리뷰·설정 포함 주요 페이지의 흰 섹션 잔재를 dark 작업면으로 맞췄다. P1 실제 세션 기본 아바타는 긴 머리 PSB 대신 컨셉 보드 크롭 <code>p1-concept</code>로 임시 전환했고, active session 스테이지 상태 배지는 아바타와 겹치지 않는 3행 구조로 정리했다.</p></div><div><b>산출물</b><p><code>Session.tsx</code>, <code>session.css</code>, <code>session-review.css</code>, <code>Settings.tsx</code>, <code>settings.css</code>, <code>lib/theme.ts</code>, <code>settings/useTheme.ts</code>, <code>apps/web/public/avatar/p1-concept/neutral.png</code>, <code>avatar-expression.spec.ts</code>, <code>session-layout.spec.ts</code></p></div><div><b>검증</b><p><code>npm run typecheck</code>, <code>npm run build</code>, <code>npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts e2e/avatar-expression.spec.ts e2e/session-review.spec.ts e2e/settings.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1</code> 36 passed. 7개 주요 화면 x 3뷰포트 캡처 21장에서 overflow 0, 큰 흰 CSS 배경면 0.</p></div></div>
|
||||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>잔여(비차단) — 공용 셸 단일 항목 → 처리</b><p>축소 사이드바 세로 구분선이 본문 전체 높이까지 닿지 않던 건을 <code>components/shell/shell.css</code>에서 처리했다. <code>.vg-nav border-right</code> 제거 후 <code>.vg-shell__body</code> 컨테이너 배경 하어라인으로 본문 그리드 전체 높이 구분선을 그리고, 그리드·구분선 폭을 <code>--nav-cur</code>로 동기화. learner-home 로딩 스켈레톤 밀도도 실제 카드 구조 모사로 보강했다.</p></div><div><b>판정</b><p>구현 완료. 검증: <code>npm run typecheck</code> PASS + <code>vite build</code> PASS. 전 페이지 시각 회귀 게이트는 web+api+DB 스택으로 이 워크스테이션 미실행 — 스택 가용 시 1회 시각 확인 권장.</p></div></div>
|
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>잔여(비차단) — 공용 셸 단일 항목 → 처리</b><p>축소 사이드바 세로 구분선이 본문 전체 높이까지 닿지 않던 건을 <code>components/shell/shell.css</code>에서 처리했다. <code>.vg-nav border-right</code> 제거 후 <code>.vg-shell__body</code> 컨테이너 배경 하어라인으로 본문 그리드 전체 높이 구분선을 그리고, 그리드·구분선 폭을 <code>--nav-cur</code>로 동기화. learner-home 로딩 스켈레톤 밀도도 실제 카드 구조 모사로 보강했다.</p></div><div><b>판정</b><p>구현 완료. 검증: <code>npm run typecheck</code> PASS + <code>vite build</code> PASS. 전 페이지 시각 회귀 게이트는 web+api+DB 스택으로 이 워크스테이션 미실행 — 스택 가용 시 1회 시각 확인 권장.</p></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-subhead"><h4>검증 실행 상세 표</h4><p>요약하지 않고 실행 범위, 명령, 결과를 행 단위로 남긴다.</p></div>
|
<div class="tab-subhead"><h4>검증 실행 상세 표</h4><p>요약하지 않고 실행 범위, 명령, 결과를 행 단위로 남긴다.</p></div>
|
||||||
|
|
@ -786,7 +786,7 @@
|
||||||
<tr><td>Contract SSOT aggregate DTO</td><td><code>npm run typecheck</code> / <code>npm run build</code> / <code>session persistence</code> / <code>learner + session-review + teacher + layout gate</code></td><td>Passed; learner sessions, session review/worksheet, teacher dashboard, session start/detail DTOs now use generated <code>ApiSchema</code> aliases with UI fallback. Stage responses are OpenAPI enum unions. Focused E2E 32 passed. <code>.github/workflows/api-contract.yml</code> now runs <code>npm run check:api-types</code> on API/Web contract changes.</td></tr>
|
<tr><td>Contract SSOT aggregate DTO</td><td><code>npm run typecheck</code> / <code>npm run build</code> / <code>session persistence</code> / <code>learner + session-review + teacher + layout gate</code></td><td>Passed; learner sessions, session review/worksheet, teacher dashboard, session start/detail DTOs now use generated <code>ApiSchema</code> aliases with UI fallback. Stage responses are OpenAPI enum unions. Focused E2E 32 passed. <code>.github/workflows/api-contract.yml</code> now runs <code>npm run check:api-types</code> on API/Web contract changes.</td></tr>
|
||||||
<tr><td>Backend pytest</td><td><code>python -m pytest app/ -q</code></td><td>130 passed</td></tr>
|
<tr><td>Backend pytest</td><td><code>python -m pytest app/ -q</code></td><td>130 passed</td></tr>
|
||||||
<tr><td>X2 evaluator model routing</td><td><code>python -m pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q</code></td><td>23 passed; fast/deep evaluator model override and blank-default routing covered</td></tr>
|
<tr><td>X2 evaluator model routing</td><td><code>python -m pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q</code></td><td>23 passed; fast/deep evaluator model override and blank-default routing covered</td></tr>
|
||||||
<tr><td>C1 case worksheet</td><td><code>python -m pytest app/test_session_turn_persistence.py -q</code> / <code>session-review.spec.ts</code></td><td>14 passed; session review exposes transcript-grounded <code>caseWorksheet</code> and UI evidence jump</td></tr>
|
<tr><td>C1 case worksheet</td><td><code>python -m pytest app/test_session_turn_persistence.py app/test_rbac_idor.py -q</code> / <code>npm run check:api-types</code> / <code>npm run typecheck</code> / <code>npm run build</code> / local API smoke</td><td>25 passed; session review exposes transcript-grounded <code>caseWorksheet</code>, learner-edited <code>saved_by_learner</code> worksheet persists through <code>app.case_worksheet</code>, and evaluator-only turns stay hidden. Local smoke returned <code>saved_by_learner:local smoke saved worksheet</code>. Stage labels are normalized at response boundaries.</td></tr>
|
||||||
<tr><td>M3 auth claim mapping</td><td><code>python -m pytest app/test_auth_providers.py -q</code></td><td>22 passed; Google/SAML cohort maps, provider external_id, provider error reason covered</td></tr>
|
<tr><td>M3 auth claim mapping</td><td><code>python -m pytest app/test_auth_providers.py -q</code></td><td>22 passed; Google/SAML cohort maps, provider external_id, provider error reason covered</td></tr>
|
||||||
<tr><td>CrisisGate 109</td><td><code>python -m pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q</code></td><td>20 passed; real crisis stops before engine, returns 109 resource, safety event DB insert payload covered</td></tr>
|
<tr><td>CrisisGate 109</td><td><code>python -m pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q</code></td><td>20 passed; real crisis stops before engine, returns 109 resource, safety event DB insert payload covered</td></tr>
|
||||||
<tr><td>Professor learner growth</td><td><code>python -m pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q</code></td><td>21 passed; teacher dashboard exposes learner_growth and safety alert queue</td></tr>
|
<tr><td>Professor learner growth</td><td><code>python -m pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q</code></td><td>21 passed; teacher dashboard exposes learner_growth and safety alert queue</td></tr>
|
||||||
|
|
@ -831,7 +831,7 @@
|
||||||
<tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr>
|
<tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr>
|
||||||
<tr><td>Learner screenshots</td><td><code>learn-empty-desktop.png / learn-empty-mobile-compact.png</code></td><td>document overflow 0, empty history visible</td></tr>
|
<tr><td>Learner screenshots</td><td><code>learn-empty-desktop.png / learn-empty-mobile-compact.png</code></td><td>document overflow 0, empty history visible</td></tr>
|
||||||
<tr><td>Session layout/turn</td><td><code>session-layout.spec.ts desktop/mobile</code></td><td>8 passed</td></tr>
|
<tr><td>Session layout/turn</td><td><code>session-layout.spec.ts desktop/mobile</code></td><td>8 passed</td></tr>
|
||||||
<tr><td>Session end + dark theme UX</td><td><code>npm run typecheck</code>, <code>npm run build</code>, <code>layout-visual-gate</code>, <code>settings</code>, <code>session-review</code></td><td>typecheck/build passed; Playwright 묶음 22 passed(layout visual gate 7, settings desktop/mobile+single-run 13, session-review desktop/mobile 2). Settings now treats API <code>system</code> preference as current initial theme instead of forcing light.</td></tr>
|
<tr><td>Session end + dark theme UX</td><td><code>npm run typecheck</code>, <code>npm run build</code>, <code>layout-visual-gate</code>, <code>session-layout</code>, <code>avatar-expression</code>, <code>settings</code>, <code>session-review</code></td><td>typecheck/build passed; Playwright 묶음 36 passed. 7개 주요 화면 x 3뷰포트 수동 캡처 갱신(<code>docs/design-verification/full-pages</code>)도 overflow 0, 큰 흰 CSS 배경면 0. P1 active session은 <code>rasterArtSet="p1-concept"</code>, 상태 배지/아바타 겹침 없음. Settings now treats API <code>system</code> preference as current initial theme instead of forcing light.</td></tr>
|
||||||
<tr><td>Voice/session focused</td><td><code>voice + voice-success + session-layout</code></td><td>15 passed</td></tr>
|
<tr><td>Voice/session focused</td><td><code>voice + voice-success + session-layout</code></td><td>15 passed</td></tr>
|
||||||
<tr><td>Layout redesign handoff</td><td><code>docs/ops/layout-redesign-handoff-2026-06-26.md</code></td><td>subagent scopes, files, verification, remaining visual review recorded</td></tr>
|
<tr><td>Layout redesign handoff</td><td><code>docs/ops/layout-redesign-handoff-2026-06-26.md</code></td><td>subagent scopes, files, verification, remaining visual review recorded</td></tr>
|
||||||
<tr><td>Layout redesign focused E2E</td><td><code>npx playwright test e2e/learner.spec.ts e2e/session-layout.spec.ts e2e/session-review.spec.ts e2e/admin.spec.ts e2e/settings.spec.ts e2e/teacher.spec.ts --project=chromium-desktop --project=chromium-mobile</code></td><td>54 passed after isolated Admin tablet flake rerun</td></tr>
|
<tr><td>Layout redesign focused E2E</td><td><code>npx playwright test e2e/learner.spec.ts e2e/session-layout.spec.ts e2e/session-review.spec.ts e2e/admin.spec.ts e2e/settings.spec.ts e2e/teacher.spec.ts --project=chromium-desktop --project=chromium-mobile</code></td><td>54 passed after isolated Admin tablet flake rerun</td></tr>
|
||||||
|
|
@ -886,7 +886,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime
|
||||||
<summary><span class="fcaret" aria-hidden="true"></span>이번 세션 내러티브 (원문 보존)<span class="fmeta">team lead review</span></summary>
|
<summary><span class="fcaret" aria-hidden="true"></span>이번 세션 내러티브 (원문 보존)<span class="fmeta">team lead review</span></summary>
|
||||||
<div class="fold-inner">
|
<div class="fold-inner">
|
||||||
<p style="color:var(--ink-2);font-size:13px;max-width:840px">
|
<p style="color:var(--ink-2);font-size:13px;max-width:840px">
|
||||||
이번 세션은 seed persona 결합 제거, 비개발 fail-closed 설정, compose OAuth 주입, admin 모바일 overflow 수정, persona 인증 경계 보강, 전체 Playwright 재통과, 공개 API prod 전환까지 완료했다. 이번 이어받기에서는 내담자 AI 생성 턴의 provider/model/token/cost 텔레메트리를 <code>/turn</code>, SSE stream, 음성 생성 경로에서 DB <code>app.turns</code>까지 적재하도록 연결했고, gateway stream <code>done/error</code> 계약을 구조화했으며, P1 상담 시작→텍스트 stream→종료→리뷰 피드백 UI E2E와 저항 엔진 정량 회귀 테스트를 추가했다. 이어서 PII 마스킹 게이트 우회 차단, RBAC/IDOR·감사 insert 경로, 음성 paralinguistic 메타 저장, 공개 런타임 watchdog 스크립트와 runbook을 보강했다. 추가로 상주 engine session reuse, SAML provider 스캐폴드, <code>visible_to</code> 런타임 필터, EOT/TTS payload 계약까지 회귀화했다. 백엔드 회귀 테스트는 84개로 재검증했고, 페르소나 승인/반려 API와 교수 콘솔 검수 UI proof를 추가했다. Postgres RLS/audit live smoke checker와 s2s 2차 PoC 판단 메모도 준비했지만, live DB 실행과 s2s 채택 결정은 별도 증거가 필요하다. 이후 로컬 5175 런타임 재기동, admin 사용자 관리 E2E 안정화, 학습자 홈 0회 기록 UX, active session 실제 SSE 스트림 전환, 텍스트/음성 실패 턴 미저장, 직접 URL persona 우회 차단, Live2D demo 자산 제거, AI 운영 fail-closed 보강까지 전체 Playwright로 확인했다. 2026-06-26 15:18 KST 기준 공개 API 터널을 복구한 뒤 전체 Playwright 72개가 desktop/mobile/single-run 모두 통과했다. Cloudflare Pages production 배포 <code>2a58ca49</code>도 완료했고, 커스텀 도메인이 새 asset을 서빙하는 것을 확인했다. 과거 Mao/Haru Live2D demo 자산은 repo와 번들에서 제거했고, <code>/live2d/*</code> 운영 경로는 404로 차단했다. 현재 런타임은 prod API 8001, dev API 8000/8010, web 5173, engine gateway 9099, cloudflared tunnel 1개를 의도적으로 유지한다. 이번 라운드에서는 레이아웃 작업을 서브에이전트 전담으로 분리해 세션, 학습자 홈/리뷰, 관리자/교수자, 설정 화면을 overflow 수리 수준이 아니라 실제 작업 화면 재설계 수준으로 갱신했다. 통합 레이아웃 E2E는 desktop/mobile 54개를 최종 통과했고, 자세한 인수인계는 <code>docs/ops/layout-redesign-handoff-2026-06-26.md</code>에 남겼다. 이번 이어받기에서는 핸드오프가 남긴 "실기기 시각 수용"을 엄격한 자동 게이트로 닫았다. 7개 권장 너비(390/720/861/900/1024/1280/1440)에서 7개 재설계 화면의 가로 overflow 0·컨트롤 클립 0을 강제하는 <code>layout-visual-gate.spec.ts</code>(7 passed, 49장 스크린샷)를 만들고, 화면별 1에이전트 병렬 시각 리뷰 → 6개 병렬 수정 에이전트 → 화면별 적대적 재검수까지 돌려 7/7 accept(critical/major 0, 회귀 0)를 확정했다. 학습자홈 1280 3열 가운데 컬럼 붕괴(critical)와 교수 'API 404' raw 배너를 포함한 결함을 모두 해소했고, 레이아웃 항목을 DONE으로 닫았다. 이번 로그인 복구 라운드에서는 dev/Tailnet OAuth direct start까지 <code>local_oauth_unavailable</code>로 막고, public OAuth state를 HttpOnly 쿠키에 묶인 서명 토큰으로 바꿔 API 재시작 후 콜백이 <code>invalid_state</code>로 실패하지 않게 했으며, callback 실패 reason/status 진단 로그와 화면 메시지 분기를 추가했다. 추가로 Google provider error를 <code>access_denied</code>/<code>provider_error</code>로 분리하고 로그인 화면에 reason code를 노출했다. 이어서 C1 사례개념화 워크시트 1차 구조를 리뷰 API와 화면에 붙여 축어록 근거 기반 read-only 초안을 제공한다. 공개 Google OAuth 실제 <code>/turn</code> 증명만 storageState 캡처 후 남아 있다.
|
이번 세션은 seed persona 결합 제거, 비개발 fail-closed 설정, compose OAuth 주입, admin 모바일 overflow 수정, persona 인증 경계 보강, 전체 Playwright 재통과, 공개 API prod 전환까지 완료했다. 이번 이어받기에서는 내담자 AI 생성 턴의 provider/model/token/cost 텔레메트리를 <code>/turn</code>, SSE stream, 음성 생성 경로에서 DB <code>app.turns</code>까지 적재하도록 연결했고, gateway stream <code>done/error</code> 계약을 구조화했으며, P1 상담 시작→텍스트 stream→종료→리뷰 피드백 UI E2E와 저항 엔진 정량 회귀 테스트를 추가했다. 이어서 PII 마스킹 게이트 우회 차단, RBAC/IDOR·감사 insert 경로, 음성 paralinguistic 메타 저장, 공개 런타임 watchdog 스크립트와 runbook을 보강했다. 추가로 상주 engine session reuse, SAML provider 스캐폴드, <code>visible_to</code> 런타임 필터, EOT/TTS payload 계약까지 회귀화했다. 백엔드 회귀 테스트는 84개로 재검증했고, 페르소나 승인/반려 API와 교수 콘솔 검수 UI proof를 추가했다. Postgres RLS/audit live smoke checker와 s2s 2차 PoC 판단 메모도 준비했지만, live DB 실행과 s2s 채택 결정은 별도 증거가 필요하다. 이후 로컬 5175 런타임 재기동, admin 사용자 관리 E2E 안정화, 학습자 홈 0회 기록 UX, active session 실제 SSE 스트림 전환, 텍스트/음성 실패 턴 미저장, 직접 URL persona 우회 차단, Live2D demo 자산 제거, AI 운영 fail-closed 보강까지 전체 Playwright로 확인했다. 2026-06-26 15:18 KST 기준 공개 API 터널을 복구한 뒤 전체 Playwright 72개가 desktop/mobile/single-run 모두 통과했다. Cloudflare Pages production 배포 <code>2a58ca49</code>도 완료했고, 커스텀 도메인이 새 asset을 서빙하는 것을 확인했다. 과거 Mao/Haru Live2D demo 자산은 repo와 번들에서 제거했고, <code>/live2d/*</code> 운영 경로는 404로 차단했다. 현재 런타임은 prod API 8001, dev API 8000/8010, web 5173, engine gateway 9099, cloudflared tunnel 1개를 의도적으로 유지한다. 이번 라운드에서는 레이아웃 작업을 서브에이전트 전담으로 분리해 세션, 학습자 홈/리뷰, 관리자/교수자, 설정 화면을 overflow 수리 수준이 아니라 실제 작업 화면 재설계 수준으로 갱신했다. 통합 레이아웃 E2E는 desktop/mobile 54개를 최종 통과했고, 자세한 인수인계는 <code>docs/ops/layout-redesign-handoff-2026-06-26.md</code>에 남겼다. 이번 이어받기에서는 핸드오프가 남긴 "실기기 시각 수용"을 엄격한 자동 게이트로 닫았다. 7개 권장 너비(390/720/861/900/1024/1280/1440)에서 7개 재설계 화면의 가로 overflow 0·컨트롤 클립 0을 강제하는 <code>layout-visual-gate.spec.ts</code>(7 passed, 49장 스크린샷)를 만들고, 화면별 1에이전트 병렬 시각 리뷰 → 6개 병렬 수정 에이전트 → 화면별 적대적 재검수까지 돌려 7/7 accept(critical/major 0, 회귀 0)를 확정했다. 학습자홈 1280 3열 가운데 컬럼 붕괴(critical)와 교수 'API 404' raw 배너를 포함한 결함을 모두 해소했고, 레이아웃 항목을 DONE으로 닫았다. 이번 로그인 복구 라운드에서는 dev/Tailnet OAuth direct start까지 <code>local_oauth_unavailable</code>로 막고, public OAuth state를 HttpOnly 쿠키에 묶인 서명 토큰으로 바꿔 API 재시작 후 콜백이 <code>invalid_state</code>로 실패하지 않게 했으며, callback 실패 reason/status 진단 로그와 화면 메시지 분기를 추가했다. 추가로 Google provider error를 <code>access_denied</code>/<code>provider_error</code>로 분리하고 로그인 화면에 reason code를 노출했다. 이어서 C1 사례개념화 워크시트는 리뷰 API·편집 UI·<code>app.case_worksheet</code> 저장/재조회 경로까지 2차로 연결했다. 공개 Google OAuth 실제 <code>/turn</code> 증명만 storageState 캡처 후 남아 있다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -277,7 +277,10 @@ server: ready → state(listening) → state(thinking) → transcript → reply
|
||||||
- `GET /sessions/{id}/review` — 저장된 축어록 + 평가 AI 산출물로 학습자-안전 리뷰 구성
|
- `GET /sessions/{id}/review` — 저장된 축어록 + 평가 AI 산출물로 학습자-안전 리뷰 구성
|
||||||
(`_rubric_from_evaluation`, `_ai_review_points` 등). 리뷰 조회 시 발화별 fast-loop 평가는
|
(`_rubric_from_evaluation`, `_ai_review_points` 등). 리뷰 조회 시 발화별 fast-loop 평가는
|
||||||
`app.feedback_scores`/라벨 조인 테이블에서 `TurnRecord.evaluation` 형태로 hydrate한다.
|
`app.feedback_scores`/라벨 조인 테이블에서 `TurnRecord.evaluation` 형태로 hydrate한다.
|
||||||
평가 미완이면 `degraded/reviewReady=false`로 표기.
|
평가 미완이면 `degraded/reviewReady=false`로 표기. 학습자가 저장한 사례개념화 워크시트가 있으면
|
||||||
|
자동 초안보다 `saved_by_learner` 저장본을 우선 반환한다.
|
||||||
|
- `PUT /sessions/{id}/review/worksheet` — 학습자가 수정한 사례개념화 워크시트를 저장한다.
|
||||||
|
저장본은 `app.case_worksheet`에 session 단위로 upsert되며, owner learner 또는 teacher/admin RLS 범위에서만 읽힌다.
|
||||||
|
|
||||||
발화 가시성: 학습자에게는 `visible_to`에 `counselor`가 포함된 턴만 보여준다
|
발화 가시성: 학습자에게는 `visible_to`에 `counselor`가 포함된 턴만 보여준다
|
||||||
(`_learner_visible_turns` → `_LEARNER_VISIBLE_AI_ROLE="counselor"`). 평가 전용 데이터는 노출되지 않는다.
|
(`_learner_visible_turns` → `_LEARNER_VISIBLE_AI_ROLE="counselor"`). 평가 전용 데이터는 노출되지 않는다.
|
||||||
|
|
@ -409,6 +412,8 @@ DB는 PostgreSQL 16 + pgvector(단일 SoR). 초기화 SQL은 `infra/db/init/`에
|
||||||
`visible_to TEXT[]`(기본 `{client,counselor,evaluator}`), salience/is_pinned/contradicts,
|
`visible_to TEXT[]`(기본 `{client,counselor,evaluator}`), salience/is_pinned/contradicts,
|
||||||
음성 paralinguistic(audio_ref/silence_ms/speech_rate/barge_in). `turn_technique`/`turn_client_state`
|
음성 paralinguistic(audio_ref/silence_ms/speech_rate/barge_in). `turn_technique`/`turn_client_state`
|
||||||
다대다, `supervisor_comment`(rationale/critique + intent_deviation JSONB), `safety_events`.
|
다대다, `supervisor_comment`(rationale/critique + intent_deviation JSONB), `safety_events`.
|
||||||
|
- **사례개념화 제출물** `app.case_worksheet` — 회기 리뷰의 축어록 기반 자동 초안을 학습자가 편집해 저장한 JSONB.
|
||||||
|
`session_id` 단위 upsert이며, `GET /review`에서 자동 초안보다 우선된다.
|
||||||
- **메모리 4계층**:
|
- **메모리 4계층**:
|
||||||
- ① WORKING `app.session_state` — 상태머신 수치 체크포인트(매 턴 UPSERT, openness/ideation CHECK 제약).
|
- ① WORKING `app.session_state` — 상태머신 수치 체크포인트(매 턴 UPSERT, openness/ideation CHECK 제약).
|
||||||
- ② EPISODIC 임베딩 `app.turn_embedding` — BGE-M3 dense `vector(1024)` + sparse, HNSW 인덱스.
|
- ② EPISODIC 임베딩 `app.turn_embedding` — BGE-M3 dense `vector(1024)` + sparse, HNSW 인덱스.
|
||||||
|
|
|
||||||
|
|
@ -45,13 +45,13 @@
|
||||||
|
|
||||||
## 2. 갭 로드맵 — 심각도 우선순위
|
## 2. 갭 로드맵 — 심각도 우선순위
|
||||||
|
|
||||||
심각도 분류: **critical 3 / high 4 / medium+ 6**. C1의 "구조 전무"는 1차로 해소됐고, 편집·저장·임상 루브릭·AI 채점은 계속 추적한다. `✓`는 작성자가 코드 grep으로 직접 재확인한 항목, `(분석)`은 분석 결과로 착수 전 코드 1차 재확인 권고.
|
심각도 분류: **critical 3 / high 4 / medium+ 6**. C1의 "구조 전무"와 저장형 제출물 부재는 2차로 해소됐고, 임상 루브릭·AI 채점·교수자 검수는 계속 추적한다. `✓`는 작성자가 코드 grep으로 직접 재확인한 항목, `(분석)`은 분석 결과로 착수 전 코드 1차 재확인 권고.
|
||||||
|
|
||||||
### Critical (3)
|
### Critical (3)
|
||||||
|
|
||||||
| ID | 갭 | 현재상태 | 권고 | 근거 |
|
| ID | 갭 | 현재상태 | 권고 | 근거 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **C1** | 사례개념화·치료계획 산출물 구조 1차 구현·고도화 필요 | `SessionReviewResponse.caseWorksheet`와 리뷰 화면 read-only 카드가 축어록 근거 기반 초안을 제공한다. CCD는 숨은 정답키이고, 현재 워크시트는 저장형 학습자 제출물은 아님. | 편집·DB 저장형 워크시트, AI 축어록 초안 추출 고도화, 규칙 기반 채점, 교수자 검수 플로우. 루브릭은 임상팀 외부 정의 가능하게 외부화. | doc1·doc4·doc5 |
|
| **C1** | 사례개념화·치료계획 산출물 저장형 구조 2차 구현·채점/검수 잔여 | `SessionReviewResponse.caseWorksheet`가 축어록 근거 기반 초안을 제공하고, 리뷰 화면에서 학습자가 편집한 워크시트를 `PUT /sessions/{id}/review/worksheet`로 `app.case_worksheet`에 저장한다. 이후 `GET /review`는 `saved_by_learner` 저장본을 자동 초안보다 우선 반환한다. CCD는 숨은 정답키로 남고 저장본에 점수로 노출하지 않는다. | AI 축어록 초안 추출 고도화, 규칙 기반 채점, 교수자 검수 플로우. 루브릭은 임상팀 외부 정의 가능하게 외부화. | doc1·doc4·doc5 |
|
||||||
| **C2** | 위기개입 프로토콜·생명유지서약·에스컬레이션 1차 배선 완료·임상 고도화 필요 | 실제 자해·자살 신호는 LLM/엔진 호출 전 중단하고 109 리소스와 `conversation_stopped`를 REST/SSE/voice 응답에 싣는다. `crisis.risk_level`은 상태머신 `ideation_observed`로 전달하고, escalate 시 `app.safety_events` detail 적재 및 교수자 대시보드 안전 알림 큐로 연결한다. | 남은 것은 임상팀 콘텐츠: 비밀보장 예외고지→단계적 탐색→생명유지서약 스크립트, 실시간 push/메일 알림 정책, 위기탐색 누락 시 회기리뷰 감점 루브릭. | doc1·doc5(핵심 시나리오)·doc4(IRB 전제) |
|
| **C2** | 위기개입 프로토콜·생명유지서약·에스컬레이션 1차 배선 완료·임상 고도화 필요 | 실제 자해·자살 신호는 LLM/엔진 호출 전 중단하고 109 리소스와 `conversation_stopped`를 REST/SSE/voice 응답에 싣는다. `crisis.risk_level`은 상태머신 `ideation_observed`로 전달하고, escalate 시 `app.safety_events` detail 적재 및 교수자 대시보드 안전 알림 큐로 연결한다. | 남은 것은 임상팀 콘텐츠: 비밀보장 예외고지→단계적 탐색→생명유지서약 스크립트, 실시간 push/메일 알림 정책, 위기탐색 누락 시 회기리뷰 감점 루브릭. | doc1·doc5(핵심 시나리오)·doc4(IRB 전제) |
|
||||||
| **C3** | 이론모드 1차 배선 완료·CBT 콘텐츠/선택 UI 잔여 | `TurnContext`/`prepare_turn`/sessions/voice/evaluator에 `theory_mode`가 전달되고, `build_turn_messages`도 인간중심·CBT·통합 이론 프레이밍을 엔진 메시지에 넣는다. 프론트는 `persona.theory_target` 기준으로 시작해 기존 `humanistic` 하드코딩을 제거했다. | 임상팀이 확정한 CBT 단계 프롬프트 체인, 이론부합 채점 루브릭, 학습자/교수자용 명시적 이론 선택 UI. 현재는 페르소나 설계 이론 자동 선택이다. | doc4(humanistic+CBT 필수)·doc2/doc5 |
|
| **C3** | 이론모드 1차 배선 완료·CBT 콘텐츠/선택 UI 잔여 | `TurnContext`/`prepare_turn`/sessions/voice/evaluator에 `theory_mode`가 전달되고, `build_turn_messages`도 인간중심·CBT·통합 이론 프레이밍을 엔진 메시지에 넣는다. 프론트는 `persona.theory_target` 기준으로 시작해 기존 `humanistic` 하드코딩을 제거했다. | 임상팀이 확정한 CBT 단계 프롬프트 체인, 이론부합 채점 루브릭, 학습자/교수자용 명시적 이론 선택 UI. 현재는 페르소나 설계 이론 자동 선택이다. | doc4(humanistic+CBT 필수)·doc2/doc5 |
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@
|
||||||
|
|
||||||
### A. 즉시 착수 가능 (내부 코드, 외부 합의 불요)
|
### A. 즉시 착수 가능 (내부 코드, 외부 합의 불요)
|
||||||
|
|
||||||
- **C1 1차 완료**: `caseWorksheet` 응답 구조와 리뷰 화면 read-only 워크시트 카드. 후속은 저장형 입력 폼·임상 루브릭·AI 추출/채점.
|
- **C1 2차 완료**: `caseWorksheet` 응답 구조, 리뷰 화면 편집 UI, `app.case_worksheet` 저장/재조회 경로. 후속은 임상 루브릭·AI 추출/채점·교수자 검수.
|
||||||
- **C2 1차 완료**: 위기 신호는 엔진 전 중단, 109 안내, `safety_events` 적재, 교수자 알림 큐, `ideation_observed` 전달까지 배선했다. 후속은 임상 스크립트·서약 문안·감점 루브릭.
|
- **C2 1차 완료**: 위기 신호는 엔진 전 중단, 109 안내, `safety_events` 적재, 교수자 알림 큐, `ideation_observed` 전달까지 배선했다. 후속은 임상 스크립트·서약 문안·감점 루브릭.
|
||||||
- **C3 1차 완료**: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI.
|
- **C3 1차 완료**: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI.
|
||||||
- **H2 1차 완료**: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋.
|
- **H2 1차 완료**: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋.
|
||||||
|
|
@ -114,8 +114,8 @@
|
||||||
### 코드 재확인 grep 예시 (저장소 루트 `D:/workspace/vignette`)
|
### 코드 재확인 grep 예시 (저장소 루트 `D:/workspace/vignette`)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# C1: 사례개념화 워크시트 1차 구조와 잔여 저장/채점 경로 확인
|
# C1: 사례개념화 워크시트 저장형 구조와 잔여 채점/검수 경로 확인
|
||||||
rg -n "caseWorksheet|ReviewCaseWorksheet|사례개념화 워크시트|cognitive_triad|인지삼제|4사분면" apps
|
rg -n "caseWorksheet|ReviewCaseWorksheet|case_worksheet|save_session_review_worksheet|사례개념화 워크시트|인지삼제|4사분면" apps infra
|
||||||
|
|
||||||
# C2: escalate 시 safety_events 적재 여부
|
# C2: escalate 시 safety_events 적재 여부
|
||||||
rg -n "safety_events|escalate|ideation_observed" apps/api/app/services/orchestrator.py
|
rg -n "safety_events|escalate|ideation_observed" apps/api/app/services/orchestrator.py
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,19 @@
|
||||||
|
|
||||||
분류: **B1 비차단 폴리시** · **B2 환경 제약(증거 생산 불가)** · **B3 소유자 결정** · **B4 외부 거버넌스**
|
분류: **B1 비차단 폴리시** · **B2 환경 제약(증거 생산 불가)** · **B3 소유자 결정** · **B4 외부 거버넌스**
|
||||||
|
|
||||||
> **B0. 원천문서 갭 분석 (2026-06-26 추가)** — 한신대 산학협력 원천문서 5종 정독으로 도출한 "부족한 부분"(critical 3 / high 4 / medium+ 6)은 **SSOT 대시보드** `docs/dev_dashboard.html` "원천문서 갭 분석" 섹션과 상세 `docs/ops/source-docs-gap-analysis-2026-06-26.md`에서 추적한다. C1 사례개념화 산출물, C2 위기개입 프로토콜, C3 이론모드 스레딩은 1차 구조를 만들었다. 콘텐츠 정의는 임상팀(구훈정·어유경) 소유라 코드는 구조를 선제 구축하되 임상 문안과 평가기준은 외부 정의로 받는다.
|
> **B0. 원천문서 갭 분석 (2026-06-26 추가)** — 한신대 산학협력 원천문서 5종 정독으로 도출한 "부족한 부분"(critical 3 / high 4 / medium+ 6)은 **SSOT 대시보드** `docs/dev_dashboard.html` "원천문서 갭 분석" 섹션과 상세 `docs/ops/source-docs-gap-analysis-2026-06-26.md`에서 추적한다. C1 사례개념화 산출물은 저장형 워크시트까지 2차 구조를 만들었고, C2 위기개입 프로토콜과 C3 이론모드 스레딩은 1차 구조를 만들었다. 콘텐츠 정의는 임상팀(구훈정·어유경) 소유라 코드는 구조를 선제 구축하되 임상 문안과 평가기준은 외부 정의로 받는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## B1. 비차단 코스메틱 폴리시
|
## B1. 비차단 코스메틱 폴리시
|
||||||
|
|
||||||
- [x] **공용 셸 사이드바 구분선 전체 높이** — (2026-06-26 처리) `.vg-nav border-right`(sticky·뷰포트 높이)를 제거하고, `.vg-shell__body` 컨테이너 배경 하어라인(`linear-gradient` 1px×100%, `--nav-cur`로 그리드·구분선 폭 동기화)으로 본문 그리드 전체 높이를 따라가는 세로 구분선을 그린다. 1024 축소폭은 `--nav-cur: var(--nav-w-collapsed)`로, 720 이하(상단 가로바 전환)는 `background-image:none`으로 정합. `components/shell/shell.css`. **검증: `npm run typecheck` PASS + `vite build` PASS.** 시각 회귀 게이트(gate 7/7 + 포커스 E2E + session-layout 8/8)는 web+api+DB 스택(Docker 미기동·secrets 부재)으로 이 환경 미실행 — 스택 가용 시 1회 시각 확인 권장.
|
- [x] **공용 셸 사이드바 구분선 전체 높이** — (2026-06-26 처리) `.vg-nav border-right`(sticky·뷰포트 높이)를 제거하고, `.vg-shell__body` 컨테이너 배경 하어라인(`linear-gradient` 1px×100%, `--nav-cur`로 그리드·구분선 폭 동기화)으로 본문 그리드 전체 높이를 따라가는 세로 구분선을 그린다. 1024 축소폭은 `--nav-cur: var(--nav-w-collapsed)`로, 720 이하(상단 가로바 전환)는 `background-image:none`으로 정합. `components/shell/shell.css`. **검증: `npm run typecheck` PASS + `vite build` PASS.** 시각 회귀 게이트(gate 7/7 + 포커스 E2E + session-layout 8/8)는 web+api+DB 스택(Docker 미기동·secrets 부재)으로 이 환경 미실행 — 스택 가용 시 1회 시각 확인 권장.
|
||||||
- [x] **세션 종료 UX와 dark theme SSOT** — (2026-06-27 처리) 오발동과 모바일 조작 불안을 줄이기 위해 세션 하단의 드래그형 `SlideToEnd`를 명시 확인 다이얼로그로 교체했다. Topbar/Settings의 `vignette.theme` 읽기·저장·`data-theme` 적용은 `apps/web/src/lib/theme.ts` 단일 경로로 통합했고, 저장값이 없으면 dark 기본값을 부팅 전에 적용한다. API 기본 preference `system`은 Settings에서 light로 오해하지 않고 현재 초기 테마를 따른다. 회기 리뷰·설정 포함 주요 페이지의 흰 섹션 잔재도 dark 작업면으로 맞췄다. **검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/settings.spec.ts e2e/session-review.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1` 22 passed.**
|
- [x] **세션 종료 UX와 dark theme SSOT** — (2026-06-27 처리) 오발동과 모바일 조작 불안을 줄이기 위해 세션 하단의 드래그형 `SlideToEnd`를 명시 확인 다이얼로그로 교체했다. Topbar/Settings의 `vignette.theme` 읽기·저장·`data-theme` 적용은 `apps/web/src/lib/theme.ts` 단일 경로로 통합했고, 저장값이 없으면 dark 기본값을 부팅 전에 적용한다. API 기본 preference `system`은 Settings에서 light로 오해하지 않고 현재 초기 테마를 따른다. 회기 리뷰·설정 포함 주요 페이지의 흰 섹션 잔재도 dark 작업면으로 맞췄다. **검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts e2e/avatar-expression.spec.ts e2e/session-review.spec.ts e2e/settings.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1` 36 passed. 추가 시각 검사: 7개 주요 화면 x 3뷰포트 캡처 21장, horizontal overflow 0, 큰 흰 CSS 배경면 0.**
|
||||||
|
- [x] **P1 세션 아바타 컨셉 보드 임시 정렬** — (2026-06-27 처리) 긴 머리 PSB 기본값이 현재 제품 컨셉과 어긋나 active session 첫인상이 흔들렸다. 정식 Image #2 재생성 전까지 `docs/design-concepts/generated/09-avatar-pattern-elements.png`에서 크롭한 래스터 `apps/web/public/avatar/p1-concept/neutral.png`를 P1 기본값으로 연결하고, 데스크톱 스테이지를 3행 구조로 바꿔 현재 상태 배지가 아바타와 겹치지 않게 했다. 이 자산은 누끼가 아니라 크롭이며 Python 분리 처리를 쓰지 않았다. **검증: P1 active session PC/태블릿/모바일 캡처, `rasterArtSet="p1-concept"`, 상태 배지/아바타 겹침 없음, overflow 0.**
|
||||||
- [ ] **빈상태 컬럼 높이 여백** — session-review 1280 masonry 우측 컬럼, session-prestart 우측 '진행 초점' 컬럼, learner-home 1440 하단 등 데이터 0건 빈상태에서만 두드러지는 컬럼 높이 불균형/여백. 실데이터면 채워진다. **(미착수, 의도적)** 0건 전용 시각 균형이라 러닝 앱에서의 시각 반복 없이 블라인드 수정 시 악화 위험 → 스택 가용 시 시각 확인하며 처리. 빈상태 전용 균형 레이아웃 채택 자체가 별도 폴리시 결정.
|
- [ ] **빈상태 컬럼 높이 여백** — session-review 1280 masonry 우측 컬럼, session-prestart 우측 '진행 초점' 컬럼, learner-home 1440 하단 등 데이터 0건 빈상태에서만 두드러지는 컬럼 높이 불균형/여백. 실데이터면 채워진다. **(미착수, 의도적)** 0건 전용 시각 균형이라 러닝 앱에서의 시각 반복 없이 블라인드 수정 시 악화 위험 → 스택 가용 시 시각 확인하며 처리. 빈상태 전용 균형 레이아웃 채택 자체가 별도 폴리시 결정.
|
||||||
- [x] **learner-home 로딩 스켈레톤 밀도(390)** — (2026-06-26 처리) '연습 대상' 로딩 스켈레톤을 빈 div 단일 셰이머에서 실제 카드 구조(마크 박스 + 이름/메타/요약 2줄 플레이스홀더)를 모사하도록 밀도 보강. `LearnerHome.tsx` (`.lh-skel__box`/`.lh-skel__line*`), reduced-motion 분기 동반 갱신. **검증: `npm run typecheck` PASS + `vite build` PASS.**
|
- [x] **learner-home 로딩 스켈레톤 밀도(390)** — (2026-06-26 처리) '연습 대상' 로딩 스켈레톤을 빈 div 단일 셰이머에서 실제 카드 구조(마크 박스 + 이름/메타/요약 2줄 플레이스홀더)를 모사하도록 밀도 보강. `LearnerHome.tsx` (`.lh-skel__box`/`.lh-skel__line*`), reduced-motion 분기 동반 갱신. **검증: `npm run typecheck` PASS + `vite build` PASS.**
|
||||||
|
|
||||||
검증 기준: 변경 후 `npm run typecheck`, `e2e/layout-visual-gate.spec.ts`(7/7), 레이아웃 포커스 E2E(54), `e2e/session-layout.spec.ts`(8/8) 무회귀. 이번 dark theme 라운드는 로컬 web+api+DB 스택 가용 상태에서 typecheck/build와 Playwright 묶음 22 passed까지 확인했다.
|
검증 기준: 변경 후 `npm run typecheck`, `e2e/layout-visual-gate.spec.ts`(7/7), 레이아웃 포커스 E2E(54), `e2e/session-layout.spec.ts`(8/8) 무회귀. 이번 dark theme/컨셉 정렬 라운드는 로컬 web+api+DB 스택 가용 상태에서 typecheck/build, Playwright 묶음 36 passed, 21장 캡처의 overflow 0/큰 흰 배경면 0까지 확인했다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -71,7 +72,7 @@
|
||||||
- 페르소나 저작 CRUD 2차: teacher/admin 전용 `POST/GET/PUT /personas/drafts` 경로를 추가해 draft 생성·조회·편집·검수요청을 열고, 교수 콘솔에 JSON 초안 작성/불러오기/수정 UI를 연결했다. 승인된 카드만 학습자 catalog에 노출하는 기존 경계는 유지한다. 추가로 `data/personas/P4.json`~`P7.json`을 저장소 관리 `PersonaCard`로 읽어 `materialize_seed_personas()`와 seed fallback catalog에 포함했다. 항목형 저작 UI, 루브릭·이론 콘텐츠 외부화, 임상팀 최종 검수/서면 evidence는 후속. **검증: `python -m pytest app/test_persona_review.py -q` 22 passed, 로컬 dev DB `materialize_seed_personas()` 결과 `approved_codes=P1,P2,P3,P4,P5,P6,P7`, 인증된 로컬 `GET /personas`도 `P1..P7` 반환.**
|
- 페르소나 저작 CRUD 2차: teacher/admin 전용 `POST/GET/PUT /personas/drafts` 경로를 추가해 draft 생성·조회·편집·검수요청을 열고, 교수 콘솔에 JSON 초안 작성/불러오기/수정 UI를 연결했다. 승인된 카드만 학습자 catalog에 노출하는 기존 경계는 유지한다. 추가로 `data/personas/P4.json`~`P7.json`을 저장소 관리 `PersonaCard`로 읽어 `materialize_seed_personas()`와 seed fallback catalog에 포함했다. 항목형 저작 UI, 루브릭·이론 콘텐츠 외부화, 임상팀 최종 검수/서면 evidence는 후속. **검증: `python -m pytest app/test_persona_review.py -q` 22 passed, 로컬 dev DB `materialize_seed_personas()` 결과 `approved_codes=P1,P2,P3,P4,P5,P6,P7`, 인증된 로컬 `GET /personas`도 `P1..P7` 반환.**
|
||||||
- M1 비언어 이벤트 1차: voice learner turn에 이미 저장하던 `audio_ref`/`silence_ms`/`speech_rate`/`barge_in`을 리뷰 API `nonverbal` 이벤트로 노출하고, 회기 리뷰 축어록에 침묵·발화 속도·끼어듦·음성 입력 칩을 표시했다. 한숨·울음 감지와 live 마이크/STT/TTS 실측은 후속. **검증: `pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q` 17 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `session-review.spec.ts` 1 passed.**
|
- M1 비언어 이벤트 1차: voice learner turn에 이미 저장하던 `audio_ref`/`silence_ms`/`speech_rate`/`barge_in`을 리뷰 API `nonverbal` 이벤트로 노출하고, 회기 리뷰 축어록에 침묵·발화 속도·끼어듦·음성 입력 칩을 표시했다. 한숨·울음 감지와 live 마이크/STT/TTS 실측은 후속. **검증: `pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q` 17 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `session-review.spec.ts` 1 passed.**
|
||||||
- M2 다회기 케이스 아크 1차: DB 세션 생성 시 `(persona_id, learner_id)` 기준 `case_profile`을 upsert하고, `session_no`를 트랜잭션 안에서 원자 증가시키며, `InProcSession.case_id`가 매회 새 `runtime_case_id`가 아니라 안정 `case_id`를 가리키게 수정했다. 시작/submit/stream/voice 경로는 직전 `session_summary` 기반 seed recall을 사용한다. `case_digest`/`pinned_fact` 실적재, episodic embedding writer, trajectory 갱신은 후속. **검증: `pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q` 18 passed, `pytest app/ -q` 119 passed, `npm run check:api-types`.**
|
- M2 다회기 케이스 아크 1차: DB 세션 생성 시 `(persona_id, learner_id)` 기준 `case_profile`을 upsert하고, `session_no`를 트랜잭션 안에서 원자 증가시키며, `InProcSession.case_id`가 매회 새 `runtime_case_id`가 아니라 안정 `case_id`를 가리키게 수정했다. 시작/submit/stream/voice 경로는 직전 `session_summary` 기반 seed recall을 사용한다. `case_digest`/`pinned_fact` 실적재, episodic embedding writer, trajectory 갱신은 후속. **검증: `pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q` 18 passed, `pytest app/ -q` 119 passed, `npm run check:api-types`.**
|
||||||
- C1 사례개념화 워크시트 1차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 read-only 카드가 탐색 11항목·호소 5영역·인지삼제/감정·보호/방해·생물심리사회 목표 초안을 축어록 근거 기반으로 제공한다. 편집·DB 저장, 임상 루브릭, AI 추출/채점, 교수자 검수는 후속. **검증: `pytest app/test_session_turn_persistence.py -q` 15 passed, `pytest app/ -q` 119 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `session-review.spec.ts` 1 passed.**
|
- C1 사례개념화 워크시트 2차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 카드가 탐색 11항목·호소 5영역·인지삼제/감정·보호/방해·생물심리사회 목표 초안을 축어록 근거 기반으로 제공한다. 학습자가 편집한 저장본은 `PUT /sessions/{id}/review/worksheet`로 `app.case_worksheet`에 저장되고, 이후 `GET /review`는 `saved_by_learner` 저장본을 자동 초안보다 우선 반환한다. 임상 루브릭, AI 추출/채점, 교수자 검수는 후속. **검증: `pytest app/test_session_turn_persistence.py app/test_rbac_idor.py -q` 25 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, 로컬 API smoke 세션 생성→저장→`GET /review` `saved_by_learner:local smoke saved worksheet`.**
|
||||||
- C3 이론모드 1차: `theory_mode`가 `TurnContext`/sessions/voice/evaluator뿐 아니라 `build_turn_messages`의 엔진 메시지까지 전달된다. 프론트는 `persona.theory_target` 기준으로 시작해 `humanistic` 하드코딩을 제거했다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속. **검증: `pytest app/test_orchestrator_masking.py app/test_session_turn_persistence.py -q` 20 passed, `pytest app/ -q` 119 passed.**
|
- C3 이론모드 1차: `theory_mode`가 `TurnContext`/sessions/voice/evaluator뿐 아니라 `build_turn_messages`의 엔진 메시지까지 전달된다. 프론트는 `persona.theory_target` 기준으로 시작해 `humanistic` 하드코딩을 제거했다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속. **검증: `pytest app/test_orchestrator_masking.py app/test_session_turn_persistence.py -q` 20 passed, `pytest app/ -q` 119 passed.**
|
||||||
- M3 인증 claim 1차: Google/SAML/dev-login이 `AUTH_EMAIL_COHORT_MAP`·`AUTH_DOMAIN_COHORT_MAP` 및 SAML cohort claim을 `cohort_ids`로 전달하고, DB `app_user.external_id`는 provider subject(`google:`/`saml:`/`dev:`) 기반으로 저장한다. 운영 SAML 서명검증, 기관 claim schema/test tenant, deprovisioning audit은 후속. **검증: `pytest app/test_auth_providers.py -q` 22 passed, `pytest app/ -q` 119 passed.**
|
- M3 인증 claim 1차: Google/SAML/dev-login이 `AUTH_EMAIL_COHORT_MAP`·`AUTH_DOMAIN_COHORT_MAP` 및 SAML cohort claim을 `cohort_ids`로 전달하고, DB `app_user.external_id`는 provider subject(`google:`/`saml:`/`dev:`) 기반으로 저장한다. 운영 SAML 서명검증, 기관 claim schema/test tenant, deprovisioning audit은 후속. **검증: `pytest app/test_auth_providers.py -q` 22 passed, `pytest app/ -q` 119 passed.**
|
||||||
- X2 예산 경고/저비용 평가 라우팅 1차: `ADMIN_USAGE_BUDGET_USD` 설정값을 기준으로 `GET /admin/usage`가 `budget.status=disabled|ok|warn|exceeded`, 사용률, 잔여 예산을 반환하고 관리자 콘솔이 예산 상태 배너를 표시한다. 80% 이상 warn, 100% 이상 exceeded. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL` 설정 시 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달하고, 비워두면 기존 gateway default 라우팅을 유지한다. semantic cache·장기 한도 정책은 후속. **검증: `pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q` 23 passed, `pytest app/ -q` 122 passed, `npm run check:api-types`, `npm run typecheck`.**
|
- X2 예산 경고/저비용 평가 라우팅 1차: `ADMIN_USAGE_BUDGET_USD` 설정값을 기준으로 `GET /admin/usage`가 `budget.status=disabled|ok|warn|exceeded`, 사용률, 잔여 예산을 반환하고 관리자 콘솔이 예산 상태 배너를 표시한다. 80% 이상 warn, 100% 이상 exceeded. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL` 설정 시 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달하고, 비워두면 기존 gateway default 라우팅을 유지한다. semantic cache·장기 한도 정책은 후속. **검증: `pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q` 23 passed, `pytest app/ -q` 122 passed, `npm run check:api-types`, `npm run typecheck`.**
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
| 주장 | 직접 검증 결과 |
|
| 주장 | 직접 검증 결과 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| C1 사례개념화/인지삼제/4사분면 구조 | `SessionReviewResponse.caseWorksheet`와 리뷰 화면 read-only 카드 1차 구현. 편집·저장·임상 루브릭·AI 채점은 후속 ✓ |
|
| C1 사례개념화/인지삼제/4사분면 구조 | `SessionReviewResponse.caseWorksheet`와 리뷰 화면 편집 카드, `app.case_worksheet` 저장/재조회 경로 2차 구현. 임상 루브릭·AI 채점·교수자 검수는 후속 ✓ |
|
||||||
| C2 위기 escalate 배선 | 실제 위기 신호는 엔진 호출 전 중단하고 109 리소스·`conversation_stopped`를 반환한다. `crisis.risk_level`은 `ideation_observed`로 전달되고, escalate 시 `app.safety_events` detail 적재 및 교수자 대시보드 알림 큐로 연결된다. 안전 이벤트 DB insert payload는 회귀 테스트로 고정했다. |
|
| C2 위기 escalate 배선 | 실제 위기 신호는 엔진 호출 전 중단하고 109 리소스·`conversation_stopped`를 반환한다. `crisis.risk_level`은 `ideation_observed`로 전달되고, escalate 시 `app.safety_events` detail 적재 및 교수자 대시보드 알림 큐로 연결된다. 안전 이벤트 DB insert payload는 회귀 테스트로 고정했다. |
|
||||||
| C3 이론모드 1차 배선 | `theory_mode`가 세션·평가·생성 프롬프트까지 전달되고 `Session.tsx` 하드코딩 제거 ✓ |
|
| C3 이론모드 1차 배선 | `theory_mode`가 세션·평가·생성 프롬프트까지 전달되고 `Session.tsx` 하드코딩 제거 ✓ |
|
||||||
| H2 턴별 평가 1차 가동 | `make_eval_hook`이 submit/voice에 주입되고 stream 평가도 붙어 정규화 적재·복원 ✓ |
|
| H2 턴별 평가 1차 가동 | `make_eval_hook`이 submit/voice에 주입되고 stream 평가도 붙어 정규화 적재·복원 ✓ |
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
|
|
||||||
원천문서가 요구하는 다수 차원 대비 Vignette가 **가장 부족한 핵심 3가지**:
|
원천문서가 요구하는 다수 차원 대비 Vignette가 **가장 부족한 핵심 3가지**:
|
||||||
|
|
||||||
1. **임상 산출물 구조의 1차 구현과 고도화 필요** — doc1/doc4/doc5가 1급 산출물로 요구하는 사례개념화 워크시트(11탐색항목·호소 5영역·인지삼제·1·2차 감정·보호/방해 4사분면·생물심리사회 목표)는 리뷰 API와 read-only UI 초안으로 1차 구현됐다. 편집·DB 저장, 임상 루브릭, AI 추출/채점, 교수자 검수는 아직 후속이다.
|
1. **임상 산출물 구조의 2차 구현과 고도화 필요** — doc1/doc4/doc5가 1급 산출물로 요구하는 사례개념화 워크시트(11탐색항목·호소 5영역·인지삼제·1·2차 감정·보호/방해 4사분면·생물심리사회 목표)는 리뷰 API, 편집 UI, `app.case_worksheet` 저장/재조회 경로로 2차 구현됐다. 임상 루브릭, AI 추출/채점, 교수자 검수는 아직 후속이다.
|
||||||
2. **위기개입 안전 프로토콜의 1차 배선 완료와 임상 고도화 필요** — 실제 위기 신호는 LLM 호출 전 중단하고 109 안내·`safety_events`·교수자 대시보드 알림 큐로 연결한다. 남은 것은 비밀보장 예외 고지, 자살사고 단계적 탐색, 생명유지서약, 위기탐색 누락 감점 같은 임상 콘텐츠·루브릭이다.
|
2. **위기개입 안전 프로토콜의 1차 배선 완료와 임상 고도화 필요** — 실제 위기 신호는 LLM 호출 전 중단하고 109 안내·`safety_events`·교수자 대시보드 알림 큐로 연결한다. 남은 것은 비밀보장 예외 고지, 자살사고 단계적 탐색, 생명유지서약, 위기탐색 누락 감점 같은 임상 콘텐츠·루브릭이다.
|
||||||
3. **계약 평가 KPI 수집 체계의 부재** — doc4가 못박은 자기효능감·기술숙련도·수련만족도 3종 사전사후 비교와 20명 실험/통제군 설계가 코드에 0건.
|
3. **계약 평가 KPI 수집 체계의 부재** — doc4가 못박은 자기효능감·기술숙련도·수련만족도 3종 사전사후 비교와 20명 실험/통제군 설계가 코드에 0건.
|
||||||
|
|
||||||
|
|
@ -44,10 +44,10 @@
|
||||||
|
|
||||||
## 3. 부족한 부분 — 심각도 우선순위
|
## 3. 부족한 부분 — 심각도 우선순위
|
||||||
|
|
||||||
### [critical] C1. 사례개념화·치료계획 산출물 구조 1차 구현·고도화 필요
|
### [critical] C1. 사례개념화·치료계획 산출물 저장형 구조 2차 구현·채점/검수 잔여
|
||||||
- **근거**: doc1, doc4(mockup '상담 설정·피드백 리포트'), doc5
|
- **근거**: doc1, doc4(mockup '상담 설정·피드백 리포트'), doc5
|
||||||
- **현재상태**(✓직접검증): `SessionReviewResponse.caseWorksheet`와 리뷰 화면 read-only 카드가 축어록 근거 기반 초안을 제공한다. CCD는 숨겨진 정답키이고, 현재 워크시트는 저장형 학습자 제출물은 아님.
|
- **현재상태**(재검증): `SessionReviewResponse.caseWorksheet`와 리뷰 화면 편집 카드가 축어록 근거 기반 초안을 제공한다. 학습자가 수정한 워크시트는 `PUT /sessions/{id}/review/worksheet`로 `app.case_worksheet`에 저장되고, 이후 `GET /review`는 `saved_by_learner` 저장본을 자동 초안보다 우선 반환한다. CCD는 숨겨진 정답키로 유지하고 저장본에는 점수로 노출하지 않는다.
|
||||||
- **권고**: 편집·DB 저장형 사례개념화 워크시트, AI 축어록 초안 추출 고도화, 규칙 기반 채점, 교수자 검수 플로우를 추가한다. 루브릭은 임상팀이 외부 설정으로 정의 가능하게 외부화.
|
- **권고**: AI 축어록 초안 추출 고도화, 규칙 기반 채점, 교수자 검수 플로우를 추가한다. 루브릭은 임상팀이 외부 설정으로 정의 가능하게 외부화.
|
||||||
|
|
||||||
### [critical] C2. 위기개입 프로토콜·생명유지서약·에스컬레이션 1차 배선 완료·임상 고도화 필요
|
### [critical] C2. 위기개입 프로토콜·생명유지서약·에스컬레이션 1차 배선 완료·임상 고도화 필요
|
||||||
- **근거**: doc1, doc5(핵심 훈련 시나리오), doc4(IRB 전제)
|
- **근거**: doc1, doc5(핵심 훈련 시나리오), doc4(IRB 전제)
|
||||||
|
|
@ -110,7 +110,7 @@
|
||||||
## 4. 즉시 착수 가능 vs 소유자 결정/외부 의존
|
## 4. 즉시 착수 가능 vs 소유자 결정/외부 의존
|
||||||
|
|
||||||
### A. 즉시 착수 가능(코드/스키마 내부, 외부 합의 불요)
|
### A. 즉시 착수 가능(코드/스키마 내부, 외부 합의 불요)
|
||||||
- C1 1차 완료: `caseWorksheet` 응답 구조와 리뷰 화면 read-only 워크시트 카드. 후속은 저장형 입력 폼·임상 루브릭·AI 추출/채점.
|
- C1 2차 완료: `caseWorksheet` 응답 구조, 리뷰 화면 편집 UI, `app.case_worksheet` 저장/재조회 경로. 후속은 임상 루브릭·AI 추출/채점·교수자 검수.
|
||||||
- C2 1차 완료: escalate 시 `safety_events` insert, prepare_turn의 `ideation_observed` 전달, 109 resource, 교수자 알림 큐까지 배선했다. 후속은 임상 스크립트·서약 문안·감점 루브릭.
|
- C2 1차 완료: escalate 시 `safety_events` insert, prepare_turn의 `ideation_observed` 전달, 109 resource, 교수자 알림 큐까지 배선했다. 후속은 임상 스크립트·서약 문안·감점 루브릭.
|
||||||
- C3 1차 완료: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI.
|
- C3 1차 완료: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI.
|
||||||
- H2 1차 완료: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋.
|
- H2 1차 완료: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋.
|
||||||
|
|
@ -130,4 +130,4 @@
|
||||||
- OCR 잔재(doc1 '괴게 씩씩'≈'되게 씩씩', doc5 따옴표 깨짐·오탈자). 의미는 시각 보정했으나 일부 표현 불확실.
|
- OCR 잔재(doc1 '괴게 씩씩'≈'되게 씩씩', doc5 따옴표 깨짐·오탈자). 의미는 시각 보정했으나 일부 표현 불확실.
|
||||||
- doc4 KPI 범위 주의: κ/ICC·환각률은 신청서 본문 미명시('정량 신뢰도 확보' 수준) — 계약 확정 지표로 단정 금지.
|
- doc4 KPI 범위 주의: κ/ICC·환각률은 신청서 본문 미명시('정량 신뢰도 확보' 수준) — 계약 확정 지표로 단정 금지.
|
||||||
- doc4/doc3 행정 불일치: 참여교수 1명 vs 구훈정·어유경 2명 병기, 서식7 연도 '2025' 오기, 연구책임자 표기 불일치(류승택/구훈정), 트웬티온스 4인 성명 공란.
|
- doc4/doc3 행정 불일치: 참여교수 1명 vs 구훈정·어유경 2명 병기, 서식7 연도 '2025' 오기, 연구책임자 표기 불일치(류승택/구훈정), 트웬티온스 4인 성명 공란.
|
||||||
- '현재상태'는 grep/코드 사실 기반 적대적 비평 인용에서 시작했으나, C1·C2·C3·H2·M3은 1차 구현 후 재검증 완료했다. H3은 작성자 직접 재확인 기준이며, 그 외는 착수 전 코드 1차 재확인 권고.
|
- '현재상태'는 grep/코드 사실 기반 적대적 비평 인용에서 시작했으나, C1·C2·C3·H2·M3은 구현 후 재검증 완료했다. H3은 작성자 직접 재확인 기준이며, 그 외는 착수 전 코드 1차 재확인 권고.
|
||||||
|
|
|
||||||
|
|
@ -70,3 +70,61 @@ CREATE POLICY p_session_evaluation_delete
|
||||||
AND s.learner_id = app.current_uid()
|
AND s.learner_id = app.current_uid()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Learner-edited case formulation worksheet for session review.
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue