워크시트 검수 UI와 세션 다크톤 정리
This commit is contained in:
parent
f7aae885b2
commit
30441bd93b
7 changed files with 404 additions and 121 deletions
|
|
@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
|
||||||
from .. import session_persistence
|
from .. import session_persistence
|
||||||
from ..deps import Principal, Role, require_role
|
from ..deps import Principal, Role, require_role
|
||||||
from ..runtime_policy import require_runtime_fallback_allowed
|
from ..runtime_policy import require_runtime_fallback_allowed
|
||||||
|
from ..session_read_model import StageLabel, stage_label
|
||||||
from ..services import session_metrics
|
from ..services import session_metrics
|
||||||
from ..store import InProcSession, store
|
from ..store import InProcSession, store
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ class TeacherSessionSummary(BaseModel):
|
||||||
persona_name: str
|
persona_name: str
|
||||||
session_no: int
|
session_no: int
|
||||||
status: str
|
status: str
|
||||||
stage: str
|
stage: StageLabel
|
||||||
turn_count: int
|
turn_count: int
|
||||||
learner_turn_count: int
|
learner_turn_count: int
|
||||||
client_turn_count: int
|
client_turn_count: int
|
||||||
|
|
@ -40,6 +41,8 @@ class TeacherSessionSummary(BaseModel):
|
||||||
class TeacherSessionReviewStatusRequest(BaseModel):
|
class TeacherSessionReviewStatusRequest(BaseModel):
|
||||||
status: Literal["viewed", "closed"] = "closed"
|
status: Literal["viewed", "closed"] = "closed"
|
||||||
note: str = Field(default="", max_length=2000)
|
note: str = Field(default="", max_length=2000)
|
||||||
|
worksheet_status: Literal["approved", "changes_requested", "rejected", "pending"] | None = None
|
||||||
|
worksheet_note: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class TeacherSessionReviewStatusResponse(BaseModel):
|
class TeacherSessionReviewStatusResponse(BaseModel):
|
||||||
|
|
@ -49,13 +52,16 @@ class TeacherSessionReviewStatusResponse(BaseModel):
|
||||||
reviewer_id: str | None = None
|
reviewer_id: str | None = None
|
||||||
reviewed_at: str | None = None
|
reviewed_at: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
worksheet_status: Literal["pending", "approved", "changes_requested", "rejected"] = "pending"
|
||||||
|
worksheet_note: str = ""
|
||||||
|
worksheet_reviewed_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TeacherGrowthPoint(BaseModel):
|
class TeacherGrowthPoint(BaseModel):
|
||||||
session_id: str
|
session_id: str
|
||||||
session_no: int
|
session_no: int
|
||||||
persona_code: str
|
persona_code: str
|
||||||
stage: str
|
stage: StageLabel
|
||||||
started_at: str
|
started_at: str
|
||||||
ended_at: str | None = None
|
ended_at: str | None = None
|
||||||
score: float | None = None
|
score: float | None = None
|
||||||
|
|
@ -118,7 +124,7 @@ def _growth_point(point: session_metrics.SessionGrowthPoint) -> TeacherGrowthPoi
|
||||||
session_id=point.session_id,
|
session_id=point.session_id,
|
||||||
session_no=point.session_no,
|
session_no=point.session_no,
|
||||||
persona_code=point.persona_code,
|
persona_code=point.persona_code,
|
||||||
stage=point.stage,
|
stage=stage_label(point.stage),
|
||||||
started_at=point.started_at,
|
started_at=point.started_at,
|
||||||
ended_at=point.ended_at,
|
ended_at=point.ended_at,
|
||||||
score=point.score,
|
score=point.score,
|
||||||
|
|
@ -161,6 +167,15 @@ def _review_status_value(record: dict[str, object] | None) -> Literal["pending",
|
||||||
return "pending"
|
return "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def _worksheet_review_status_value(
|
||||||
|
record: dict[str, object] | None,
|
||||||
|
) -> Literal["pending", "approved", "changes_requested", "rejected"]:
|
||||||
|
value = str((record or {}).get("worksheet_status") or "pending")
|
||||||
|
if value in {"approved", "changes_requested", "rejected"}:
|
||||||
|
return value # type: ignore[return-value]
|
||||||
|
return "pending"
|
||||||
|
|
||||||
|
|
||||||
def _summary(
|
def _summary(
|
||||||
sess: InProcSession,
|
sess: InProcSession,
|
||||||
review_status: dict[str, object] | None = None,
|
review_status: dict[str, object] | None = None,
|
||||||
|
|
@ -175,7 +190,7 @@ def _summary(
|
||||||
persona_name=sess.persona.display_name,
|
persona_name=sess.persona.display_name,
|
||||||
session_no=sess.session_no,
|
session_no=sess.session_no,
|
||||||
status="ended" if sess.ended else "active",
|
status="ended" if sess.ended else "active",
|
||||||
stage=sess.state.stage.value,
|
stage=stage_label(sess.state.stage),
|
||||||
turn_count=len(sess.turns),
|
turn_count=len(sess.turns),
|
||||||
learner_turn_count=learner_turns,
|
learner_turn_count=learner_turns,
|
||||||
client_turn_count=client_turns,
|
client_turn_count=client_turns,
|
||||||
|
|
@ -286,6 +301,8 @@ async def update_session_review_status(
|
||||||
status=request.status,
|
status=request.status,
|
||||||
note=request.note,
|
note=request.note,
|
||||||
principal=principal,
|
principal=principal,
|
||||||
|
worksheet_status=request.worksheet_status,
|
||||||
|
worksheet_note=request.worksheet_note,
|
||||||
)
|
)
|
||||||
if saved is None:
|
if saved is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -299,4 +316,7 @@ async def update_session_review_status(
|
||||||
reviewer_id=str(saved.get("reviewer_id") or "") or None,
|
reviewer_id=str(saved.get("reviewer_id") or "") or None,
|
||||||
reviewed_at=str(saved.get("reviewed_at") or "") or None,
|
reviewed_at=str(saved.get("reviewed_at") or "") or None,
|
||||||
updated_at=str(saved.get("updated_at") or "") or None,
|
updated_at=str(saved.get("updated_at") or "") or None,
|
||||||
|
worksheet_status=_worksheet_review_status_value(saved),
|
||||||
|
worksheet_note=str(saved.get("worksheet_note") or ""),
|
||||||
|
worksheet_reviewed_at=str(saved.get("worksheet_reviewed_at") or "") or None,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,9 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
||||||
"reviewer_id": principal.user_id,
|
"reviewer_id": principal.user_id,
|
||||||
"status": "closed",
|
"status": "closed",
|
||||||
"note": "다음 회기에서 반영 질문을 늘리도록 지도",
|
"note": "다음 회기에서 반영 질문을 늘리도록 지도",
|
||||||
|
"worksheet_status": "changes_requested",
|
||||||
|
"worksheet_note": "보호요인 근거를 한 줄 더 보강",
|
||||||
|
"worksheet_reviewed_at": "2026-06-27T10:05:00Z",
|
||||||
"reviewed_at": "2026-06-27T10:00:00Z",
|
"reviewed_at": "2026-06-27T10:00:00Z",
|
||||||
"updated_at": "2026-06-27T10:00:00Z",
|
"updated_at": "2026-06-27T10:00:00Z",
|
||||||
},
|
},
|
||||||
|
|
@ -225,13 +228,21 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
||||||
teacher.TeacherSessionReviewStatusRequest(
|
teacher.TeacherSessionReviewStatusRequest(
|
||||||
status="closed",
|
status="closed",
|
||||||
note="다음 회기에서 반영 질문을 늘리도록 지도",
|
note="다음 회기에서 반영 질문을 늘리도록 지도",
|
||||||
|
worksheet_status="changes_requested",
|
||||||
|
worksheet_note="보호요인 근거를 한 줄 더 보강",
|
||||||
),
|
),
|
||||||
principal,
|
principal,
|
||||||
)
|
)
|
||||||
|
|
||||||
save_status.assert_awaited_once()
|
save_status.assert_awaited_once()
|
||||||
|
save_kwargs = save_status.await_args.kwargs
|
||||||
|
self.assertEqual(save_kwargs["worksheet_status"], "changes_requested")
|
||||||
|
self.assertIn("보호요인", save_kwargs["worksheet_note"])
|
||||||
self.assertEqual(response.status, "closed")
|
self.assertEqual(response.status, "closed")
|
||||||
self.assertIn("반영 질문", response.note)
|
self.assertIn("반영 질문", response.note)
|
||||||
|
self.assertEqual(response.worksheet_status, "changes_requested")
|
||||||
|
self.assertIn("보호요인", response.worksheet_note)
|
||||||
|
self.assertEqual(response.worksheet_reviewed_at, "2026-06-27T10:05:00Z")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -1851,7 +1851,7 @@ export interface components {
|
||||||
/** Session Id */
|
/** Session Id */
|
||||||
session_id: string;
|
session_id: string;
|
||||||
/** Stage */
|
/** Stage */
|
||||||
stage: string;
|
stage?: ("라포" | "탐색" | "개입" | "정리") | null;
|
||||||
};
|
};
|
||||||
/** HTTPValidationError */
|
/** HTTPValidationError */
|
||||||
HTTPValidationError: {
|
HTTPValidationError: {
|
||||||
|
|
@ -2287,7 +2287,7 @@ export interface components {
|
||||||
/** Session Id */
|
/** Session Id */
|
||||||
session_id: string;
|
session_id: string;
|
||||||
/** Stage */
|
/** Stage */
|
||||||
stage: string;
|
stage?: ("라포" | "탐색" | "개입" | "정리") | null;
|
||||||
suggestion: components["schemas"]["LiveCoachSuggestion"];
|
suggestion: components["schemas"]["LiveCoachSuggestion"];
|
||||||
/** Turn Seq */
|
/** Turn Seq */
|
||||||
turn_seq: number;
|
turn_seq: number;
|
||||||
|
|
@ -2906,7 +2906,7 @@ export interface components {
|
||||||
*/
|
*/
|
||||||
privacy: string;
|
privacy: string;
|
||||||
/** Reachedphase */
|
/** Reachedphase */
|
||||||
reachedPhase: string;
|
reachedPhase?: ("라포" | "탐색" | "개입" | "정리") | null;
|
||||||
/**
|
/**
|
||||||
* Reviewready
|
* Reviewready
|
||||||
* @default false
|
* @default false
|
||||||
|
|
@ -2995,10 +2995,16 @@ export interface components {
|
||||||
};
|
};
|
||||||
/** ReviewPhaseSegment */
|
/** ReviewPhaseSegment */
|
||||||
ReviewPhaseSegment: {
|
ReviewPhaseSegment: {
|
||||||
/** Key */
|
/**
|
||||||
key: string;
|
* Key
|
||||||
/** Label */
|
* @enum {string}
|
||||||
label: string;
|
*/
|
||||||
|
key: "rapport" | "explore" | "intervene" | "closing";
|
||||||
|
/**
|
||||||
|
* Label
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
label: "라포" | "탐색" | "개입" | "정리";
|
||||||
/** Weight */
|
/** Weight */
|
||||||
weight: number;
|
weight: number;
|
||||||
};
|
};
|
||||||
|
|
@ -3196,13 +3202,8 @@ export interface components {
|
||||||
/** Session No */
|
/** Session No */
|
||||||
session_no: number;
|
session_no: number;
|
||||||
};
|
};
|
||||||
/**
|
/** SessionEvaluationResponse */
|
||||||
* SessionEvaluation
|
SessionEvaluationResponse: {
|
||||||
* @description deep-loop 회기말/단계전환 정밀 평가 결과.
|
|
||||||
*
|
|
||||||
* 기법분포 + 잘한 순간 + 개선점(최대3) + 슈퍼바이저 rationale/critique + 의도이탈 집계.
|
|
||||||
*/
|
|
||||||
SessionEvaluation: {
|
|
||||||
/** Alternative Utterances */
|
/** Alternative Utterances */
|
||||||
alternative_utterances?: string[];
|
alternative_utterances?: string[];
|
||||||
distribution?: components["schemas"]["TechniqueDistribution"];
|
distribution?: components["schemas"]["TechniqueDistribution"];
|
||||||
|
|
@ -3224,8 +3225,11 @@ export interface components {
|
||||||
scope: string;
|
scope: string;
|
||||||
/** Session Id */
|
/** Session Id */
|
||||||
session_id: string;
|
session_id: string;
|
||||||
/** Stage */
|
/**
|
||||||
stage: string;
|
* Stage
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
stage: "라포" | "탐색" | "개입" | "정리";
|
||||||
/** Strengths */
|
/** Strengths */
|
||||||
strengths?: string[];
|
strengths?: string[];
|
||||||
/** Supervisor Critique */
|
/** Supervisor Critique */
|
||||||
|
|
@ -3275,8 +3279,11 @@ export interface components {
|
||||||
phaseAxis?: string[];
|
phaseAxis?: string[];
|
||||||
/** Phases */
|
/** Phases */
|
||||||
phases?: components["schemas"]["ReviewPhaseSegment"][];
|
phases?: components["schemas"]["ReviewPhaseSegment"][];
|
||||||
/** Reachedphase */
|
/**
|
||||||
reachedPhase: string;
|
* Reachedphase
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
reachedPhase: "라포" | "탐색" | "개입" | "정리";
|
||||||
/**
|
/**
|
||||||
* Reviewready
|
* Reviewready
|
||||||
* @default false
|
* @default false
|
||||||
|
|
@ -3374,6 +3381,19 @@ export interface components {
|
||||||
status: "pending" | "viewed" | "closed";
|
status: "pending" | "viewed" | "closed";
|
||||||
/** Updatedat */
|
/** Updatedat */
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
|
/**
|
||||||
|
* Worksheetnote
|
||||||
|
* @default
|
||||||
|
*/
|
||||||
|
worksheetNote: string;
|
||||||
|
/** Worksheetreviewedat */
|
||||||
|
worksheetReviewedAt?: string | null;
|
||||||
|
/**
|
||||||
|
* Worksheetstatus
|
||||||
|
* @default pending
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
worksheetStatus: "pending" | "approved" | "changes_requested" | "rejected";
|
||||||
};
|
};
|
||||||
/** TeacherDashboardResponse */
|
/** TeacherDashboardResponse */
|
||||||
TeacherDashboardResponse: {
|
TeacherDashboardResponse: {
|
||||||
|
|
@ -3418,8 +3438,11 @@ export interface components {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
/** Session No */
|
/** Session No */
|
||||||
session_no: number;
|
session_no: number;
|
||||||
/** Stage */
|
/**
|
||||||
stage: string;
|
* Stage
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
stage: "라포" | "탐색" | "개입" | "정리";
|
||||||
/** Started At */
|
/** Started At */
|
||||||
started_at: string;
|
started_at: string;
|
||||||
/**
|
/**
|
||||||
|
|
@ -3511,6 +3534,10 @@ export interface components {
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
status: "viewed" | "closed";
|
status: "viewed" | "closed";
|
||||||
|
/** Worksheet Note */
|
||||||
|
worksheet_note?: string | null;
|
||||||
|
/** Worksheet Status */
|
||||||
|
worksheet_status?: ("approved" | "changes_requested" | "rejected" | "pending") | null;
|
||||||
};
|
};
|
||||||
/** TeacherSessionReviewStatusResponse */
|
/** TeacherSessionReviewStatusResponse */
|
||||||
TeacherSessionReviewStatusResponse: {
|
TeacherSessionReviewStatusResponse: {
|
||||||
|
|
@ -3533,6 +3560,19 @@ export interface components {
|
||||||
status: "pending" | "viewed" | "closed";
|
status: "pending" | "viewed" | "closed";
|
||||||
/** Updated At */
|
/** Updated At */
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
|
/**
|
||||||
|
* Worksheet Note
|
||||||
|
* @default
|
||||||
|
*/
|
||||||
|
worksheet_note: string;
|
||||||
|
/** Worksheet Reviewed At */
|
||||||
|
worksheet_reviewed_at?: string | null;
|
||||||
|
/**
|
||||||
|
* Worksheet Status
|
||||||
|
* @default pending
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
worksheet_status: "pending" | "approved" | "changes_requested" | "rejected";
|
||||||
};
|
};
|
||||||
/** TeacherSessionSummary */
|
/** TeacherSessionSummary */
|
||||||
TeacherSessionSummary: {
|
TeacherSessionSummary: {
|
||||||
|
|
@ -3564,8 +3604,11 @@ export interface components {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
/** Session No */
|
/** Session No */
|
||||||
session_no: number;
|
session_no: number;
|
||||||
/** Stage */
|
/**
|
||||||
stage: string;
|
* Stage
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
stage: "라포" | "탐색" | "개입" | "정리";
|
||||||
/** Started At */
|
/** Started At */
|
||||||
started_at: string;
|
started_at: string;
|
||||||
/** Status */
|
/** Status */
|
||||||
|
|
@ -3610,17 +3653,8 @@ export interface components {
|
||||||
/** Rationale */
|
/** Rationale */
|
||||||
rationale?: string | null;
|
rationale?: string | null;
|
||||||
};
|
};
|
||||||
/**
|
/** TurnEvaluationResponse */
|
||||||
* TurnEvaluation
|
TurnEvaluationResponse: {
|
||||||
* @description fast-loop 턴 평가 결과(턴 직후 경량 4차원).
|
|
||||||
*
|
|
||||||
* 4차원:
|
|
||||||
* ① technique[] : 학습자(상담자) 발화에 부착된 기법 라벨(복수)
|
|
||||||
* ② client_state_read[] : 내담자 응답에서 읽은 상태(복수)
|
|
||||||
* ③ appropriateness : 적절성 신호 pos|warn|neutral (경량)
|
|
||||||
* ④ intent_deviation : '의도와 다른 부분' 있으면 구조화(없으면 None)
|
|
||||||
*/
|
|
||||||
TurnEvaluation: {
|
|
||||||
/**
|
/**
|
||||||
* Appropriateness
|
* Appropriateness
|
||||||
* @default neutral
|
* @default neutral
|
||||||
|
|
@ -3640,8 +3674,11 @@ export interface components {
|
||||||
loop: string;
|
loop: string;
|
||||||
/** Rapport Signal */
|
/** Rapport Signal */
|
||||||
rapport_signal?: number | null;
|
rapport_signal?: number | null;
|
||||||
/** Stage */
|
/**
|
||||||
stage: string;
|
* Stage
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
stage: "라포" | "탐색" | "개입" | "정리";
|
||||||
/** Techniques */
|
/** Techniques */
|
||||||
techniques?: components["schemas"]["TechniqueTag"][];
|
techniques?: components["schemas"]["TechniqueTag"][];
|
||||||
/** Theory Mode */
|
/** Theory Mode */
|
||||||
|
|
@ -4791,7 +4828,7 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["SessionEvaluation"];
|
"application/json": components["schemas"]["SessionEvaluationResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Validation Error */
|
/** @description Validation Error */
|
||||||
|
|
@ -4829,7 +4866,7 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["TurnEvaluation"];
|
"application/json": components["schemas"]["TurnEvaluationResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Validation Error */
|
/** @description Validation Error */
|
||||||
|
|
|
||||||
|
|
@ -1611,6 +1611,33 @@ export default function Session() {
|
||||||
sending;
|
sending;
|
||||||
const textTurnBlocked = sending || voiceStatus === "thinking";
|
const textTurnBlocked = sending || voiceStatus === "thinking";
|
||||||
const elapsedLabel = formatElapsed(elapsed);
|
const elapsedLabel = formatElapsed(elapsed);
|
||||||
|
const recommendedSessionSeconds = 30 * 60;
|
||||||
|
const remainingLabel = formatElapsed(Math.max(0, recommendedSessionSeconds - elapsed));
|
||||||
|
const turnCount = utterances.filter((utterance) => !utterance.partial).length;
|
||||||
|
const latestClientUtterance = [...utterances]
|
||||||
|
.reverse()
|
||||||
|
.find((utterance) => utterance.speaker === "client" && utterance.text.trim());
|
||||||
|
const stageClientLine =
|
||||||
|
latestClientUtterance?.text ??
|
||||||
|
primaryContext?.v ??
|
||||||
|
`${clientName}님이 당신의 첫 질문을 기다리고 있습니다.`;
|
||||||
|
const voiceInputStatus =
|
||||||
|
voiceAvailable === false
|
||||||
|
? "음성 미설정"
|
||||||
|
: micOn
|
||||||
|
? "수신 중"
|
||||||
|
: voiceStatus === "requesting"
|
||||||
|
? "권한 요청"
|
||||||
|
: "대기";
|
||||||
|
const responseStatus =
|
||||||
|
sending || clientReplyPending
|
||||||
|
? "응답 대기"
|
||||||
|
: voiceStatus === "thinking"
|
||||||
|
? "전사 중"
|
||||||
|
: voiceStatus === "speaking"
|
||||||
|
? "재생 중"
|
||||||
|
: "안정";
|
||||||
|
const safetyStatusText = safety ? "확인 필요" : "안전";
|
||||||
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
|
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
|
||||||
const consentRequired = user?.role === "learner" && user.consentAt == null;
|
const consentRequired = user?.role === "learner" && user.consentAt == null;
|
||||||
const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired;
|
const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired;
|
||||||
|
|
@ -2370,23 +2397,20 @@ export default function Session() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 안전 점검(있을 때만, warn 톤 — 빨강 아님) */}
|
<section className={"sx-safety" + (safety ? "" : " sx-safety--ok")}>
|
||||||
{safety ? (
|
<span className="sx-safety__ico">
|
||||||
<section className="sx-safety">
|
<Icon name={safety ? "alert" : "shield"} size={17} />
|
||||||
<span className="sx-safety__ico">
|
</span>
|
||||||
<Icon name="alert" size={17} />
|
<span className="sx-safety__text">
|
||||||
</span>
|
<b>안전 점검</b> · {safety ?? "현재 감지된 위기 신호 없음"}
|
||||||
<span className="sx-safety__text">
|
{crisisResource ? (
|
||||||
<b>안전 점검</b> · {safety}
|
<span className="sx-crisis-resource">
|
||||||
{crisisResource ? (
|
<strong>{crisisResource.title}</strong>
|
||||||
<span className="sx-crisis-resource">
|
<a href={`tel:${crisisResource.number}`}>{crisisResource.number}</a>
|
||||||
<strong>{crisisResource.title}</strong>
|
</span>
|
||||||
<a href={`tel:${crisisResource.number}`}>{crisisResource.number}</a>
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
</section>
|
||||||
</span>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -2530,7 +2554,7 @@ export default function Session() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="sx-coach-history__item-top">
|
<div className="sx-coach-history__item-top">
|
||||||
<span>{event.turn_seq}번 턴 · {event.stage}</span>
|
<span>{event.turn_seq}번 턴 · {event.stage ?? "단계 미상"}</span>
|
||||||
<time dateTime={event.created_at}>
|
<time dateTime={event.created_at}>
|
||||||
{formatCoachTimestamp(event.created_at)}
|
{formatCoachTimestamp(event.created_at)}
|
||||||
</time>
|
</time>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import "./session-review/session-review.css";
|
||||||
type LoadState = "loading" | "ready" | "error";
|
type LoadState = "loading" | "ready" | "error";
|
||||||
type PrepostMeasureName = UserPrepostMeasureItem["measure_name"];
|
type PrepostMeasureName = UserPrepostMeasureItem["measure_name"];
|
||||||
type PrepostTimepoint = UserPrepostMeasureItem["timepoint"];
|
type PrepostTimepoint = UserPrepostMeasureItem["timepoint"];
|
||||||
|
type WorksheetReviewStatus = "pending" | "approved" | "changes_requested" | "rejected";
|
||||||
|
type WorksheetDecisionStatus = Exclude<WorksheetReviewStatus, "pending">;
|
||||||
|
|
||||||
const PREPOST_PILOT_ID = "phase3-pilot-draft";
|
const PREPOST_PILOT_ID = "phase3-pilot-draft";
|
||||||
const PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28";
|
const PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28";
|
||||||
|
|
@ -52,6 +54,13 @@ function prepostDraftKey(measure: PrepostMeasureName, timepoint: PrepostTimepoin
|
||||||
return `${measure}:${timepoint}`;
|
return `${measure}:${timepoint}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function worksheetReviewStatusLabel(status: WorksheetReviewStatus) {
|
||||||
|
if (status === "approved") return "승인";
|
||||||
|
if (status === "changes_requested") return "수정요청";
|
||||||
|
if (status === "rejected") return "반려";
|
||||||
|
return "검수 대기";
|
||||||
|
}
|
||||||
|
|
||||||
function splitPrepostDraftKey(key: string): [PrepostMeasureName, PrepostTimepoint] | null {
|
function splitPrepostDraftKey(key: string): [PrepostMeasureName, PrepostTimepoint] | null {
|
||||||
const [measure, timepoint] = key.split(":");
|
const [measure, timepoint] = key.split(":");
|
||||||
if (
|
if (
|
||||||
|
|
@ -667,7 +676,10 @@ export default function SessionReview() {
|
||||||
const [shareUrl, setShareUrl] = useState<string | null>(null);
|
const [shareUrl, setShareUrl] = useState<string | null>(null);
|
||||||
const [shareError, setShareError] = useState<string | null>(null);
|
const [shareError, setShareError] = useState<string | null>(null);
|
||||||
const [teacherNote, setTeacherNote] = useState("");
|
const [teacherNote, setTeacherNote] = useState("");
|
||||||
|
const [teacherWorksheetNote, setTeacherWorksheetNote] = useState("");
|
||||||
const [teacherReviewSaving, setTeacherReviewSaving] = useState<"viewed" | "closed" | null>(null);
|
const [teacherReviewSaving, setTeacherReviewSaving] = useState<"viewed" | "closed" | null>(null);
|
||||||
|
const [teacherWorksheetSaving, setTeacherWorksheetSaving] =
|
||||||
|
useState<WorksheetDecisionStatus | null>(null);
|
||||||
const [teacherReviewError, setTeacherReviewError] = useState<string | null>(null);
|
const [teacherReviewError, setTeacherReviewError] = useState<string | null>(null);
|
||||||
const turnRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const turnRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
|
|
@ -696,6 +708,7 @@ export default function SessionReview() {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setData(next);
|
setData(next);
|
||||||
setTeacherNote(next.teacherReview?.note ?? "");
|
setTeacherNote(next.teacherReview?.note ?? "");
|
||||||
|
setTeacherWorksheetNote(next.teacherReview?.worksheetNote ?? "");
|
||||||
setTeacherReviewError(null);
|
setTeacherReviewError(null);
|
||||||
setLoadState("ready");
|
setLoadState("ready");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -762,6 +775,7 @@ export default function SessionReview() {
|
||||||
const saved = await teacherApi.updateSessionReviewStatus(sessionId, {
|
const saved = await teacherApi.updateSessionReviewStatus(sessionId, {
|
||||||
status,
|
status,
|
||||||
note: teacherNote,
|
note: teacherNote,
|
||||||
|
worksheet_note: teacherWorksheetNote,
|
||||||
});
|
});
|
||||||
setData((current) =>
|
setData((current) =>
|
||||||
current
|
current
|
||||||
|
|
@ -773,11 +787,15 @@ export default function SessionReview() {
|
||||||
reviewerId: saved.reviewer_id ?? null,
|
reviewerId: saved.reviewer_id ?? null,
|
||||||
reviewedAt: saved.reviewed_at ?? null,
|
reviewedAt: saved.reviewed_at ?? null,
|
||||||
updatedAt: saved.updated_at ?? null,
|
updatedAt: saved.updated_at ?? null,
|
||||||
|
worksheetStatus: saved.worksheet_status ?? "pending",
|
||||||
|
worksheetNote: saved.worksheet_note ?? "",
|
||||||
|
worksheetReviewedAt: saved.worksheet_reviewed_at ?? null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: current,
|
: current,
|
||||||
);
|
);
|
||||||
setTeacherNote(saved.note);
|
setTeacherNote(saved.note);
|
||||||
|
setTeacherWorksheetNote(saved.worksheet_note ?? "");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setTeacherReviewError(
|
setTeacherReviewError(
|
||||||
err instanceof Error ? err.message : "교수자 검토 상태를 저장하지 못했습니다.",
|
err instanceof Error ? err.message : "교수자 검토 상태를 저장하지 못했습니다.",
|
||||||
|
|
@ -787,6 +805,45 @@ export default function SessionReview() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveWorksheetReviewStatus(status: WorksheetDecisionStatus) {
|
||||||
|
if (!sessionId || !data) return;
|
||||||
|
setTeacherWorksheetSaving(status);
|
||||||
|
setTeacherReviewError(null);
|
||||||
|
try {
|
||||||
|
const saved = await teacherApi.updateSessionReviewStatus(sessionId, {
|
||||||
|
status: teacherReviewStatus === "closed" ? "closed" : "viewed",
|
||||||
|
note: teacherNote,
|
||||||
|
worksheet_status: status,
|
||||||
|
worksheet_note: teacherWorksheetNote,
|
||||||
|
});
|
||||||
|
setData((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
teacherReview: {
|
||||||
|
status: saved.status,
|
||||||
|
note: saved.note,
|
||||||
|
reviewerId: saved.reviewer_id ?? null,
|
||||||
|
reviewedAt: saved.reviewed_at ?? null,
|
||||||
|
updatedAt: saved.updated_at ?? null,
|
||||||
|
worksheetStatus: saved.worksheet_status ?? "pending",
|
||||||
|
worksheetNote: saved.worksheet_note ?? "",
|
||||||
|
worksheetReviewedAt: saved.worksheet_reviewed_at ?? null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
setTeacherNote(saved.note);
|
||||||
|
setTeacherWorksheetNote(saved.worksheet_note ?? "");
|
||||||
|
} catch (err) {
|
||||||
|
setTeacherReviewError(
|
||||||
|
err instanceof Error ? err.message : "워크시트 검수 상태를 저장하지 못했습니다.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setTeacherWorksheetSaving(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loadState === "loading") {
|
if (loadState === "loading") {
|
||||||
return (
|
return (
|
||||||
<AppShell contextLabel={contextLabel} navRole={reviewNavRole}>
|
<AppShell contextLabel={contextLabel} navRole={reviewNavRole}>
|
||||||
|
|
@ -838,7 +895,11 @@ export default function SessionReview() {
|
||||||
: teacherReviewStatus === "viewed"
|
: teacherReviewStatus === "viewed"
|
||||||
? "검토 중"
|
? "검토 중"
|
||||||
: "검토 대기";
|
: "검토 대기";
|
||||||
|
const worksheetReviewStatus = (teacherReview?.worksheetStatus ?? "pending") as WorksheetReviewStatus;
|
||||||
|
const worksheetReviewLabel = worksheetReviewStatusLabel(worksheetReviewStatus);
|
||||||
|
const hasLearnerWorksheet = data.caseWorksheet?.status === "saved_by_learner";
|
||||||
const canCloseTeacherReview = isSupervisorView && data.sessionSignal === "종료됨";
|
const canCloseTeacherReview = isSupervisorView && data.sessionSignal === "종료됨";
|
||||||
|
const canDecideWorksheet = canCloseTeacherReview && hasLearnerWorksheet;
|
||||||
const reviewReadiness = hasTranscript
|
const reviewReadiness = hasTranscript
|
||||||
? `${turns.length}개 발화 기반`
|
? `${turns.length}개 발화 기반`
|
||||||
: "축어록 저장 후 생성";
|
: "축어록 저장 후 생성";
|
||||||
|
|
@ -1147,7 +1208,7 @@ export default function SessionReview() {
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={teacherReviewSaving !== null}
|
disabled={teacherReviewSaving !== null || teacherWorksheetSaving !== null}
|
||||||
onClick={() => void saveTeacherReviewStatus("viewed")}
|
onClick={() => void saveTeacherReviewStatus("viewed")}
|
||||||
>
|
>
|
||||||
{teacherReviewSaving === "viewed" ? "저장 중" : "메모 저장"}
|
{teacherReviewSaving === "viewed" ? "저장 중" : "메모 저장"}
|
||||||
|
|
@ -1157,6 +1218,7 @@ export default function SessionReview() {
|
||||||
disabled={
|
disabled={
|
||||||
!canCloseTeacherReview ||
|
!canCloseTeacherReview ||
|
||||||
teacherReviewSaving !== null ||
|
teacherReviewSaving !== null ||
|
||||||
|
teacherWorksheetSaving !== null ||
|
||||||
teacherReviewStatus === "closed"
|
teacherReviewStatus === "closed"
|
||||||
}
|
}
|
||||||
onClick={() => void saveTeacherReviewStatus("closed")}
|
onClick={() => void saveTeacherReviewStatus("closed")}
|
||||||
|
|
@ -1164,6 +1226,65 @@ export default function SessionReview() {
|
||||||
{teacherReviewSaving === "closed" ? "완료 중" : "검토 완료"}
|
{teacherReviewSaving === "closed" ? "완료 중" : "검토 완료"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sr-teacher-review__worksheet">
|
||||||
|
<div className="sr-teacher-review__head sr-teacher-review__head--sub">
|
||||||
|
<div>
|
||||||
|
<Kicker>워크시트 검수</Kicker>
|
||||||
|
<b>{worksheetReviewLabel}</b>
|
||||||
|
</div>
|
||||||
|
<Badge tone={worksheetReviewStatus === "approved" ? "accent" : "neutral"}>
|
||||||
|
{worksheetReviewLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<label className="sr-teacher-review__note">
|
||||||
|
<span>워크시트 검수 메모</span>
|
||||||
|
<textarea
|
||||||
|
value={teacherWorksheetNote}
|
||||||
|
rows={3}
|
||||||
|
placeholder="수정요청이나 반려 사유를 간단히 남깁니다."
|
||||||
|
onChange={(event) => setTeacherWorksheetNote(event.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{teacherReview?.worksheetReviewedAt ? (
|
||||||
|
<p className="sr-teacher-review__meta">
|
||||||
|
워크시트 검수 시각 {teacherReview.worksheetReviewedAt}
|
||||||
|
</p>
|
||||||
|
) : hasLearnerWorksheet ? (
|
||||||
|
<p className="sr-teacher-review__meta">
|
||||||
|
임상 루브릭 점수가 아니라 교수자의 수동 검수 상태만 저장합니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="sr-teacher-review__meta">
|
||||||
|
학습자 저장본이 있을 때만 워크시트 승인·수정요청·반려를 남깁니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="sr-teacher-review__actions sr-teacher-review__actions--three">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!canDecideWorksheet || teacherWorksheetSaving !== null}
|
||||||
|
onClick={() => void saveWorksheetReviewStatus("approved")}
|
||||||
|
>
|
||||||
|
{teacherWorksheetSaving === "approved" ? "저장 중" : "승인"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canDecideWorksheet || teacherWorksheetSaving !== null}
|
||||||
|
onClick={() => void saveWorksheetReviewStatus("changes_requested")}
|
||||||
|
>
|
||||||
|
{teacherWorksheetSaving === "changes_requested" ? "저장 중" : "수정요청"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canDecideWorksheet || teacherWorksheetSaving !== null}
|
||||||
|
onClick={() => void saveWorksheetReviewStatus("rejected")}
|
||||||
|
>
|
||||||
|
{teacherWorksheetSaving === "rejected" ? "저장 중" : "반려"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,14 @@
|
||||||
font-size: var(--fs-body);
|
font-size: var(--fs-body);
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
.sr-teacher-review__head--sub {
|
||||||
|
margin-bottom: var(--sp-2);
|
||||||
|
}
|
||||||
|
.sr-teacher-review__worksheet {
|
||||||
|
margin-top: var(--sp-4);
|
||||||
|
padding-top: var(--sp-4);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
.sr-teacher-review__note {
|
.sr-teacher-review__note {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
|
@ -311,6 +319,9 @@
|
||||||
.sr-teacher-review__actions .vg-btn {
|
.sr-teacher-review__actions .vg-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.sr-teacher-review__actions--three {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.sr-summary {
|
.sr-summary {
|
||||||
max-width: 68ch;
|
max-width: 68ch;
|
||||||
|
|
|
||||||
|
|
@ -40,16 +40,36 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.sx-page--active {
|
.sx-page--active {
|
||||||
|
--paper-2: #1b2b27;
|
||||||
|
--bg-surface: #14231f;
|
||||||
|
--bg-surface-2: #1d2d29;
|
||||||
|
--bg-stage: #0c1714;
|
||||||
|
--text-strong: #edf7f4;
|
||||||
|
--text-body: #c9d9d5;
|
||||||
|
--text-muted: #8fa29c;
|
||||||
|
--text-on-accent: #071411;
|
||||||
|
--border-subtle: rgba(169, 215, 204, 0.15);
|
||||||
|
--border-strong: rgba(169, 215, 204, 0.24);
|
||||||
|
--border-focus: #7cc8b8;
|
||||||
|
--focus-ring: rgba(124, 200, 184, 0.32);
|
||||||
|
--accent: #79bfae;
|
||||||
|
--accent-deep: #9ad3c7;
|
||||||
|
--accent-tint: rgba(121, 191, 174, 0.16);
|
||||||
|
--accent-bright: #79c8b7;
|
||||||
|
--clay: #d08b70;
|
||||||
|
--clay-deep: #ebb196;
|
||||||
|
--clay-tint: rgba(208, 139, 112, 0.15);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
grid-template-rows: min-content minmax(0, 1fr) min-content;
|
grid-template-rows: min-content minmax(0, 1fr) min-content;
|
||||||
padding: 10px 14px 14px;
|
padding: 10px 14px 14px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 16% 14%, rgba(95, 150, 139, 0.2), transparent 27%),
|
radial-gradient(circle at 18% 8%, rgba(121, 191, 174, 0.12), transparent 28%),
|
||||||
radial-gradient(circle at 82% 8%, rgba(176, 115, 92, 0.18), transparent 24%),
|
radial-gradient(circle at 86% 12%, rgba(208, 139, 112, 0.1), transparent 24%),
|
||||||
linear-gradient(135deg, #111c1a, #1d2926 52%, #15211f);
|
linear-gradient(135deg, #0a1210 0%, #0e1714 48%, #101d19 100%);
|
||||||
color: #eaf0f1;
|
color: var(--text-strong);
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-grid {
|
.sx-page--active .sx-grid {
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
@ -69,11 +89,11 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
border: 1px solid rgba(169, 215, 204, 0.16);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: rgba(16, 28, 26, 0.9);
|
background: rgba(13, 24, 21, 0.92);
|
||||||
color: rgba(238, 244, 242, 0.88);
|
color: rgba(238, 244, 242, 0.88);
|
||||||
box-shadow: 0 10px 30px rgba(7, 16, 14, 0.18);
|
box-shadow: 0 12px 34px rgba(4, 12, 10, 0.26);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -312,13 +332,12 @@
|
||||||
.sx-page--active .sx-panel,
|
.sx-page--active .sx-panel,
|
||||||
.sx-page--active .sx-mobile-context {
|
.sx-page--active .sx-mobile-context {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(20, 35, 31, 0.92), rgba(13, 25, 22, 0.94)),
|
linear-gradient(180deg, rgba(23, 39, 35, 0.94), rgba(13, 25, 22, 0.96));
|
||||||
var(--asset-warm-elements) center / cover no-repeat;
|
border-color: var(--border-subtle);
|
||||||
border-color: rgba(203, 227, 220, 0.13);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 18px 42px rgba(4, 10, 9, 0.24),
|
0 18px 42px rgba(2, 8, 7, 0.3),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
inset 0 1px 0 rgba(237, 247, 244, 0.045);
|
||||||
color: rgba(234, 240, 241, 0.78);
|
color: var(--text-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 컬럼 공통 */
|
/* 컬럼 공통 */
|
||||||
|
|
@ -337,7 +356,7 @@
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-col-center {
|
.sx-page--active .sx-col-center {
|
||||||
grid-template-rows: minmax(230px, 0.42fr) minmax(0, 1fr);
|
grid-template-rows: minmax(250px, 0.44fr) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── LEFT: 세로 단계 트랙 ── */
|
/* ── LEFT: 세로 단계 트랙 ── */
|
||||||
|
|
@ -544,11 +563,13 @@
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-stage {
|
.sx-page--active .sx-stage {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 28% 50%, rgba(197, 222, 214, 0.26), transparent 42%),
|
radial-gradient(circle at 50% 47%, rgba(121, 191, 174, 0.22), transparent 36%),
|
||||||
linear-gradient(135deg, rgba(24, 42, 38, 0.94), rgba(48, 65, 59, 0.9)),
|
radial-gradient(circle at 50% 82%, rgba(208, 139, 112, 0.12), transparent 24%),
|
||||||
var(--asset-warm-elements) center / cover no-repeat;
|
linear-gradient(145deg, rgba(11, 22, 19, 0.98), rgba(20, 34, 30, 0.96) 56%, rgba(10, 19, 17, 0.98));
|
||||||
border-color: rgba(255, 255, 255, 0.11);
|
border-color: rgba(169, 215, 204, 0.18);
|
||||||
box-shadow: 0 18px 44px rgba(7, 16, 14, 0.28);
|
box-shadow:
|
||||||
|
0 22px 52px rgba(2, 8, 7, 0.42),
|
||||||
|
inset 0 1px 0 rgba(237, 247, 244, 0.05);
|
||||||
grid-template-columns: minmax(184px, 256px) minmax(0, 1fr);
|
grid-template-columns: minmax(184px, 256px) minmax(0, 1fr);
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
}
|
}
|
||||||
|
|
@ -556,7 +577,7 @@
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 12px;
|
inset: 12px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
border: 1px solid rgba(169, 215, 204, 0.1);
|
||||||
border-radius: calc(var(--radius-lg) - 2px);
|
border-radius: calc(var(--radius-lg) - 2px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
@ -571,8 +592,8 @@
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-on-accent);
|
color: var(--text-muted);
|
||||||
opacity: 0.72;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.sx-stage__status {
|
.sx-stage__status {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -594,12 +615,15 @@
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex: none;
|
flex: none;
|
||||||
background: var(--clay);
|
background: var(--clay);
|
||||||
|
box-shadow: 0 0 0 4px rgba(208, 139, 112, 0.12);
|
||||||
}
|
}
|
||||||
.sx-stage__state-dot.is-learner {
|
.sx-stage__state-dot.is-learner {
|
||||||
background: var(--accent-bright);
|
background: var(--accent-bright);
|
||||||
|
box-shadow: 0 0 0 4px rgba(121, 200, 183, 0.12);
|
||||||
}
|
}
|
||||||
.sx-stage__state-dot.is-think {
|
.sx-stage__state-dot.is-think {
|
||||||
background: var(--neutral-sig);
|
background: var(--neutral-sig);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 음성 오브 + 아바타 (오브는 아바타 둘레 링) */
|
/* 음성 오브 + 아바타 (오브는 아바타 둘레 링) */
|
||||||
|
|
@ -620,8 +644,8 @@
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
--ring: var(--accent-bright);
|
--ring: var(--accent-bright);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 2px color-mix(in srgb, var(--ring) 55%, transparent),
|
0 0 0 1px color-mix(in srgb, var(--ring) 58%, transparent),
|
||||||
0 0 26px 3px color-mix(in srgb, var(--ring) 28%, transparent);
|
0 0 30px 2px color-mix(in srgb, var(--ring) 22%, transparent);
|
||||||
transition: box-shadow var(--dur-slow) var(--ease-out);
|
transition: box-shadow var(--dur-slow) var(--ease-out);
|
||||||
}
|
}
|
||||||
.sx-orb[data-orb="learner"] {
|
.sx-orb[data-orb="learner"] {
|
||||||
|
|
@ -632,9 +656,9 @@
|
||||||
--ring: var(--clay);
|
--ring: var(--clay);
|
||||||
/* 음량(--amp)을 JS 가 주입 → blur 미세 모듈레이션 */
|
/* 음량(--amp)을 JS 가 주입 → blur 미세 모듈레이션 */
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 2px color-mix(in srgb, var(--ring) 60%, transparent),
|
0 0 0 1px color-mix(in srgb, var(--ring) 62%, transparent),
|
||||||
0 0 calc(22px + var(--amp, 0) * 18px) 3px
|
0 0 calc(24px + var(--amp, 0) * 18px) 2px
|
||||||
color-mix(in srgb, var(--ring) 32%, transparent);
|
color-mix(in srgb, var(--ring) 28%, transparent);
|
||||||
}
|
}
|
||||||
.sx-orb[data-orb="thinking"] {
|
.sx-orb[data-orb="thinking"] {
|
||||||
--ring: var(--neutral-sig);
|
--ring: var(--neutral-sig);
|
||||||
|
|
@ -659,8 +683,8 @@
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
color: var(--text-on-accent);
|
color: var(--text-strong);
|
||||||
opacity: 0.88;
|
opacity: 0.9;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
|
|
@ -725,10 +749,10 @@
|
||||||
width: min(100%, 520px);
|
width: min(100%, 520px);
|
||||||
max-width: calc(100% - 48px);
|
max-width: calc(100% - 48px);
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border: 1px solid rgba(203, 227, 220, 0.12);
|
border: 1px solid rgba(169, 215, 204, 0.15);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(8, 19, 17, 0.42);
|
background: rgba(7, 18, 15, 0.62);
|
||||||
color: rgba(234, 240, 241, 0.82);
|
color: var(--text-strong);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -764,12 +788,11 @@
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-transcript {
|
.sx-page--active .sx-transcript {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(20, 35, 31, 0.94), rgba(12, 23, 20, 0.96)),
|
linear-gradient(180deg, rgba(19, 34, 30, 0.97), rgba(10, 22, 19, 0.98));
|
||||||
var(--asset-warm-elements) center / cover no-repeat;
|
border-color: rgba(169, 215, 204, 0.16);
|
||||||
border-color: rgba(203, 227, 220, 0.13);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 18px 42px rgba(4, 10, 9, 0.28),
|
0 18px 44px rgba(2, 8, 7, 0.34),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
inset 0 1px 0 rgba(237, 247, 244, 0.04);
|
||||||
color: var(--text-body);
|
color: var(--text-body);
|
||||||
}
|
}
|
||||||
.sx-transcript__head {
|
.sx-transcript__head {
|
||||||
|
|
@ -1047,6 +1070,11 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
.sx-page--active button:focus-visible,
|
||||||
|
.sx-page--active textarea:focus-visible {
|
||||||
|
outline: 2px solid rgba(124, 200, 184, 0.88);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
.sx-turn-error {
|
.sx-turn-error {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
@ -1085,6 +1113,7 @@
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: var(--paper-2);
|
background: var(--paper-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: opacity var(--dur-slow) var(--ease-out);
|
transition: opacity var(--dur-slow) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
@ -1097,6 +1126,7 @@
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--paper-2);
|
background: var(--paper-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
@ -1516,6 +1546,7 @@
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: var(--warn-tint);
|
background: var(--warn-tint);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--warn-solid) 24%, transparent);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.sx-safety + .sx-safety {
|
.sx-safety + .sx-safety {
|
||||||
|
|
@ -1528,6 +1559,14 @@
|
||||||
.sx-safety--engine .sx-safety__text {
|
.sx-safety--engine .sx-safety__text {
|
||||||
color: var(--info-text);
|
color: var(--info-text);
|
||||||
}
|
}
|
||||||
|
.sx-safety--ok {
|
||||||
|
background: var(--accent-tint);
|
||||||
|
border-color: rgba(121, 200, 183, 0.2);
|
||||||
|
}
|
||||||
|
.sx-safety--ok .sx-safety__ico,
|
||||||
|
.sx-safety--ok .sx-safety__text {
|
||||||
|
color: #9ed6ca;
|
||||||
|
}
|
||||||
.sx-safety__ico {
|
.sx-safety__ico {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 17px;
|
width: 17px;
|
||||||
|
|
@ -1581,9 +1620,11 @@
|
||||||
.sx-page--active .sx-controlbar {
|
.sx-page--active .sx-controlbar {
|
||||||
width: min(100%, 1460px);
|
width: min(100%, 1460px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background: rgba(16, 28, 26, 0.94);
|
background: rgba(11, 22, 19, 0.96);
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
border-color: rgba(169, 215, 204, 0.16);
|
||||||
box-shadow: 0 14px 36px rgba(7, 16, 14, 0.24);
|
box-shadow:
|
||||||
|
0 16px 40px rgba(2, 8, 7, 0.34),
|
||||||
|
inset 0 1px 0 rgba(237, 247, 244, 0.04);
|
||||||
color: rgba(238, 244, 242, 0.86);
|
color: rgba(238, 244, 242, 0.86);
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-mic-block__l,
|
.sx-page--active .sx-mic-block__l,
|
||||||
|
|
@ -1594,30 +1635,30 @@
|
||||||
color: rgba(238, 244, 242, 0.58);
|
color: rgba(238, 244, 242, 0.58);
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-segmented {
|
.sx-page--active .sx-segmented {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(237, 247, 244, 0.07);
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(169, 215, 204, 0.1);
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-segmented button {
|
.sx-page--active .sx-segmented button {
|
||||||
color: rgba(238, 244, 242, 0.68);
|
color: rgba(238, 244, 242, 0.68);
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-segmented button:hover {
|
.sx-page--active .sx-segmented button:hover {
|
||||||
color: #ffffff;
|
color: var(--text-strong);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(237, 247, 244, 0.08);
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-segmented button.is-on {
|
.sx-page--active .sx-segmented button.is-on {
|
||||||
background: rgba(145, 200, 189, 0.2);
|
background: rgba(121, 200, 183, 0.24);
|
||||||
color: #ffffff;
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-cb-sep {
|
.sx-page--active .sx-cb-sep {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-pause {
|
.sx-page--active .sx-pause {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(237, 247, 244, 0.07);
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
border-color: rgba(169, 215, 204, 0.14);
|
||||||
color: #eef4f2;
|
color: #eef4f2;
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-pause:hover {
|
.sx-page--active .sx-pause:hover {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(237, 247, 244, 0.11);
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-review-button {
|
.sx-page--active .sx-review-button {
|
||||||
background: rgba(145, 200, 189, 0.14);
|
background: rgba(145, 200, 189, 0.14);
|
||||||
|
|
@ -1640,7 +1681,8 @@
|
||||||
.sx-page--active .sx-signal__rest,
|
.sx-page--active .sx-signal__rest,
|
||||||
.sx-page--active .sx-mobile-context__row span,
|
.sx-page--active .sx-mobile-context__row span,
|
||||||
.sx-page--active .sx-mobile-context__brief span {
|
.sx-page--active .sx-mobile-context__brief span {
|
||||||
background: rgba(255, 255, 255, 0.055);
|
background: rgba(237, 247, 244, 0.055);
|
||||||
|
border-color: rgba(169, 215, 204, 0.09);
|
||||||
color: rgba(234, 240, 241, 0.76);
|
color: rgba(234, 240, 241, 0.76);
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-chip.is-clay,
|
.sx-page--active .sx-chip.is-clay,
|
||||||
|
|
@ -1688,17 +1730,26 @@
|
||||||
color: #9ed1c6;
|
color: #9ed1c6;
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-compose textarea {
|
.sx-page--active .sx-compose textarea {
|
||||||
background: rgba(255, 255, 255, 0.055);
|
background: rgba(7, 18, 15, 0.58);
|
||||||
color: #eef6f3;
|
color: #eef6f3;
|
||||||
border-color: rgba(203, 227, 220, 0.18);
|
border-color: rgba(169, 215, 204, 0.22);
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-compose textarea::placeholder {
|
.sx-page--active .sx-compose textarea::placeholder {
|
||||||
color: rgba(234, 240, 241, 0.36);
|
color: rgba(234, 240, 241, 0.36);
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-compose .vg-btn--primary:disabled {
|
.sx-page--active .sx-compose .vg-btn--primary:disabled {
|
||||||
background: rgba(255, 255, 255, 0.055);
|
background: rgba(237, 247, 244, 0.055);
|
||||||
color: rgba(234, 240, 241, 0.38);
|
color: rgba(234, 240, 241, 0.38);
|
||||||
border-color: rgba(203, 227, 220, 0.14);
|
border-color: rgba(169, 215, 204, 0.14);
|
||||||
|
}
|
||||||
|
.sx-page--active .sx-compose .vg-btn--primary:not(:disabled) {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 0 0 1px rgba(237, 247, 244, 0.08), 0 10px 22px rgba(3, 12, 10, 0.24);
|
||||||
|
}
|
||||||
|
.sx-page--active .sx-compose .vg-btn--primary:hover:not(:disabled) {
|
||||||
|
background: #8ad1c2;
|
||||||
}
|
}
|
||||||
/* 마이크 (주 컨트롤, 음성 호흡 펄스) — 원형 예외 허용 */
|
/* 마이크 (주 컨트롤, 음성 호흡 펄스) — 원형 예외 허용 */
|
||||||
.sx-mic-block {
|
.sx-mic-block {
|
||||||
|
|
@ -1819,7 +1870,10 @@
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
|
color var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
.sx-pause:hover {
|
.sx-pause:hover {
|
||||||
background: var(--bg-surface-2);
|
background: var(--bg-surface-2);
|
||||||
|
|
@ -1846,7 +1900,10 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
|
color var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
.sx-review-button:hover {
|
.sx-review-button:hover {
|
||||||
background: color-mix(in srgb, var(--accent-tint) 80%, var(--bg-surface));
|
background: color-mix(in srgb, var(--accent-tint) 80%, var(--bg-surface));
|
||||||
|
|
@ -1886,7 +1943,9 @@
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
color var(--dur-fast) var(--ease-out);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.sx-segmented button:hover {
|
.sx-segmented button:hover {
|
||||||
|
|
@ -2629,7 +2688,7 @@
|
||||||
.sx-page--active .sx-col-center {
|
.sx-page--active .sx-col-center {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
grid-template-rows: minmax(142px, 0.26fr) minmax(0, 1fr);
|
grid-template-rows: minmax(154px, 0.28fr) minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-col-right {
|
.sx-page--active .sx-col-right {
|
||||||
|
|
@ -2703,7 +2762,7 @@
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-stage {
|
.sx-page--active .sx-stage {
|
||||||
grid-template-columns: minmax(110px, 132px) minmax(0, 1fr);
|
grid-template-columns: minmax(110px, 132px) minmax(0, 1fr);
|
||||||
padding: 10px 12px;
|
padding: 12px 14px;
|
||||||
column-gap: 12px;
|
column-gap: 12px;
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-stage .vg-avatar {
|
.sx-page--active .sx-stage .vg-avatar {
|
||||||
|
|
@ -2821,7 +2880,7 @@
|
||||||
.sx-page--active .sx-col-center {
|
.sx-page--active .sx-col-center {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
grid-template-rows: minmax(118px, 0.22fr) minmax(0, 1fr);
|
grid-template-rows: minmax(132px, 0.24fr) minmax(0, 1fr);
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -2834,7 +2893,7 @@
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.sx-page--active .sx-stage {
|
.sx-page--active .sx-stage {
|
||||||
padding: 6px 8px;
|
padding: 8px 10px;
|
||||||
grid-template-columns: minmax(88px, 104px) minmax(0, 1fr);
|
grid-template-columns: minmax(88px, 104px) minmax(0, 1fr);
|
||||||
column-gap: 10px;
|
column-gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue