개선관리 요구사항과 Google 로그인을 완료

This commit is contained in:
Yun Chan 2026-08-28 16:07:09 +09:00
parent cc0a15b7c6
commit 2a39636163
112 changed files with 10166 additions and 527 deletions

View file

@ -471,6 +471,18 @@
}
}
@media (max-width: 480px) {
/* 관리자처럼 역할을 모두 전환할 있는 계정도 테마·로그아웃 버튼을
밀어내지 않게 한다. 링크 이름은 aria-label/title에 그대로 남는다. */
.vg-topbar__switch-link {
flex: 0 0 34px;
padding: 0;
}
.vg-topbar__switch-link span {
display: none;
}
}
/* ── Botanical glass canvas ── */
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
position: relative;

View file

@ -128,6 +128,58 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/protocols": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Admin Protocols */
get: operations["list_admin_protocols_admin_protocols_get"];
put?: never;
/** Create Admin Protocol */
post: operations["create_admin_protocol_admin_protocols_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/protocols/{protocol_id}/activate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Activate Admin Protocol */
post: operations["activate_admin_protocol_admin_protocols__protocol_id__activate_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/protocols/{protocol_id}/retire": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Retire Admin Protocol */
post: operations["retire_admin_protocol_admin_protocols__protocol_id__retire_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/tickets": {
parameters: {
query?: never;
@ -1218,7 +1270,8 @@ export interface paths {
* @description (, RBAC ADMIN ). content_hash + .
*
* BackgroundTasks/ (202 Accepted).
* DSM verbatim (license C/D) source external_llm_ok .
* source_id는 kb.source만 , active
* DB .
* embedding NULL (BM25 , degraded=True) X.
*/
post: operations["index_document_kb_index_post"];
@ -2573,8 +2626,8 @@ export interface components {
};
/** ActualTransferExecutionResponse */
ActualTransferExecutionResponse: {
assessment: components["schemas"]["ActualTransferAssessment"];
execution: components["schemas"]["ActualTransferExecution"];
assessment?: components["schemas"]["ActualTransferAssessment"] | null;
execution?: components["schemas"]["ActualTransferExecution"] | null;
/** Idempotent Replay */
idempotent_replay: boolean;
};
@ -2738,6 +2791,89 @@ export interface components {
/** Smtp Configured */
smtp_configured: boolean;
};
/** AdminProtocolActivationResponse */
AdminProtocolActivationResponse: {
/** Chunks Indexed */
chunks_indexed: number;
/** Degraded */
degraded: boolean;
/** Embedded */
embedded: boolean;
protocol: components["schemas"]["AdminProtocolResponse"];
/** Skipped Unchanged */
skipped_unchanged: boolean;
};
/** AdminProtocolCreate */
AdminProtocolCreate: {
/** Content */
content: string;
/**
* External Llm Ok
* @default false
*/
external_llm_ok: boolean;
/**
* License
* @enum {string}
*/
license: "A" | "B" | "C" | "D";
/** Source */
source: string;
/** Title */
title: string;
/**
* Version
* @default 1
*/
version: number;
};
/** AdminProtocolListResponse */
AdminProtocolListResponse: {
/** Protocols */
protocols: components["schemas"]["AdminProtocolResponse"][];
/** Total */
total: number;
};
/** AdminProtocolResponse */
AdminProtocolResponse: {
/** Activated At */
activated_at?: string | null;
/** Content */
content: string;
/** Content Hash */
content_hash: string;
/** External Llm Ok */
external_llm_ok: boolean;
/**
* License
* @enum {string}
*/
license: "A" | "B" | "C" | "D";
/** Protocol Id */
protocol_id: string;
/**
* Registered At
* Format: date-time
*/
registered_at: string;
/** Registered By */
registered_by: string;
/** Retired At */
retired_at?: string | null;
/** Source */
source: string;
/** Source Id */
source_id: string;
/**
* Status
* @enum {string}
*/
status: "draft" | "active" | "retired";
/** Title */
title: string;
/** Version */
version: number;
};
/** AdminServiceHealth */
AdminServiceHealth: {
/** Detail */
@ -3073,10 +3209,11 @@ export interface components {
AdminUserCreate: {
/**
* Account Status
* @default approved
* @default pending
* @constant
* @enum {string}
*/
account_status: "pending" | "approved" | "suspended";
account_status: "pending";
/**
* Admin Access
* @default false
@ -3090,6 +3227,11 @@ export interface components {
display_name: string;
/** Email */
email: string;
/**
* Learner Feedback Enabled
* @default true
*/
learner_feedback_enabled: boolean;
/**
* Role
* @default learner
@ -3116,6 +3258,8 @@ export interface components {
cohort_ids?: string[] | null;
/** Display Name */
display_name?: string | null;
/** Learner Feedback Enabled */
learner_feedback_enabled?: boolean | null;
/** Role */
role?: ("learner" | "teacher" | "admin") | null;
};
@ -3142,6 +3286,8 @@ export interface components {
email: string;
/** Last Seen At */
last_seen_at: number;
/** Learner Feedback Enabled */
learner_feedback_enabled: boolean;
/**
* Role
* @enum {string}
@ -5881,6 +6027,58 @@ export interface components {
* @default runtime
*/
source: string;
training_exposure?: components["schemas"]["LearnerDashboardTrainingExposure"];
};
/** LearnerDashboardTrainingExposure */
LearnerDashboardTrainingExposure: {
/**
* Attention Threshold
* @default 0.75
*/
attention_threshold: number;
/**
* Completed Sessions
* @default 0
*/
completed_sessions: number;
/**
* Definition
* @default , .
*/
definition: string;
/** Dominant Persona Code */
dominant_persona_code?: string | null;
/** Dominant Persona Name */
dominant_persona_name?: string | null;
/**
* Dominant Sessions
* @default 0
*/
dominant_sessions: number;
/** Dominant Share */
dominant_share?: number | null;
/**
* Label
* @default
* @enum {string}
*/
label: "판정 근거 부족" | "훈련 집중 주의" | "균형";
/**
* Minimum Completed Sessions
* @default 4
*/
minimum_completed_sessions: number;
/**
* Status
* @default insufficient
* @enum {string}
*/
status: "insufficient" | "attention" | "balanced";
/**
* Version
* @default training-exposure-dominant-share.v1
*/
version: string;
};
/** LearnerRefMapping */
LearnerRefMapping: {
@ -5905,6 +6103,11 @@ export interface components {
client_turn_count: number;
/** Ended At */
ended_at?: string | null;
/**
* Learner Feedback Enabled
* @default true
*/
learner_feedback_enabled: boolean;
/** Learner Turn Count */
learner_turn_count: number;
/** Persona Code */
@ -8678,6 +8881,62 @@ export interface components {
/** Persona */
persona: string;
};
/** ReviewFirstSessionChecklist */
ReviewFirstSessionChecklist: {
/**
* Applicable
* @default true
*/
applicable: boolean;
/** Criteria */
criteria?: components["schemas"]["ReviewFirstSessionChecklistCriterion"][];
/**
* Note
* @default , .
*/
note: string;
/**
* Status
* @default ready
* @enum {string}
*/
status: "ready" | "not_applicable";
/**
* Title
* @default ·
*/
title: string;
/**
* Version
* @default first-session-rapport-open-question.v1
*/
version: string;
};
/** ReviewFirstSessionChecklistCriterion */
ReviewFirstSessionChecklistCriterion: {
/** Criterionid */
criterionId: string;
/** Description */
description: string;
/** Evidenceturns */
evidenceTurns?: components["schemas"]["ReviewFirstSessionChecklistEvidence"][];
/** Label */
label: string;
/**
* Status
* @enum {string}
*/
status: "met" | "not_observed";
};
/** ReviewFirstSessionChecklistEvidence */
ReviewFirstSessionChecklistEvidence: {
/** Quote */
quote: string;
/** Turnid */
turnId: string;
/** Turnseq */
turnSeq: number;
};
/** ReviewNonverbalEvent */
ReviewNonverbalEvent: {
/** Detail */
@ -9097,10 +9356,19 @@ export interface components {
ended_at?: string | null;
/** Goal Stages */
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
/**
* Learner Feedback Enabled
* @default true
*/
learner_feedback_enabled: boolean;
/** Persona Code */
persona_code: string;
/** Persona Id */
persona_id?: string | null;
/** Persona Name */
persona_name: string;
/** Persona Version */
persona_version?: number | null;
progress?: components["schemas"]["SessionProgress"] | null;
/**
* Review Ready
@ -9260,10 +9528,16 @@ export interface components {
durationLabel: string;
/** Durationseconds */
durationSeconds: number;
firstSessionChecklist?: components["schemas"]["ReviewFirstSessionChecklist"] | null;
/** Goodmoments */
goodMoments?: components["schemas"]["ReviewPoint"][];
/** Growthpoints */
growthPoints?: components["schemas"]["ReviewPoint"][];
/**
* Learnerfeedbackenabled
* @default true
*/
learnerFeedbackEnabled: boolean;
/** Nextline */
nextLine?: string | null;
/** Pdfexporturl */
@ -9284,6 +9558,8 @@ export interface components {
reviewReady: boolean;
/** Rubric */
rubric?: components["schemas"]["ReviewRubricRow"][];
/** Sessionno */
sessionNo: number;
/** Sessionsignal */
sessionSignal: string;
/** Session Id */
@ -9378,6 +9654,15 @@ export interface components {
effective_openness: number;
/** Goal Stages */
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
/**
* Learner Feedback Enabled
* @default true
*/
learner_feedback_enabled: boolean;
/** Persona Id */
persona_id: string;
/** Persona Version */
persona_version: number;
/** Recall Summary */
recall_summary?: string | null;
/** Session Id */
@ -11244,6 +11529,145 @@ export interface operations {
};
};
};
list_admin_protocols_admin_protocols_get: {
parameters: {
query?: {
status?: ("draft" | "active" | "retired") | null;
search?: string | null;
};
header?: never;
path?: never;
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminProtocolListResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_admin_protocol_admin_protocols_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["AdminProtocolCreate"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminProtocolResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
activate_admin_protocol_admin_protocols__protocol_id__activate_post: {
parameters: {
query?: never;
header?: never;
path: {
protocol_id: string;
};
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminProtocolActivationResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
retire_admin_protocol_admin_protocols__protocol_id__retire_post: {
parameters: {
query?: never;
header?: never;
path: {
protocol_id: string;
};
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminProtocolResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_tickets_admin_tickets_get: {
parameters: {
query?: {
@ -11841,7 +12265,9 @@ export interface operations {
};
get_my_calibration_transfer_calibration_learners_me_get: {
parameters: {
query?: never;
query?: {
session_id?: string | null;
};
header?: never;
path?: never;
cookie?: {
@ -14361,7 +14787,9 @@ export interface operations {
};
get_multimodal_session_metadata_sessions__session_id__multimodal_alliance_get: {
parameters: {
query?: never;
query?: {
include_derived?: boolean;
};
header?: never;
path: {
session_id: string;

View file

@ -252,6 +252,7 @@ export type LearnerDashboardResponse = ApiSchema<"LearnerDashboardResponse">;
export type LearnerDashboardPersonaProgress = ApiSchema<"LearnerDashboardPersonaProgress">;
export type LearnerDashboardAchievement = ApiSchema<"LearnerDashboardAchievement">;
export type LearnerDashboardFeedbackItem = ApiSchema<"LearnerDashboardFeedbackItem">;
export type LearnerDashboardTrainingExposure = ApiSchema<"LearnerDashboardTrainingExposure">;
export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">;
@ -274,6 +275,9 @@ export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">;
export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection-Output">;
export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">;
export type ReviewCaseWorksheetSaveRequest = ApiSchema<"ReviewCaseWorksheetSaveRequest">;
export type ReviewFirstSessionChecklistEvidence = ApiSchema<"ReviewFirstSessionChecklistEvidence">;
export type ReviewFirstSessionChecklistCriterion = ApiSchema<"ReviewFirstSessionChecklistCriterion">;
export type ReviewFirstSessionChecklist = ApiSchema<"ReviewFirstSessionChecklist">;
export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">;
export type SessionShareResponse = ApiSchema<"SessionShareResponse">;
export type SessionShareDeleteResponse = ApiSchema<"SessionShareDeleteResponse">;
@ -441,7 +445,12 @@ export async function openSessionStream(
processBuffer(true);
if (streamError) throw streamError;
return donePayload ?? { session_id: sessionId };
if (!donePayload) {
const detail = "client_stream_incomplete";
handlers.onError?.({ detail });
throw new ApiError(503, detail, { code: detail });
}
return donePayload;
}
/* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */
@ -643,6 +652,34 @@ export const adminUsersApi = {
}),
};
export type AdminProtocol = ApiSchema<"AdminProtocolResponse">;
export type AdminProtocolCreateRequest = ApiSchema<"AdminProtocolCreate">;
export type AdminProtocolListResponse = ApiSchema<"AdminProtocolListResponse">;
export type AdminProtocolActivationResponse = ApiSchema<"AdminProtocolActivationResponse">;
export type AdminProtocolStatus = AdminProtocol["status"];
export const adminProtocolsApi = {
list: (filters: { status?: AdminProtocolStatus | ""; search?: string } = {}) => {
const params = new URLSearchParams();
if (filters.status) params.set("status", filters.status);
if (filters.search?.trim()) params.set("search", filters.search.trim());
const query = params.toString();
return api.get<AdminProtocolListResponse>(`/admin/protocols${query ? `?${query}` : ""}`);
},
create: (body: AdminProtocolCreateRequest) =>
api.post<AdminProtocol>("/admin/protocols", body),
activate: (protocolId: string) =>
api.post<AdminProtocolActivationResponse>(
`/admin/protocols/${encodeURIComponent(protocolId)}/activate`,
{},
),
retire: (protocolId: string) =>
api.post<AdminProtocol>(
`/admin/protocols/${encodeURIComponent(protocolId)}/retire`,
{},
),
};
export type TeacherSessionSummary = ApiSchema<"TeacherSessionSummary">;
export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">;

View file

@ -1,5 +1,7 @@
const DISPLAY_PLACEHOLDERS: Record<string, string> = {
"[NAME]": "익명 내담자",
"[COUNSELOR]": "상담자",
"[CLIENT]": "내담자",
"[NAME]": "익명 인물",
"[ORG]": "소속 기관",
"[PHONE]": "연락처",
"[EMAIL]": "이메일",

View file

@ -29,10 +29,14 @@ import {
} from "../components/ui";
import {
adminApi,
adminProtocolsApi,
adminUsersApi,
type AdminHealthResponse,
type AdminHealthStatus,
type AdminManagedUser,
type AdminProtocol,
type AdminProtocolCreateRequest,
type AdminProtocolStatus,
type AdminTicketFilters,
type AdminSupportTicket,
type AdminTicketsResponse,
@ -58,6 +62,7 @@ type UserDraft = Pick<
| "display_name"
| "role"
| "admin_access"
| "learner_feedback_enabled"
| "account_status"
| "affiliation"
| "cohort_ids"
@ -67,10 +72,17 @@ type NewUserDraft = Required<
> &
Pick<
UserDraft,
"admin_access" | "account_status" | "affiliation" | "cohort_ids"
>;
| "admin_access"
| "learner_feedback_enabled"
| "affiliation"
| "cohort_ids"
> & {
account_status: "pending";
};
type UserTab = "approval" | "manage" | "register" | "activity";
type AccessTab = "roles" | "groups" | "matrix";
type AccessTab = "roles" | "groups" | "matrix" | "protocols";
type ProtocolStatusFilter = AdminProtocolStatus | "all";
type ProtocolDraft = AdminProtocolCreateRequest;
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
@ -116,11 +128,46 @@ const EMPTY_NEW_USER: NewUserDraft = {
display_name: "",
role: "learner",
admin_access: false,
account_status: "approved",
learner_feedback_enabled: true,
account_status: "pending",
affiliation: "",
cohort_ids: [],
};
const EMPTY_PROTOCOL_DRAFT: ProtocolDraft = {
title: "",
source: "",
version: 1,
license: "B",
external_llm_ok: false,
content: "",
};
function protocolStatusLabel(status: AdminProtocolStatus): string {
if (status === "draft") return "초안";
if (status === "active") return "활성";
return "퇴역";
}
function protocolStatusTone(
status: AdminProtocolStatus,
): "warn" | "pos" | "neutral" {
if (status === "draft") return "warn";
if (status === "active") return "pos";
return "neutral";
}
function protocolAllowsExternalLlm(license: ProtocolDraft["license"]): boolean {
return license === "A" || license === "B";
}
function protocolVersionError(version: number): string | null {
if (!Number.isInteger(version) || version < 1 || version > 1_000_000) {
return "버전은 1부터 1,000,000 사이의 정수여야 합니다.";
}
return null;
}
const ROLE_POLICIES = [
{
role: "관리자",
@ -516,6 +563,7 @@ function userDraftFrom(user: AdminManagedUser): UserDraft {
display_name: user.display_name,
role: user.role,
admin_access: user.admin_access,
learner_feedback_enabled: user.learner_feedback_enabled ?? true,
account_status: user.account_status,
affiliation: user.affiliation,
cohort_ids: user.cohort_ids,
@ -827,6 +875,18 @@ export default function Admin({ section = "overview" }: AdminProps) {
const [ticketStaleOnly, setTicketStaleOnly] = useState(false);
const [userTab, setUserTab] = useState<UserTab>("approval");
const [accessTab, setAccessTab] = useState<AccessTab>("roles");
const [protocols, setProtocols] = useState<AdminProtocol[]>([]);
const [protocolsLoaded, setProtocolsLoaded] = useState(false);
const [protocolsLoading, setProtocolsLoading] = useState(false);
const [protocolsError, setProtocolsError] = useState<string | null>(null);
const [protocolDraft, setProtocolDraft] = useState<ProtocolDraft>(
EMPTY_PROTOCOL_DRAFT,
);
const [protocolSaving, setProtocolSaving] = useState(false);
const [protocolActionId, setProtocolActionId] = useState<string | null>(null);
const [protocolSearch, setProtocolSearch] = useState("");
const [protocolStatusFilter, setProtocolStatusFilter] =
useState<ProtocolStatusFilter>("all");
const canGrantAdminAccess = currentUser?.superAdmin === true;
const loadHealth = useCallback(async () => {
@ -940,6 +1000,24 @@ export default function Admin({ section = "overview" }: AdminProps) {
ticketStatusFilter,
]);
const loadProtocols = useCallback(async () => {
setProtocolsLoading(true);
setProtocolsError(null);
try {
const response = await adminProtocolsApi.list();
setProtocols(response.protocols);
setProtocolsLoaded(true);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: "상담 프로토콜 목록을 불러오지 못했습니다.",
);
} finally {
setProtocolsLoading(false);
}
}, []);
useEffect(() => {
void loadHealth();
void loadUsers();
@ -951,6 +1029,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
void loadTickets();
}, [loadTickets]);
useEffect(() => {
if (section === "access" && accessTab === "protocols" && !protocolsLoaded) {
void loadProtocols();
}
}, [accessTab, loadProtocols, protocolsLoaded, section]);
const refreshAll = useCallback(async () => {
await Promise.all([
loadHealth(),
@ -961,6 +1045,109 @@ export default function Admin({ section = "overview" }: AdminProps) {
]);
}, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]);
const visibleProtocols = useMemo(() => {
const query = protocolSearch.trim().toLocaleLowerCase("ko-KR");
return protocols.filter((protocol) => {
if (
protocolStatusFilter !== "all" &&
protocol.status !== protocolStatusFilter
) {
return false;
}
if (!query) return true;
return [protocol.title, protocol.source, protocol.source_id]
.join(" ")
.toLocaleLowerCase("ko-KR")
.includes(query);
});
}, [protocolSearch, protocolStatusFilter, protocols]);
const createProtocol = async () => {
if (
!protocolDraft.title.trim() ||
!protocolDraft.source.trim() ||
!protocolDraft.content.trim()
) {
setProtocolsError("제목, 출처, 원문을 모두 입력해야 합니다.");
return;
}
const versionError = protocolVersionError(protocolDraft.version);
if (versionError) {
setProtocolsError(versionError);
return;
}
setProtocolSaving(true);
setProtocolsError(null);
try {
const created = await adminProtocolsApi.create({
...protocolDraft,
title: protocolDraft.title.trim(),
source: protocolDraft.source.trim(),
content: protocolDraft.content.trim(),
external_llm_ok:
protocolAllowsExternalLlm(protocolDraft.license)
? protocolDraft.external_llm_ok
: false,
});
setProtocols((current) => [
created,
...current.filter((item) => item.protocol_id !== created.protocol_id),
]);
setProtocolDraft(EMPTY_PROTOCOL_DRAFT);
setProtocolsLoaded(true);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: "상담 프로토콜 초안을 등록하지 못했습니다.",
);
} finally {
setProtocolSaving(false);
}
};
const activateProtocol = async (protocol: AdminProtocol) => {
setProtocolActionId(protocol.protocol_id);
setProtocolsError(null);
try {
const response = await adminProtocolsApi.activate(protocol.protocol_id);
setProtocols((current) =>
current.map((item) =>
item.protocol_id === protocol.protocol_id ? response.protocol : item,
),
);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: `${protocol.title}” 프로토콜을 활성화하지 못했습니다.`,
);
} finally {
setProtocolActionId(null);
}
};
const retireProtocol = async (protocol: AdminProtocol) => {
setProtocolActionId(protocol.protocol_id);
setProtocolsError(null);
try {
const retired = await adminProtocolsApi.retire(protocol.protocol_id);
setProtocols((current) =>
current.map((item) =>
item.protocol_id === protocol.protocol_id ? retired : item,
),
);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: `${protocol.title}” 프로토콜을 퇴역하지 못했습니다.`,
);
} finally {
setProtocolActionId(null);
}
};
const updateDraft = (userId: string, patch: Partial<UserDraft>) => {
setUserDrafts((current) => ({
...current,
@ -969,6 +1156,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
display_name: "",
role: "learner",
admin_access: false,
learner_feedback_enabled: true,
account_status: "approved",
affiliation: "",
cohort_ids: [],
@ -1017,6 +1205,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
display_name: displayName,
affiliation: newUser.affiliation.trim(),
cohort_ids: newUser.cohort_ids,
account_status: "pending",
});
setNewUser(EMPTY_NEW_USER);
await loadUsers();
@ -1388,6 +1577,31 @@ export default function Admin({ section = "overview" }: AdminProps) {
);
},
},
{
id: "learner_feedback_enabled",
accessorFn: (user) => Number(user.learner_feedback_enabled),
header: "AI 피드백",
cell: ({ row }) => {
const user = row.original;
const draft = userDrafts[user.user_id] ?? user;
return (
<label className="vgops-user-table__check">
<input
type="checkbox"
checked={draft.learner_feedback_enabled}
onChange={(event) =>
updateDraft(user.user_id, {
learner_feedback_enabled: event.target.checked,
})
}
disabled={!usersWritable || savingUserId === user.user_id}
aria-label={`${user.email} 학습자 AI 피드백`}
/>
<span>{draft.learner_feedback_enabled ? "켜짐" : "꺼짐"}</span>
</label>
);
},
},
{
id: "affiliation",
accessorFn: (user) => user.affiliation,
@ -1465,6 +1679,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
draft.display_name !== user.display_name ||
draft.role !== user.role ||
draft.admin_access !== user.admin_access ||
draft.learner_feedback_enabled !==
(user.learner_feedback_enabled ?? true) ||
draft.account_status !== user.account_status ||
draft.affiliation !== user.affiliation ||
cohortInputValue(draft.cohort_ids) !==
@ -1868,7 +2084,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
`가입 승인${accountCounts.pending ? ` ${accountCounts.pending}` : ""}`,
],
["manage", "사용자 목록"],
["register", "사용자 등록"],
["register", "외부 연구참여자 사전등록"],
["activity", "활동 요약"],
]}
value={userTab}
@ -2012,12 +2228,13 @@ export default function Admin({ section = "overview" }: AdminProps) {
const renderUserCreate = () => (
<section className={surfaceClassName("vgops-panel")}>
<div className="vgops-section__head">
<h2> </h2>
<h2> </h2>
<span>{usersWritable ? "DB 저장 가능" : "읽기 전용"}</span>
</div>
<div className="vgops-users-note">
.
.
Google·
. ,
.
</div>
{!usersWritable ? (
<div className="vgops-users-note vgops-users-note--warn" role="alert">
@ -2034,6 +2251,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
<label>
<span></span>
<input
type="email"
value={newUser.email}
onChange={(event) =>
setNewUser((current) => ({
@ -2041,7 +2259,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
email: event.target.value,
}))
}
placeholder="name@example.com"
placeholder="participant@example.com"
autoComplete="email"
disabled={!usersWritable || creatingUser}
aria-label="새 사용자 이메일"
/>
@ -2100,24 +2319,30 @@ export default function Admin({ section = "overview" }: AdminProps) {
/>
<span> </span>
</label>
<label>
<span> </span>
<select
value={newUser.account_status}
<label className="vgops-checkline">
<input
type="checkbox"
checked={newUser.learner_feedback_enabled}
onChange={(event) =>
setNewUser((current) => ({
...current,
account_status: event.target
.value as AdminManagedUser["account_status"],
learner_feedback_enabled: event.target.checked,
}))
}
disabled={!usersWritable || creatingUser}
aria-label="새 사용자 학습자 AI 피드백"
/>
<span> AI </span>
</label>
<label>
<span> </span>
<output
className="vgops-readonly-control"
aria-label="새 사용자 승인 상태"
data-value="pending"
>
<option value="approved"></option>
<option value="pending"> </option>
<option value="suspended"></option>
</select>
</output>
</label>
<label>
<span></span>
@ -2154,7 +2379,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
leading={<Icon name="users" size={15} />}
disabled={!usersWritable || creatingUser}
>
{creatingUser ? "등록 중" : "사용자 등록"}
{creatingUser ? "사전등록 중" : "연구참여자 사전등록"}
</Button>
</form>
</section>
@ -2354,23 +2579,303 @@ export default function Admin({ section = "overview" }: AdminProps) {
</section>
);
const renderProtocols = () => (
<section className="vgops-protocols" aria-label="상담 프로토콜 관리">
<section className={surfaceClassName("vgops-panel vgops-protocol-create") }>
<div className="vgops-section__head">
<h2> </h2>
<Badge tone="neutral"> </Badge>
</div>
<p className="vgops-protocol-intro">
RAG에 , C/D LLM에
.
</p>
<form
className="vgops-protocol-form"
onSubmit={(event) => {
event.preventDefault();
void createProtocol();
}}
>
<label>
<span></span>
<input
required
value={protocolDraft.title}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
title: event.target.value,
}))
}
aria-label="프로토콜 제목"
disabled={protocolSaving}
/>
</label>
<label className="vgops-protocol-form__source">
<span></span>
<input
required
value={protocolDraft.source}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
source: event.target.value,
}))
}
placeholder="공식 문서 URL 또는 출처 식별자"
aria-label="프로토콜 출처"
disabled={protocolSaving}
/>
</label>
<label>
<span></span>
<input
type="number"
min={1}
max={1_000_000}
required
value={protocolDraft.version}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
version: Number(event.target.value),
}))
}
aria-label="프로토콜 버전"
aria-invalid={protocolVersionError(protocolDraft.version) !== null}
aria-describedby="protocol-version-error"
disabled={protocolSaving}
/>
{protocolVersionError(protocolDraft.version) ? (
<small
id="protocol-version-error"
className="vgops-protocol-field-error"
role="alert"
>
{protocolVersionError(protocolDraft.version)}
</small>
) : null}
</label>
<label>
<span></span>
<select
value={protocolDraft.license}
onChange={(event) => {
const license = event.target.value as ProtocolDraft["license"];
setProtocolDraft((current) => ({
...current,
license,
external_llm_ok:
protocolAllowsExternalLlm(license)
? current.external_llm_ok
: false,
}));
}}
aria-label="프로토콜 라이선스"
disabled={protocolSaving}
>
{(["A", "B", "C", "D"] as const).map((license) => (
<option value={license} key={license}>
{license}
</option>
))}
</select>
</label>
<label className="vgops-protocol-check">
<input
type="checkbox"
checked={protocolDraft.external_llm_ok}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
external_llm_ok: event.target.checked,
}))
}
aria-label="외부 LLM 사용 허용"
disabled={
protocolSaving ||
protocolDraft.license === "C" ||
protocolDraft.license === "D"
}
/>
<span> LLM </span>
</label>
<p className="vgops-protocol-policy" aria-live="polite">
{protocolDraft.license === "C" || protocolDraft.license === "D"
? "라이선스 C/D는 정책상 외부 LLM 사용이 차단됩니다."
: "A/B도 명시적으로 허용한 경우에만 외부 LLM에 전달됩니다."}
</p>
<label className="vgops-protocol-form__content">
<span> </span>
<textarea
required
value={protocolDraft.content}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
content: event.target.value,
}))
}
aria-label="프로토콜 원문"
disabled={protocolSaving}
/>
</label>
<Button
className="vgops-protocol-submit"
type="submit"
disabled={protocolSaving}
leading={<Icon name="plus" size={16} />}
>
{protocolSaving ? "초안 등록 중" : "초안 등록"}
</Button>
</form>
</section>
<section className={surfaceClassName("vgops-panel vgops-protocol-list") }>
<div className="vgops-section__head">
<h2> </h2>
<span>{protocols.length}</span>
</div>
<div className="vgops-protocol-toolbar">
<label>
<span></span>
<input
type="search"
value={protocolSearch}
onChange={(event) => setProtocolSearch(event.target.value)}
placeholder="제목 또는 출처"
aria-label="프로토콜 검색"
/>
</label>
<label>
<span></span>
<select
value={protocolStatusFilter}
onChange={(event) =>
setProtocolStatusFilter(event.target.value as ProtocolStatusFilter)
}
aria-label="프로토콜 상태 필터"
>
<option value="all"></option>
<option value="draft"></option>
<option value="active"></option>
<option value="retired"></option>
</select>
</label>
<Button
variant="secondary"
onClick={() => void loadProtocols()}
disabled={protocolsLoading}
>
{protocolsLoading ? "불러오는 중" : "새로고침"}
</Button>
</div>
{protocolsError ? (
<div className="vgops-protocol-error">
<InlineError message={protocolsError} />
<Button
variant="secondary"
onClick={() => void loadProtocols()}
disabled={protocolsLoading}
>
</Button>
</div>
) : null}
{protocolsLoading && !protocolsLoaded ? (
<div className="vgops-users-note" role="status">
.
</div>
) : null}
{!protocolsLoading && protocolsLoaded && visibleProtocols.length === 0 ? (
<EmptyState
title="조건에 맞는 프로토콜이 없습니다"
body="새 초안을 등록하거나 검색·상태 필터를 바꿔 주세요."
/>
) : null}
<div className="vgops-protocol-cards">
{visibleProtocols.map((protocol) => (
<article
className={surfaceClassName("vgops-protocol-card", {
variant: "inset",
})}
key={protocol.protocol_id}
data-protocol-status={protocol.status}
>
<div className="vgops-protocol-card__copy">
<div className="vgops-protocol-card__title">
<h3>{protocol.title}</h3>
<Badge tone={protocolStatusTone(protocol.status)}>
{protocolStatusLabel(protocol.status)}
</Badge>
</div>
<p>{protocol.source}</p>
<div className="vgops-chip-row">
<span>v{protocol.version}</span>
<span> {protocol.license}</span>
<span>
LLM {protocol.external_llm_ok ? "허용" : "차단"}
</span>
</div>
</div>
<div className="vgops-protocol-card__actions">
{protocol.status === "draft" ? (
<Button
onClick={() => void activateProtocol(protocol)}
disabled={protocolActionId === protocol.protocol_id}
aria-label={`${protocol.title} 활성화`}
>
{protocolActionId === protocol.protocol_id
? "활성화 중"
: "활성화"}
</Button>
) : null}
{protocol.status === "active" ? (
<Button
variant="danger"
onClick={() => void retireProtocol(protocol)}
disabled={protocolActionId === protocol.protocol_id}
aria-label={`${protocol.title} 퇴역`}
>
{protocolActionId === protocol.protocol_id ? "퇴역 중" : "퇴역"}
</Button>
) : null}
{protocol.status === "retired" ? (
<span className="vgops-protocol-card__terminal"> </span>
) : null}
</div>
</article>
))}
</div>
</section>
</section>
);
const renderAccess = () => (
<>
{/* eyebrow "접근 권한"은 아래 "역할, 그룹, 접근 범위"와 같은 말이라 제거 */}
<PageHeader
title="역할, 그룹, 접근 범위"
description="운영자가 누구에게 어떤 작업 권한을 줄지 판단하는 정책 화면입니다."
description="운영 권한 정책과 평가용 상담 프로토콜의 수명주기를 관리합니다."
/>
<div className="vgops-users-note">
· API는 .
.
</div>
{accessTab !== "protocols" ? (
<div className="vgops-users-note">
· API는 .
.
</div>
) : null}
<TabBar
ariaLabel="접근 권한 탭"
items={[
["roles", "역할"],
["groups", "그룹"],
["matrix", "권한 매트릭스"],
["protocols", "상담 프로토콜"],
]}
value={accessTab}
onChange={setAccessTab}
@ -2447,6 +2952,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
</div>
</section>
) : null}
{accessTab === "protocols" ? renderProtocols() : null}
</>
);

View file

@ -427,6 +427,11 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
);
const topPersona =
personaProgressRows.find((row) => row.total > 0) ?? personaProgressRows[0];
const trainingExposure = dashboard?.training_exposure;
const dominantExposurePercent =
typeof trainingExposure?.dominant_share === "number"
? Math.round(trainingExposure.dominant_share * 100)
: null;
const personaCatalogUnavailable = loadState === "ready" && !selected;
const hasSessionRecords = sessionLoadState === "ready" && sessions.length > 0;
const rootClassName = [
@ -1596,14 +1601,15 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
</div>
<div className="lh-insight-list">
<div>
<span> </span>
<b>
{topPersona?.total
? "주호소 범위를 넓힐 차례"
: "첫 회기 이후 표시"}
</b>
<span> </span>
<b>{trainingExposure?.label ?? "판정 근거 부족"}</b>
<p>
.
{trainingExposure?.status === "insufficient"
? `종료 회기 ${trainingExposure.completed_sessions}/4회 · 4회부터 최다 노출 비중을 확인합니다.`
: `${trainingExposure?.dominant_persona_name ?? "최다 연습 대상"} ${trainingExposure?.dominant_sessions ?? 0}/${trainingExposure?.completed_sessions ?? 0}회 · 최다 노출 ${dominantExposurePercent ?? 0}%`}
</p>
<p>
.
</p>
</div>
</div>

View file

@ -26,7 +26,7 @@ const OAUTH_PROVIDER_FAILED_MESSAGE =
const OAUTH_UNSUPPORTED_PROVIDER_MESSAGE =
"지원하지 않는 로그인 공급자입니다. Google 로그인 버튼으로 다시 시작하세요.";
const SAML_NOT_CONFIGURED_MESSAGE =
"학교 SSO가 아직 연결되지 않았습니다. 현재는 승인된 Google 계정으로 로그인하세요.";
"학교 SSO가 아직 연결되지 않았습니다. 현재는 Google 계정으로 로그인하세요.";
const SAML_FAILED_MESSAGE =
"학교 SSO 로그인 흐름을 완료하지 못했습니다. 관리자에게 SSO 설정 확인을 요청하세요.";
@ -131,21 +131,16 @@ export default function Login() {
!isLocalRedirectUri(authConfig.redirect_uri);
const oauthReady =
authConfig?.google_oauth_configured === true && !devOAuthUnavailable;
const allowedDomains = authConfig?.allowed_email_domains ?? [];
const primaryDomainLabel = devOAuthUnavailable
const primaryAccountLabel = devOAuthUnavailable
? "로컬은 테스트 계정 사용"
: allowedDomains[0]
? `@${allowedDomains[0]}`
: oauthChecking
? "도메인 확인 중"
: "승인 도메인 계정";
const secondaryDomainLabel = devOAuthUnavailable
: "모든 Google 계정";
const secondaryAccountLabel = devOAuthUnavailable
? "공개 주소에서 사용"
: allowedDomains[1]
? `@${allowedDomains[1]}`
: allowedDomains[0]
? "승인된 Google 계정"
: "관리자 설정 필요";
: oauthChecking
? "계정 정책 확인 중"
: "이메일 확인 후 바로 시작";
const oauthStatusMessage = devOAuthUnavailable
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
: oauthChecking
@ -183,12 +178,12 @@ export default function Login() {
return (
<AuthShell className="lg-root" variant="room">
<LoginBrand allowedDomains={allowedDomains} oauthChecking={oauthChecking} />
<LoginBrand oauthChecking={oauthChecking} oauthReady={oauthReady} />
<LoginPanel
oauth={{
ready: oauthReady,
primaryDomainLabel,
secondaryDomainLabel,
primaryAccountLabel,
secondaryAccountLabel,
statusIcon: authConfigError ? "alert" : "info",
statusMessage: oauthStatusMessage,
onStart: startOAuth,

View file

@ -1381,6 +1381,17 @@ export default function Session() {
} catch (err) {
if (err instanceof ApiError && err.detail === "consent_required") {
setStartError("개인정보 및 연습 기록 처리 동의 후 회기를 시작할 수 있습니다.");
} else if (err instanceof ApiError && err.status === 404) {
setStartError(
"이 페르소나는 아직 승인되지 않았거나 공개 목록에서 제외됐습니다. 승인 상태를 확인한 뒤 목록에서 다시 선택해 주세요.",
);
} else if (
err instanceof ApiError &&
err.detail === "session_persistence_unavailable"
) {
setStartError(
"세션 기록 저장소에 연결할 수 없습니다. 기록 유실을 막기 위해 시작하지 않았습니다. 잠시 뒤 다시 시도해 주세요.",
);
} else {
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
}
@ -1621,11 +1632,13 @@ export default function Session() {
setAvatarState("idle");
return;
}
const normalized = detail.includes("Not logged in")
? "AI 엔진 로그인이 필요합니다. 관리자에게 엔진 상태 확인을 요청하세요."
: detail.includes("engine unavailable")
? "AI 엔진이 응답하지 않습니다. 잠시 뒤 다시 시도하거나 관리자에게 알려 주세요."
: "내담자 응답을 생성하지 못했습니다. 잠시 뒤 다시 시도해 주세요.";
const normalized = detail.includes("client_stream_incomplete")
? "내담자 응답 연결이 중간에 끊겼습니다. 입력 내용은 복원했습니다. 같은 발화로 다시 시도해 주세요."
: detail.includes("Not logged in")
? "AI 엔진 로그인이 필요합니다. 입력 내용은 복원했습니다. 관리자에게 엔진 상태 확인을 요청하세요."
: detail.includes("engine unavailable") || detail.includes("engine stream")
? "AI 엔진이 응답하지 않습니다. 입력 내용은 복원했습니다. 잠시 뒤 다시 시도하고, 반복되면 관리자에게 내담자 응답 엔진 상태 확인을 요청하세요."
: "내담자 응답을 생성하지 못했습니다. 입력 내용은 복원했습니다. 같은 발화로 다시 시도해 주세요.";
setTurnError(normalized);
pushSignal("warn", "AI 엔진 연결 실패");
setAvatarState("listening");

View file

@ -36,6 +36,7 @@ import {
type UserPrepostMeasuresResponse,
} from "../lib/api";
import { canAccessRole, useAuth } from "../lib/auth";
import { displayPiiSafeText } from "../lib/piiDisplay";
import {
parsePracticeLaunchIntent,
practiceCriterionLabel,
@ -1019,17 +1020,16 @@ export default function SessionReview() {
function jumpToTurn(id: string) {
if (!data) return;
const turns = data.turns ?? [];
setActiveTurn(id);
const target = turns.find((turn) => turn.id === id || turn.turn_id === id);
const displayId = target?.id ?? id;
setActiveTurn(displayId);
setReviewTab("transcript");
if (
learnerOnly &&
turns.find((turn) => turn.id === id)?.speaker === "client"
) {
if (learnerOnly && target?.speaker === "client") {
setLearnerOnly(false);
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
turnRefs.current[id]?.scrollIntoView({
turnRefs.current[displayId]?.scrollIntoView({
behavior: "smooth",
block: "center",
});
@ -1211,19 +1211,26 @@ export default function SessionReview() {
}
const turns = data.turns ?? [];
const sessionNo =
(data as SessionReviewResponse & { sessionNo?: number }).sessionNo ?? 1;
const phases = data.phases ?? [];
const phaseAxis = data.phaseAxis ?? [];
const valenceAxis = data.valenceAxis ?? [];
const clientValence = data.clientValence ?? [];
const counselorBaseline = data.counselorBaseline ?? [];
const rubric = data.rubric ?? [];
const firstSessionChecklist = data.firstSessionChecklist;
const goodMoments = data.goodMoments ?? [];
const growthPoints = data.growthPoints ?? [];
const hasValence = clientValence.length > 0 || counselorBaseline.length > 0;
const canOpenAudio = Boolean(data.audioUrl);
const canExportPdf = Boolean(data.pdfExportUrl);
const hasTranscript = turns.length > 0;
const canCreateShare = !isSupervisorView && data.sessionSignal === "종료됨";
const learnerFeedbackVisible = isSupervisorView || data.learnerFeedbackEnabled;
const canCreateShare =
!isSupervisorView &&
data.learnerFeedbackEnabled &&
data.sessionSignal === "종료됨";
const teacherReview = data.teacherReview;
const teacherReviewStatus = teacherReview?.status ?? "pending";
const teacherReviewStatusLabel =
@ -1464,7 +1471,7 @@ export default function SessionReview() {
>
PDF
</Button>
{!isSupervisorView ? (
{!isSupervisorView && data.learnerFeedbackEnabled ? (
<>
<Button
variant="secondary"
@ -1509,6 +1516,7 @@ export default function SessionReview() {
sessionId={data.session_id}
turns={turns}
isSupervisorView={isSupervisorView}
feedbackEnabled={learnerFeedbackVisible}
onJumpToTurn={jumpToTurn}
/>
) : null}
@ -1559,9 +1567,10 @@ export default function SessionReview() {
sessionId={data.session_id}
isSupervisorView={false}
practiceLaunchIntent={returnedTransferIntent}
feedbackEnabled={data.learnerFeedbackEnabled}
/>
) : null}
{data.sessionSignal === "종료됨" && returnedDeliberateIntent ? (
{data.sessionSignal === "종료됨" && returnedDeliberateIntent && learnerFeedbackVisible ? (
<DeliberatePracticeCard
sessionId={data.session_id}
turns={turns}
@ -1574,6 +1583,7 @@ export default function SessionReview() {
<MultimodalAllianceCard
sessionId={data.session_id}
isSupervisorView={isSupervisorView}
feedbackEnabled={learnerFeedbackVisible}
/>
) : null}
{data.sessionSignal === "종료됨" && !returnedTransferIntent ? (
@ -1581,17 +1591,20 @@ export default function SessionReview() {
sessionId={data.session_id}
isSupervisorView={isSupervisorView}
practiceLaunchIntent={null}
feedbackEnabled={learnerFeedbackVisible}
/>
) : null}
{data.sessionSignal === "종료됨" ? (
<OutcomeTrajectoryCard
sessionId={data.session_id}
sessionNo={sessionNo}
turns={turns}
isSupervisorView={isSupervisorView}
feedbackEnabled={learnerFeedbackVisible}
onJumpToTurn={jumpToTurn}
/>
) : null}
{data.sessionSignal === "종료됨" ? (
{data.sessionSignal === "종료됨" && learnerFeedbackVisible ? (
<RuptureRepairCard
sessionId={data.session_id}
turns={turns}
@ -1599,7 +1612,7 @@ export default function SessionReview() {
onJumpToTurn={jumpToTurn}
/>
) : null}
{data.sessionSignal === "종료됨" && !returnedDeliberateIntent ? (
{data.sessionSignal === "종료됨" && !returnedDeliberateIntent && learnerFeedbackVisible ? (
<DeliberatePracticeCard
sessionId={data.session_id}
turns={turns}
@ -1608,7 +1621,7 @@ export default function SessionReview() {
onJumpToTurn={jumpToTurn}
/>
) : null}
<Card className="sr-card sr-card--chart">
{learnerFeedbackVisible ? <Card className="sr-card sr-card--chart">
<Kicker dot={false}> </Kicker>
<div style={{ marginTop: "var(--sp-4)" }}>
{hasValence ? (
@ -1624,9 +1637,9 @@ export default function SessionReview() {
/>
)}
</div>
</Card>
</Card> : null}
<Card className="sr-card sr-card--flow">
{learnerFeedbackVisible ? <Card className="sr-card sr-card--flow">
<Kicker dot={false}> </Kicker>
<div className="sr-phasebar" style={{ marginTop: "var(--sp-4)" }}>
{phases.length > 0 ? (
@ -1662,7 +1675,7 @@ export default function SessionReview() {
/>
)}
</div>
</Card>
</Card> : null}
</div>
<Card
@ -1934,7 +1947,60 @@ export default function SessionReview() {
</Card>
) : null}
<Card className="sr-card sr-card--side sr-card--rubric">
{learnerFeedbackVisible && firstSessionChecklist?.applicable ? (
<Card className="sr-card sr-card--side sr-card--first-session-checklist">
<div className="sr-first-checklist__heading">
<Kicker dot={false}>{firstSessionChecklist.title}</Kicker>
<span className="sr-first-checklist__version">
{firstSessionChecklist.version}
</span>
</div>
<p className="sr-first-checklist__note">
{firstSessionChecklist.note}
</p>
<div className="sr-first-checklist__items">
{(firstSessionChecklist.criteria ?? []).map((criterion) => (
<section
key={criterion.criterionId}
className="sr-first-checklist__item"
data-criterion-id={criterion.criterionId}
>
<div className="sr-first-checklist__item-head">
<b>{criterion.label}</b>
<span
className={`sr-first-checklist__status sr-first-checklist__status--${criterion.status}`}
>
{criterion.status === "met" ? "근거 확인" : "근거 미발견"}
</span>
</div>
<p>{criterion.description}</p>
{(criterion.evidenceTurns ?? []).length > 0 ? (
<div className="sr-first-checklist__evidence-list">
{(criterion.evidenceTurns ?? []).map((evidence) => (
<button
key={`${criterion.criterionId}-${evidence.turnId}`}
type="button"
className="sr-first-checklist__evidence"
onClick={() => jumpToTurn(evidence.turnId)}
aria-label={`${criterion.label} 근거 ${evidence.turnSeq}번 발화로 이동`}
>
<span>{evidence.turnSeq} </span>
<q>{displayPiiSafeText(evidence.quote)}</q>
</button>
))}
</div>
) : (
<p className="sr-first-checklist__empty">
.
</p>
)}
</section>
))}
</div>
</Card>
) : null}
{learnerFeedbackVisible ? <Card className="sr-card sr-card--side sr-card--rubric">
<Kicker dot={false}> </Kicker>
<div className="sr-rubric" style={{ marginTop: "var(--sp-4)" }}>
{rubric.length > 0 ? (
@ -1972,9 +2038,9 @@ export default function SessionReview() {
/>
)}
</div>
</Card>
</Card> : null}
<Card className="sr-card sr-card--side sr-card--good">
{learnerFeedbackVisible ? <Card className="sr-card sr-card--side sr-card--good">
<Kicker dot={false}> </Kicker>
<div
className="sr-points sr-points--good"
@ -1996,9 +2062,9 @@ export default function SessionReview() {
/>
)}
</div>
</Card>
</Card> : null}
<Card className="sr-card sr-card--side sr-card--growth">
{learnerFeedbackVisible ? <Card className="sr-card sr-card--side sr-card--growth">
<Kicker dot={false}> </Kicker>
<div
className="sr-points sr-points--grow"
@ -2035,11 +2101,11 @@ export default function SessionReview() {
</div>
</div>
) : null}
</Card>
</Card> : null}
{!isSupervisorView ? <PrepostMeasureCard /> : null}
<div
{learnerFeedbackVisible ? <div
className={surfaceClassName(
`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`,
{ variant: "inset" },
@ -2068,7 +2134,7 @@ export default function SessionReview() {
</div>
</>
)}
</div>
</div> : null}
</div>
<div

View file

@ -608,7 +608,7 @@
.vgops-tabs button {
flex: 0 0 auto;
height: 34px;
min-height: 44px;
border: 1px solid var(--hair);
border-radius: var(--radius);
background: var(--bg-surface);
@ -660,12 +660,13 @@
.vgops-user-create input,
.vgops-user-create select,
.vgops-user-create output,
.vgops-users-toolbar input,
.vgops-user-table input,
.vgops-user-table select {
width: 100%;
min-width: 0;
height: 36px;
min-height: 44px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
@ -673,6 +674,12 @@
padding: 0 10px;
font: var(--fs-sm) / 1.3 var(--font-sans);
}
.vgops-user-create output {
display: flex;
align-items: center;
background: var(--bg-surface-2);
color: var(--text);
}
.vgops-user-create .vgops-checkline input {
width: 16px;
height: 16px;
@ -692,7 +699,7 @@
.vgops-user-create .vg-btn {
width: 100%;
height: 36px;
min-height: 44px;
justify-content: center;
white-space: nowrap;
padding-inline: 12px;
@ -756,7 +763,7 @@
.vgops-user-table {
width: 100%;
min-width: 1440px;
min-width: 1580px;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
@ -804,11 +811,12 @@
.vgops-user-table th:nth-child(2) { width: 140px; }
.vgops-user-table th:nth-child(3) { width: 112px; }
.vgops-user-table th:nth-child(4) { width: 145px; }
.vgops-user-table th:nth-child(5) { width: 180px; }
.vgops-user-table th:nth-child(6) { width: 155px; }
.vgops-user-table th:nth-child(7) { width: 90px; }
.vgops-user-table th:nth-child(8) { width: 160px; }
.vgops-user-table th:nth-child(9) { width: 208px; }
.vgops-user-table th:nth-child(5) { width: 140px; }
.vgops-user-table th:nth-child(6) { width: 180px; }
.vgops-user-table th:nth-child(7) { width: 155px; }
.vgops-user-table th:nth-child(8) { width: 90px; }
.vgops-user-table th:nth-child(9) { width: 160px; }
.vgops-user-table th:nth-child(10) { width: 208px; }
.vgops-user-sort {
width: 100%;
@ -1267,6 +1275,196 @@
font-size: var(--fs-sm);
}
.vgops-protocols {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.25fr);
align-items: start;
gap: 12px;
min-width: 0;
}
.vgops-protocol-create,
.vgops-protocol-list,
.vgops-protocol-form,
.vgops-protocol-toolbar,
.vgops-protocol-cards,
.vgops-protocol-card,
.vgops-protocol-card__copy {
min-width: 0;
}
.vgops-protocol-intro,
.vgops-protocol-policy {
margin: 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.5;
}
.vgops-protocol-field-error {
color: var(--crit-text);
font-size: var(--fs-xs);
line-height: 1.45;
}
.vgops-protocol-form {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(96px, 0.38fr);
gap: 12px;
}
.vgops-protocol-form label,
.vgops-protocol-toolbar label {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.vgops-protocol-form label > span,
.vgops-protocol-toolbar label > span {
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 650;
}
.vgops-protocol-form input,
.vgops-protocol-form select,
.vgops-protocol-form textarea,
.vgops-protocol-toolbar input,
.vgops-protocol-toolbar select {
width: 100%;
min-width: 0;
min-height: 44px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
color: var(--text-strong);
padding: 0 11px;
font: var(--fs-sm) / 1.4 var(--font-sans);
}
.vgops-protocol-form textarea {
min-height: 168px;
resize: vertical;
padding-block: 10px;
}
.vgops-protocol-form input:focus,
.vgops-protocol-form select:focus,
.vgops-protocol-form textarea:focus,
.vgops-protocol-toolbar input:focus,
.vgops-protocol-toolbar select:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.vgops-protocol-form__source,
.vgops-protocol-form__content,
.vgops-protocol-policy,
.vgops-protocol-submit {
grid-column: 1 / -1;
}
.vgops-protocol-check {
min-height: 44px;
flex-direction: row !important;
align-items: center;
align-self: end;
gap: 9px !important;
border: 1px solid var(--hair);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
padding: 8px 10px;
}
.vgops-protocol-check input {
width: 20px;
min-height: 20px;
height: 20px;
flex: none;
padding: 0;
}
.vgops-protocol-submit,
.vgops-protocol-list .vg-btn {
min-height: 44px;
}
.vgops-protocol-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(112px, 0.38fr) max-content;
align-items: end;
gap: 8px;
}
.vgops-protocol-error {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
gap: 8px;
}
.vgops-protocol-cards {
display: grid;
gap: 8px;
}
.vgops-protocol-card {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--hair);
border-radius: var(--radius);
background: var(--bg-surface-2);
}
.vgops-protocol-card__copy {
display: grid;
gap: 8px;
}
.vgops-protocol-card__title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.vgops-protocol-card h3,
.vgops-protocol-card p {
margin: 0;
overflow-wrap: anywhere;
}
.vgops-protocol-card h3 {
color: var(--text-strong);
font-size: var(--fs-body);
line-height: 1.35;
}
.vgops-protocol-card p {
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.45;
}
.vgops-protocol-card__actions {
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 88px;
}
.vgops-protocol-card__terminal {
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 650;
}
.vgops-ticket-list {
display: flex;
flex-direction: column;
@ -1546,6 +1744,10 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.vgops-protocols {
grid-template-columns: 1fr;
}
.vgops-ticket {
grid-template-columns: minmax(0, 1fr) 84px;
}
@ -1626,6 +1828,30 @@
grid-template-columns: 1fr;
}
.vgops-protocol-form,
.vgops-protocol-toolbar,
.vgops-protocol-card,
.vgops-protocol-error {
grid-template-columns: 1fr;
}
.vgops-protocol-form__source,
.vgops-protocol-form__content,
.vgops-protocol-policy,
.vgops-protocol-submit {
grid-column: auto;
}
.vgops-protocol-card {
align-items: stretch;
}
.vgops-protocol-card__actions,
.vgops-protocol-card__actions .vg-btn,
.vgops-protocol-error .vg-btn {
width: 100%;
}
.vgops-users-toolbar {
align-items: stretch;
flex-direction: column;

View file

@ -288,6 +288,10 @@ export function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): {
warnings,
label,
),
learner_feedback_enabled:
typeof sourceRecord.learner_feedback_enabled === "boolean"
? sourceRecord.learner_feedback_enabled
: true,
super_admin: booleanField(
sourceRecord,
"super_admin",

View file

@ -1,8 +1,8 @@
import { Icon } from "../../components/ui/Icon";
interface LoginBrandProps {
allowedDomains: string[];
oauthChecking: boolean;
oauthReady: boolean;
}
function VignetteMark() {
@ -22,7 +22,7 @@ function VignetteMark() {
);
}
export function LoginBrand({ allowedDomains, oauthChecking }: LoginBrandProps) {
export function LoginBrand({ oauthChecking, oauthReady }: LoginBrandProps) {
return (
<section className="lg-brand" aria-label="Vignette">
<div className="lg-wordmark">
@ -48,13 +48,9 @@ export function LoginBrand({ allowedDomains, oauthChecking }: LoginBrandProps) {
<div className="lg-policy">
<span>
<Icon name="shield" size={17} />
Google
</span>
{allowedDomains.length ? (
allowedDomains.map((domain) => <b key={domain}>{domain}</b>)
) : (
<b>{oauthChecking ? "확인 중" : "설정 필요"}</b>
)}
<b>{oauthChecking ? "확인 중" : oauthReady ? "모든 Google 계정" : "설정 필요"}</b>
</div>
</section>
);

View file

@ -11,8 +11,8 @@ export interface LoginRoleOption {
interface LoginOAuthView {
ready: boolean;
primaryDomainLabel: string;
secondaryDomainLabel: string;
primaryAccountLabel: string;
secondaryAccountLabel: string;
statusIcon: Extract<IconName, "alert" | "info">;
statusMessage: string;
onStart: () => void;
@ -136,23 +136,23 @@ export function LoginPanel({ oauth, devAccess, error }: LoginPanelProps) {
</span>
<h2></h2>
<p className="lg-lead">
Google .
Google .
</p>
<div className="lg-actions">
<LoginProviderButton
variant="primary"
icon="school"
label="학교 Google 계정으로 계속"
subLabel={oauth.primaryDomainLabel}
label="Google 계정으로 계속"
subLabel={oauth.primaryAccountLabel}
disabled={!oauth.ready}
onClick={oauth.onStart}
/>
<LoginProviderButton
variant="secondary"
icon="google"
label="Google 계정으로 계속"
subLabel={oauth.secondaryDomainLabel}
label="다른 Google 계정 선택"
subLabel={oauth.secondaryAccountLabel}
disabled={!oauth.ready}
onClick={oauth.onStart}
/>

View file

@ -19,6 +19,7 @@ import "./alliance-pulse.css";
interface PulseTurn {
id: string;
turn_id?: string | null;
ts: string;
speaker: string;
who: string;
@ -29,6 +30,7 @@ interface AlliancePulseCardProps {
sessionId: string;
turns: PulseTurn[];
isSupervisorView: boolean;
feedbackEnabled: boolean;
onJumpToTurn: (turnId: string) => void;
}
@ -206,7 +208,16 @@ function EvidencePicker({
onChange: (turnIds: string[]) => void;
scope: "learner" | "supervisor";
}) {
const eligible = useMemo(() => turns.slice(-12), [turns]);
const eligible = useMemo(
() =>
turns
.flatMap((turn) => {
const persistedId = turn.turn_id?.trim();
return persistedId ? [{ turn, persistedId }] : [];
})
.slice(-12),
[turns],
);
const summaryLabel =
scope === "learner" ? "내 판단의 근거 장면 선택" : "교수자 판정 근거 장면 선택";
@ -226,12 +237,12 @@ function EvidencePicker({
</summary>
{eligible.length > 0 ? (
<div className="ap-evidence-picker__list">
{eligible.map((turn) => (
{eligible.map(({ turn, persistedId }) => (
<label key={turn.id}>
<input
type="checkbox"
checked={selected.includes(turn.id)}
onChange={() => toggle(turn.id)}
checked={selected.includes(persistedId)}
onChange={() => toggle(persistedId)}
/>
<span className="ap-evidence-picker__time">{turn.ts}</span>
<span>
@ -242,7 +253,7 @@ function EvidencePicker({
))}
</div>
) : (
<p> .</p>
<p> ID가 .</p>
)}
</details>
);
@ -360,6 +371,25 @@ function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) {
);
}
function SelfOnlyState({ pulse }: { pulse: AlliancePulse }) {
return (
<div className="ap-waiting ap-self-only" aria-label="저장된 치료 동맹 자기평가">
<div className="ap-waiting__copy">
<b> </b>
<p> . AI· .</p>
</div>
<dl className="ap-locked-scores">
{DIMENSIONS.map((dimension) => (
<div key={dimension}>
<dt>{DIMENSION_COPY[dimension].short}</dt>
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
</div>
))}
</dl>
</div>
);
}
function ComparisonView({
pulse,
onJumpToTurn,
@ -525,6 +555,7 @@ export function AlliancePulseCard({
sessionId,
turns,
isSupervisorView,
feedbackEnabled,
onJumpToTurn,
}: AlliancePulseCardProps) {
const [loadState, setLoadState] = useState<LoadState>("loading");
@ -559,12 +590,16 @@ export function AlliancePulseCard({
}, [load]);
useEffect(() => {
if (!pulse || (pulse.status !== "awaiting_agents" && pulse.status !== "processing")) {
if (
!feedbackEnabled ||
!pulse ||
(pulse.status !== "awaiting_agents" && pulse.status !== "processing")
) {
return;
}
const timer = window.setTimeout(() => void load(), POLL_INTERVAL_MS);
return () => window.clearTimeout(timer);
}, [load, pulse]);
}, [feedbackEnabled, load, pulse]);
const submitSelfAssessment = async () => {
if (!isCompleteDraft(draft) || submitting) return;
@ -606,8 +641,8 @@ export function AlliancePulseCard({
const revealReady = Boolean(
pulse?.learner_locked_at && pulse?.revealed_at,
);
const status = pulse ? pulseStatusCopy(pulse) : null;
const learnerEvidenceRequired = turns.length > 0;
const status = feedbackEnabled && pulse ? pulseStatusCopy(pulse) : null;
const learnerEvidenceRequired = turns.some((turn) => Boolean(turn.turn_id?.trim()));
const canSubmit =
isCompleteDraft(draft) &&
(!learnerEvidenceRequired || evidenceTurnIds.length > 0) &&
@ -642,8 +677,16 @@ export function AlliancePulseCard({
content = (
<div className="ap-self-form">
<div className="ap-self-form__intro">
<b>AI </b>
<p> . .</p>
<b>
{feedbackEnabled
? "AI 관점을 보기 전에 먼저 스스로 판단합니다"
: "목표·과업·유대를 내가 직접 기록합니다"}
</b>
<p>
{feedbackEnabled
? "세 축을 따로 평가하세요. 제출하면 값은 잠기며 이후 관점에 맞춰 수정할 수 없습니다."
: "세 축을 따로 평가해 저장합니다. AI·교수자 파생 비교는 표시하지 않습니다."}
</p>
</div>
{DIMENSIONS.map((dimension) => (
<AxisAssessmentControl
@ -667,14 +710,22 @@ export function AlliancePulseCard({
<div className="ap-self-form__commit">
<span>
<Icon name="shield" size={15} strokeWidth={1.8} />
AI .
{feedbackEnabled
? "제출 뒤에는 AI 관점이 준비될 때까지 내 판단만 보입니다."
: "제출 뒤에도 내가 직접 잠근 기록만 표시합니다."}
</span>
<Button size="sm" disabled={!canSubmit} onClick={() => void submitSelfAssessment()}>
{submitting ? "판단 잠그는 중" : "내 판단 잠그고 관점 비교"}
{submitting
? "판단 잠그는 중"
: feedbackEnabled
? "내 판단 잠그고 관점 비교"
: "내 판단 기록"}
</Button>
</div>
</div>
);
} else if (!feedbackEnabled) {
content = <SelfOnlyState pulse={pulse} />;
} else if (!revealReady) {
content = <LockedWaitingState pulse={pulse} />;
} else {
@ -697,8 +748,14 @@ export function AlliancePulseCard({
<Card className="sr-card ap-card" aria-busy={loadState === "loading"}>
<div className="ap-card__head">
<div>
<Kicker dot={false}> </Kicker>
<h2>, , </h2>
<Kicker dot={false}>
{feedbackEnabled ? "치료 동맹 펄스" : "치료 동맹 자기평가"}
</Kicker>
<h2>
{feedbackEnabled
? "목표, 과업, 유대를 따로 봅니다"
: "목표, 과업, 유대를 직접 기록합니다"}
</h2>
</div>
{status ? <Badge tone={status.tone}>{status.label}</Badge> : null}
</div>

View file

@ -38,6 +38,7 @@ interface CalibrationTransferCardProps {
sessionId: string;
isSupervisorView: boolean;
practiceLaunchIntent: TransferPracticeLaunchIntent | null;
feedbackEnabled: boolean;
}
type LoadState = "loading" | "ready" | "error";
@ -191,7 +192,7 @@ function actualBlockerCopy(blocker: string): string {
function actualExecutionCopy(
response: ActualTransferExecutionResponse | null,
): string | null {
if (!response) return null;
if (!response?.execution) return null;
if (response.execution.status === "passed") return "목표 행동이 관찰됨";
if (response.execution.status === "failed") return "목표 행동을 다시 연습해야 함";
return "이번 회기만으로 판정하기 어려움";
@ -202,6 +203,7 @@ function ActualTransferExecutionObservation({
intent,
sourceIsValid,
data,
showFeedback,
ledgerRefreshState,
onRecorded,
}: {
@ -209,6 +211,7 @@ function ActualTransferExecutionObservation({
intent: TransferPracticeLaunchIntent;
sourceIsValid: boolean;
data: CalibrationTransferReadModelResponse;
showFeedback: boolean;
ledgerRefreshState: "idle" | "refreshing" | "complete";
onRecorded: () => void;
}) {
@ -249,9 +252,11 @@ function ActualTransferExecutionObservation({
setResult(response);
setState("success");
setMessage(
response.idempotent_replay
? "이미 기록된 같은 회기 근거와 일치해. 중복 기록은 만들지 않았어."
: "이번 완료 회기를 실제 전이 근거로 기록했어.",
showFeedback
? response.idempotent_replay
? "이미 기록된 같은 회기 근거와 일치해. 중복 기록은 만들지 않았어."
: "이번 완료 회기를 실제 전이 근거로 기록했어."
: "이번 완료 회기 연결 입력을 기록했습니다. AI 판정은 표시하지 않습니다.",
);
onRecorded();
} catch (cause) {
@ -275,11 +280,13 @@ function ActualTransferExecutionObservation({
}
}
const afterAssessment = result?.assessment ?? currentAssessment;
const afterAssessment = showFeedback
? result?.assessment ?? currentAssessment
: null;
const afterStatus = afterAssessment
? ACTUAL_STATUS_COPY[afterAssessment.actual_transfer_status]
: null;
const executionCopy = actualExecutionCopy(result);
const executionCopy = showFeedback ? actualExecutionCopy(result) : null;
const actionLabel =
state === "saving"
? "전이 근거 확인 중"
@ -835,6 +842,7 @@ export function CalibrationTransferCard({
sessionId,
isSupervisorView,
practiceLaunchIntent,
feedbackEnabled,
}: CalibrationTransferCardProps) {
const [data, setData] = useState<CalibrationTransferReadModelResponse | null>(null);
const [loadState, setLoadState] = useState<LoadState>("loading");
@ -859,7 +867,7 @@ export function CalibrationTransferCard({
setLoadError("");
const request = isSupervisorView
? calibrationTransferApi.getForTeacherSession(sessionId, controller.signal)
: calibrationTransferApi.getForLearner(controller.signal);
: calibrationTransferApi.getForLearner(sessionId, controller.signal);
void request
.then((payload) => {
setData(payload);
@ -1057,8 +1065,9 @@ export function CalibrationTransferCard({
<ActualTransferExecutionObservation
sessionId={sessionId}
intent={practiceLaunchIntent}
sourceIsValid={returnedTransferSourceIsValid}
sourceIsValid={feedbackEnabled ? returnedTransferSourceIsValid : true}
data={data}
showFeedback={feedbackEnabled}
ledgerRefreshState={ledgerRefreshState}
onRecorded={() => {
setLedgerRefreshState("refreshing");
@ -1068,10 +1077,16 @@ export function CalibrationTransferCard({
) : null}
<header className="ct-card__head">
<div>
<Kicker>Calibration mirror &amp; transfer</Kicker>
<h2 id="ct-card-title"> , , </h2>
<Kicker>{feedbackEnabled ? "Calibration mirror & transfer" : "학습자 자기예측 원장"}</Kicker>
<h2 id="ct-card-title">
{feedbackEnabled
? "먼저 예측하고, 잠근 뒤, 근거로 교정하기"
: "먼저 예측하고 잠그는 내 기록만 보존합니다"}
</h2>
<p>
. .
{feedbackEnabled
? "외부평가를 보기 전에 남긴 자기예측만 비교해. 결과를 맞혔는지를 세는 화면이 아니라 내 판단 습관을 더 정확하게 만드는 원장이야."
: "내 예측 revision과 명시적 잠금은 유지하며, 내부 평가와 교수자·AI 결과는 표시하지 않습니다."}
</p>
</div>
<div className="ct-card__badges" aria-label="평가 범위">
@ -1184,7 +1199,7 @@ export function CalibrationTransferCard({
</p>
) : null}
{latestAssessments.length > 0 ? (
{feedbackEnabled && latestAssessments.length > 0 ? (
<section className="ct-calibration" aria-labelledby="ct-calibration-title">
<div className="ct-section-head">
<div>
@ -1198,26 +1213,26 @@ export function CalibrationTransferCard({
))}
</div>
</section>
) : (
) : feedbackEnabled ? (
<p className="ct-awaiting" role="status">
· · .
</p>
)}
) : null}
{transferSuite ? (
{feedbackEnabled && transferSuite ? (
<TransferCoverage
suite={transferSuite}
sourceSessionId={sessionId}
calibrationAssessments={latestAssessments}
isSupervisorView={isSupervisorView}
/>
) : (
) : feedbackEnabled ? (
<section className="ct-transfer ct-transfer--empty">
<span className="ct-eyebrow">Unseen transfer</span>
<h3> </h3>
<p>· ·· .</p>
</section>
)}
) : null}
{isSupervisorView ? (
<TeacherCorrectionPanel

View file

@ -20,6 +20,7 @@ import { randomUuid } from "../../lib/uuid";
interface MultimodalAllianceCardProps {
sessionId: string;
isSupervisorView: boolean;
feedbackEnabled: boolean;
}
type LoadState = "loading" | "ready" | "empty" | "error";
@ -498,7 +499,11 @@ function PrivacyLedger({
);
}
export function MultimodalAllianceCard({ sessionId, isSupervisorView }: MultimodalAllianceCardProps) {
export function MultimodalAllianceCard({
sessionId,
isSupervisorView,
feedbackEnabled,
}: MultimodalAllianceCardProps) {
const navigate = useNavigate();
const [data, setData] = useState<MultimodalAllianceReadModel | null>(null);
const [loadState, setLoadState] = useState<LoadState>("loading");
@ -530,7 +535,8 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
const controller = new AbortController();
setLoadState((current) => current === "ready" ? "ready" : "loading");
setLoadError("");
void multimodalAllianceApi.getMetadata(sessionId, controller.signal)
void multimodalAllianceApi
.getMetadata(sessionId, controller.signal, feedbackEnabled)
.then((payload) => {
setData(payload);
setLoadState("ready");
@ -546,7 +552,7 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
setLoadError(compactError(error, "멀티모달 동맹 원장을 불러오지 못했습니다."));
});
return () => controller.abort();
}, [reloadSeq, sessionId]);
}, [feedbackEnabled, reloadSeq, sessionId]);
const latestConsent = useMemo(() => data
? [...data.consent_snapshots].sort((a, b) => a.sequence_no - b.sequence_no).at(-1) ?? null
@ -624,13 +630,13 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
}
function saveConsent() {
const signature = `${retentionDays}:${retainAudio}`;
const signature = `${retentionDays}:${retainAudio}:${feedbackEnabled}`;
if (!pendingConsentRef.current || pendingConsentSignatureRef.current !== signature) {
pendingConsentRef.current = {
submission_id: uuid(),
consent_status: "granted",
retain_audio: retainAudio,
retain_derived_features: true,
retain_derived_features: feedbackEnabled,
transcript_retained: true,
retention_days: retentionDays,
policy_version: POLICY_VERSION,
@ -735,9 +741,19 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
<Card className="mma-card">
<header className="mma-header">
<div>
<Kicker dot={false}> </Kicker>
<h2> </h2>
<p> , .</p>
<Kicker dot={false}>
{feedbackEnabled ? "멀티모달 동맹 근거" : "멀티모달 개인정보 원장"}
</Kicker>
<h2>
{feedbackEnabled
? "말의 내용과 오디오 시간을 같은 시계에서 봅니다"
: "동의·보존·삭제 범위를 직접 관리합니다"}
</h2>
<p>
{feedbackEnabled
? "텍스트와 음성은 먼저 독립 측정하고, 검증된 추가 이득이 있을 때만 보정 융합합니다."
: "AI 타임라인·측정·융합 결과는 불러오지 않으며 개인정보 통제 기록만 유지합니다."}
</p>
</div>
<div className="mma-header__badges">
<Badge tone="info"> </Badge>
@ -745,6 +761,7 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
</div>
</header>
{feedbackEnabled ? (
<section className="mma-clock" aria-labelledby="mma-clock-title">
<div className="mma-section-head">
<div>
@ -808,7 +825,9 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
</article>
) : null}
</section>
) : null}
{feedbackEnabled ? (
<section className="mma-axis-board" aria-labelledby="mma-axis-title">
<div className="mma-section-head">
<div>
@ -829,10 +848,11 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
);
})}
</section>
) : null}
<PrivacyLedger sessionId={sessionId} data={data} isSupervisorView={isSupervisorView} rawAudio={rawAudio} rawAudioState={rawAudioState} rawAudioError={rawAudioError} selectedScene={selectedEvent} retentionDays={retentionDays} setRetentionDays={setRetentionDays} retainAudio={retainAudio} setRetainAudio={setRetainAudio} consentAcknowledged={consentAcknowledged} setConsentAcknowledged={setConsentAcknowledged} withdrawalAcknowledged={withdrawalAcknowledged} setWithdrawalAcknowledged={setWithdrawalAcknowledged} deletionScopes={deletionScopes} setDeletionScopes={setDeletionScopes} actionState={actionState} actionMessage={actionMessage} onConsent={saveConsent} onWithdraw={withdrawConsent} onDelete={requestDeletion} />
{!isSupervisorView ? (
{!isSupervisorView && feedbackEnabled ? (
<footer className="mma-practice-cta">
<div><Icon name="mic" size={19} /><div><strong> </strong><p> , .</p></div></div>
<Button

View file

@ -40,12 +40,14 @@ interface TrajectoryTurn {
interface OutcomeTrajectoryCardProps {
sessionId: string;
sessionNo: number;
turns: TrajectoryTurn[];
isSupervisorView: boolean;
feedbackEnabled: boolean;
onJumpToTurn: (turnId: string) => void;
}
type LoadState = "loading" | "ready" | "empty" | "error";
type LoadState = "loading" | "ready" | "empty" | "error" | "input_only";
type CheckinState = "idle" | "submitting" | "success" | "error";
type NullableAxisValues = Record<OutcomeAxis, number | null>;
@ -794,8 +796,10 @@ function timelineKeyDown(
export function OutcomeTrajectoryCard({
sessionId,
sessionNo,
turns,
isSupervisorView,
feedbackEnabled,
onJumpToTurn,
}: OutcomeTrajectoryCardProps) {
const titleId = useId();
@ -810,6 +814,12 @@ export function OutcomeTrajectoryCard({
useEffect(() => {
const controller = new AbortController();
let alive = true;
if (!isSupervisorView && !feedbackEnabled) {
setData(null);
setError(null);
setLoadState("input_only");
return () => controller.abort();
}
setLoadState("loading");
setError(null);
@ -841,7 +851,7 @@ export function OutcomeTrajectoryCard({
alive = false;
controller.abort();
};
}, [reloadSeq, sessionId]);
}, [feedbackEnabled, isSupervisorView, reloadSeq, sessionId]);
const latestSessionNo = data ? latestSessionNumber(data) : 1;
const activeSessionNo = data
@ -862,6 +872,30 @@ export function OutcomeTrajectoryCard({
[activeSessionNo, data],
);
if (loadState === "input_only") {
return (
<Card className="ot-card" aria-labelledby={titleId}>
<header className="ot-card__head">
<div>
<Kicker dot={false}> </Kicker>
<h2 id={titleId}> </h2>
<p>
AI ,
.
</p>
</div>
<Badge tone="neutral"> </Badge>
</header>
<LearnerOutcomeCheckin
sessionId={sessionId}
sessionNo={sessionNo}
observations={[]}
turns={turns}
onSubmitted={() => undefined}
/>
</Card>
);
}
if (loadState === "loading") return <LoadingState />;
if (loadState === "empty") return <EmptyState isSupervisorView={isSupervisorView} />;
if (loadState === "error" || !data) {

View file

@ -76,10 +76,11 @@ export class CalibrationLearnerContextError extends Error {
}
export const calibrationTransferApi = {
getForLearner: (signal?: AbortSignal) =>
supplementalApi.get<CalibrationTransferReadModelResponse>(learnerPath(), {
signal,
}),
getForLearner: (sessionId: string, signal?: AbortSignal) =>
supplementalApi.get<CalibrationTransferReadModelResponse>(
`${learnerPath()}?session_id=${encodeURIComponent(sessionId)}`,
{ signal },
),
getForTeacherSession: async (sessionId: string, signal?: AbortSignal) => {
const dashboard = await supplementalApi.get<TeacherDashboardLookup>(

View file

@ -526,6 +526,7 @@
.mma-privacy-actions > .vg-btn,
.mma-delete-fieldset .vg-btn {
justify-self: start;
min-height: 44px;
}
.mma-raw-access strong,

View file

@ -365,9 +365,16 @@ export function rawAudioPlaybackUrl(
}
export const multimodalAllianceApi = {
getMetadata: (sessionId: string, signal?: AbortSignal) =>
getMetadata: (
sessionId: string,
signal?: AbortSignal,
includeDerived = true,
) =>
supplementalApi
.get<WireMetadata>(basePath(sessionId), { signal })
.get<WireMetadata>(
`${basePath(sessionId)}?include_derived=${includeDerived ? "true" : "false"}`,
{ signal },
)
.then(normalizeMetadata),
getRawAudio: (sessionId: string, signal?: AbortSignal) =>
supplementalApi

View file

@ -1166,6 +1166,112 @@
font-style: italic;
}
.sr-first-checklist__heading,
.sr-first-checklist__item-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
}
.sr-first-checklist__version {
flex: none;
color: var(--text-muted);
font-family: var(--font-num);
font-size: 10px;
letter-spacing: 0.04em;
}
.sr-first-checklist__note {
margin: var(--sp-3) 0 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.55;
}
.sr-first-checklist__items {
display: grid;
gap: var(--sp-4);
margin-top: var(--sp-4);
}
.sr-first-checklist__item {
display: grid;
gap: 8px;
padding-top: var(--sp-4);
border-top: 1px solid var(--border-subtle);
}
.sr-first-checklist__item:first-child {
padding-top: 0;
border-top: 0;
}
.sr-first-checklist__item-head b {
color: var(--text-strong);
font-size: var(--fs-sm);
}
.sr-first-checklist__item > p {
margin: 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.55;
}
.sr-first-checklist__status {
flex: none;
padding: 3px 8px;
border: 1px solid var(--border-subtle);
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.sr-first-checklist__status--met {
border-color: color-mix(in srgb, var(--pos-solid) 32%, var(--border-subtle));
color: var(--pos-text);
background: color-mix(in srgb, var(--pos-solid) 9%, transparent);
}
.sr-first-checklist__status--not_observed {
color: var(--text-muted);
background: var(--bg-surface-2);
}
.sr-first-checklist__evidence-list {
display: grid;
gap: 7px;
}
.sr-first-checklist__evidence {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
width: 100%;
min-height: 44px;
padding: 9px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-strong);
background: var(--bg-surface-2);
text-align: left;
cursor: pointer;
}
.sr-first-checklist__evidence:hover {
border-color: color-mix(in srgb, var(--accent) 42%, var(--border-subtle));
}
.sr-first-checklist__evidence:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.sr-first-checklist__evidence span {
color: var(--accent-text);
font-family: var(--font-num);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.sr-first-checklist__evidence q {
min-width: 0;
overflow: hidden;
color: var(--text-muted);
font-size: var(--fs-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.sr-first-checklist__empty {
color: var(--text-muted);
}
.sr-rubric {
display: grid;
gap: var(--sp-4);
@ -1529,6 +1635,7 @@
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 94%, transparent), color-mix(in srgb, var(--bg-surface) 98%, transparent));
}
[data-theme="dark"] .sr-card--prepost,
[data-theme="dark"] .sr-card--first-session-checklist,
[data-theme="dark"] .sr-card--rubric {
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 92%, transparent), color-mix(in srgb, var(--bg-surface) 96%, transparent));
}
@ -1538,6 +1645,7 @@
[data-theme="dark"] .sr-chip-toggle,
[data-theme="dark"] .sr-eval-scope,
[data-theme="dark"] .sr-ws-item,
[data-theme="dark"] .sr-first-checklist__evidence,
[data-theme="dark"] .sr-prepost__field input,
[data-theme="dark"] .sr-feedback,
[data-theme="dark"] .sr-nextline {

View file

@ -2843,7 +2843,7 @@
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
min-height: 40px;
min-height: 44px;
padding: 9px 13px;
cursor: pointer;
display: inline-flex;
@ -4835,6 +4835,26 @@
.sx-page--active .sx-review-button {
min-width: 116px;
}
/* 390px 이하에서는 코칭/전송 문구가 입력 필드를 122px까지 밀어 placeholder가
세로로 잘렸다. 접근 가능한 이름은 유지하고 액션만 44px 아이콘형으로 압축한다. */
.sx-page--active .sx-coach-trigger-btn,
.sx-page--active .sx-compose .vg-btn {
width: 44px;
min-width: 44px;
padding-inline: 0;
justify-content: center;
}
.sx-page--active .sx-coach-trigger-btn > span {
display: none;
}
.sx-page--active .sx-compose .vg-btn {
font-size: 0;
}
.sx-page--active .sx-compose textarea {
height: 44px;
padding-inline: 7px;
}
}
/* 프리스타트 아주 좁은 : 1180 이하 규칙이 아바타 열을 140px 고정해 두는데,
@ -5557,11 +5577,14 @@
}
.sx-page--active .sx-compose {
margin-top: 5px;
padding-top: 5px;
margin-top: 3px;
padding-top: 3px;
}
.sx-page--active .sx-compose textarea {
height: 44px;
}
.sx-page--active .sx-compose textarea,
.sx-page--active .sx-compose .vg-btn {
height: 44px;
}