계약 DTO SSOT 3차
This commit is contained in:
parent
1007eaf7d9
commit
480fbd543a
10 changed files with 90 additions and 162 deletions
|
|
@ -21,6 +21,10 @@ async function signInAsLearnerEmail(
|
|||
},
|
||||
});
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
const consent = await page.request.post("/api/auth/consent", {
|
||||
data: { accepted: true },
|
||||
});
|
||||
expect(consent.ok(), await consent.text()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function createPracticeSession(page: import("@playwright/test").Page, personaCode?: string) {
|
||||
|
|
|
|||
|
|
@ -197,10 +197,7 @@ export type SessionEndResponse = ApiSchema<"SessionEndResponse">;
|
|||
export type CrisisResource = ApiSchema<"CrisisResourceResponse">;
|
||||
export type LearnerSessionSummary = ApiSchema<"LearnerSessionSummary">;
|
||||
|
||||
export interface LearnerSessionsResponse {
|
||||
source: string;
|
||||
sessions: LearnerSessionSummary[];
|
||||
}
|
||||
export type LearnerSessionsResponse = ApiSchema<"LearnerSessionsResponse">;
|
||||
|
||||
export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">;
|
||||
|
||||
|
|
@ -224,16 +221,7 @@ export type ReviewTechnique = ApiSchema<"ReviewTechnique">;
|
|||
export type ReviewNonverbalEvent = ApiSchema<"ReviewNonverbalEvent">;
|
||||
export type ReviewNote = ApiSchema<"ReviewNote">;
|
||||
|
||||
export interface ReviewTurn {
|
||||
id: string;
|
||||
ts: string;
|
||||
speaker: "learner" | "client";
|
||||
who: string;
|
||||
text: string;
|
||||
techniques: ReviewTechnique[];
|
||||
nonverbal: ReviewNonverbalEvent[];
|
||||
note?: ReviewNote | null;
|
||||
}
|
||||
export type ReviewTurn = ApiSchema<"ReviewTurn">;
|
||||
|
||||
export type ReviewPhaseSegment = ApiSchema<"ReviewPhaseSegment">;
|
||||
export type ReviewValencePoint = ApiSchema<"ReviewValencePoint">;
|
||||
|
|
@ -241,56 +229,10 @@ export type ReviewRubricRow = ApiSchema<"ReviewRubricRow">;
|
|||
export type ReviewPoint = ApiSchema<"ReviewPoint">;
|
||||
export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">;
|
||||
|
||||
export interface ReviewWorksheetItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
evidence: ReviewWorksheetEvidence[];
|
||||
confidence: "none" | "low" | "medium";
|
||||
emptyReason?: string | null;
|
||||
}
|
||||
|
||||
export interface ReviewWorksheetSection {
|
||||
key: string;
|
||||
title: string;
|
||||
items: ReviewWorksheetItem[];
|
||||
}
|
||||
|
||||
export interface ReviewCaseWorksheet {
|
||||
status: "empty" | "draft_from_transcript";
|
||||
generatedBy: string;
|
||||
sections: ReviewWorksheetSection[];
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
export interface SessionReviewResponse {
|
||||
session_id: string;
|
||||
client: ReviewClient;
|
||||
date: string;
|
||||
durationLabel: string;
|
||||
durationSeconds: number;
|
||||
reachedPhase: string;
|
||||
sessionSignal: string;
|
||||
supervisorState: string;
|
||||
supervisorName: string;
|
||||
summary: string;
|
||||
phases: ReviewPhaseSegment[];
|
||||
phaseAxis: string[];
|
||||
valenceAxis: string[];
|
||||
clientValence: ReviewValencePoint[];
|
||||
counselorBaseline: ReviewValencePoint[];
|
||||
turns: ReviewTurn[];
|
||||
rubric: ReviewRubricRow[];
|
||||
goodMoments: ReviewPoint[];
|
||||
growthPoints: ReviewPoint[];
|
||||
caseWorksheet?: ReviewCaseWorksheet | null;
|
||||
nextLine?: string | null;
|
||||
clientFeedback?: string | null;
|
||||
audioUrl?: string | null;
|
||||
pdfExportUrl?: string | null;
|
||||
degraded: boolean;
|
||||
reviewReady: boolean;
|
||||
}
|
||||
export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">;
|
||||
export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection">;
|
||||
export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">;
|
||||
export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">;
|
||||
|
||||
/* =====================================================================
|
||||
SSE 헬퍼 — POST /sessions/{id}/stream
|
||||
|
|
@ -528,53 +470,13 @@ export const adminUsersApi = {
|
|||
}),
|
||||
};
|
||||
|
||||
export interface TeacherSessionSummary {
|
||||
session_id: string;
|
||||
learner_id: string;
|
||||
learner_label: string;
|
||||
persona_code: string;
|
||||
persona_name: string;
|
||||
session_no: number;
|
||||
status: "active" | "ended" | string;
|
||||
stage: string;
|
||||
turn_count: number;
|
||||
learner_turn_count: number;
|
||||
client_turn_count: number;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
}
|
||||
export type TeacherSessionSummary = ApiSchema<"TeacherSessionSummary">;
|
||||
|
||||
export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">;
|
||||
export type TeacherGrowthPoint = ApiSchema<"TeacherGrowthPoint">;
|
||||
|
||||
export interface TeacherLearnerGrowth {
|
||||
learner_id: string;
|
||||
learner_label: string;
|
||||
sessions: number;
|
||||
ended_sessions: number;
|
||||
latest_at: string;
|
||||
first_score: number | null;
|
||||
latest_score: number | null;
|
||||
score_delta: number | null;
|
||||
avg_score: number | null;
|
||||
avg_rapport: number | null;
|
||||
trend: "up" | "down" | "flat" | "insufficient" | string;
|
||||
top_techniques: string[];
|
||||
points: TeacherGrowthPoint[];
|
||||
}
|
||||
|
||||
export interface TeacherDashboardResponse {
|
||||
source: string;
|
||||
cohort_label: string;
|
||||
total_learners: number;
|
||||
active_sessions: number;
|
||||
ended_sessions: number;
|
||||
safety_alerts: TeacherSafetyAlert[];
|
||||
learner_growth: TeacherLearnerGrowth[];
|
||||
pending_reviews: TeacherSessionSummary[];
|
||||
recent_sessions: TeacherSessionSummary[];
|
||||
message: string;
|
||||
}
|
||||
export type TeacherLearnerGrowth = ApiSchema<"TeacherLearnerGrowth">;
|
||||
export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">;
|
||||
|
||||
export const teacherApi = {
|
||||
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export default function LearnerHome() {
|
|||
}
|
||||
|
||||
if (sessionResult.status === "fulfilled") {
|
||||
setSessions(sessionResult.value.sessions);
|
||||
setSessions(sessionResult.value.sessions ?? []);
|
||||
setSessionLoadState("ready");
|
||||
} else {
|
||||
console.warn("[learner-home] failed to load sessions", sessionResult.reason);
|
||||
|
|
@ -438,7 +438,7 @@ const LH_CSS = `
|
|||
gap:clamp(12px,1.6vw,20px);
|
||||
padding:clamp(14px,2vw,28px);
|
||||
background:var(--bg-app);
|
||||
overflow:hidden;
|
||||
overflow:clip;
|
||||
}
|
||||
.lh-root::before{
|
||||
content:"";
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ export default function Professor() {
|
|||
() => [
|
||||
{
|
||||
label: "검토 대기",
|
||||
value: dashboard?.pending_reviews.length ?? 0,
|
||||
value: dashboard?.pending_reviews?.length ?? 0,
|
||||
hint: "종료 회기",
|
||||
icon: "review" as const,
|
||||
},
|
||||
|
|
@ -303,13 +303,13 @@ export default function Professor() {
|
|||
},
|
||||
{
|
||||
label: "최근 세션",
|
||||
value: dashboard?.recent_sessions.length ?? 0,
|
||||
value: dashboard?.recent_sessions?.length ?? 0,
|
||||
hint: "종료 · 진행",
|
||||
icon: "play" as const,
|
||||
},
|
||||
{
|
||||
label: "위기 알림",
|
||||
value: dashboard?.safety_alerts.length ?? 0,
|
||||
value: dashboard?.safety_alerts?.length ?? 0,
|
||||
hint: "109 확인",
|
||||
icon: "alert" as const,
|
||||
},
|
||||
|
|
@ -328,12 +328,14 @@ export default function Professor() {
|
|||
],
|
||||
[dashboard, personaReviews.length],
|
||||
);
|
||||
const pendingCount = dashboard?.pending_reviews.length ?? 0;
|
||||
const pendingCount = dashboard?.pending_reviews?.length ?? 0;
|
||||
const hasPending = pendingCount > 0;
|
||||
const totalSessions = (dashboard?.active_sessions ?? 0) + (dashboard?.ended_sessions ?? 0);
|
||||
const personaReviewCount = personaReviews.length;
|
||||
const safetyAlerts = dashboard?.safety_alerts ?? [];
|
||||
const learnerGrowth = dashboard?.learner_growth ?? [];
|
||||
const pendingReviews = dashboard?.pending_reviews ?? [];
|
||||
const recentSessions = dashboard?.recent_sessions ?? [];
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
|
|
@ -630,13 +632,13 @@ export default function Professor() {
|
|||
<Kicker>검토 대기</Kicker>
|
||||
<h2>종료된 실제 회기</h2>
|
||||
</div>
|
||||
<Badge tone="accent">{dashboard?.pending_reviews.length ?? 0}건</Badge>
|
||||
<Badge tone="accent">{pendingReviews.length}건</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="pf-panel">
|
||||
{dashboard && dashboard.pending_reviews.length > 0 ? (
|
||||
{pendingReviews.length > 0 ? (
|
||||
<div className="pf-list">
|
||||
{dashboard.pending_reviews.map((session) => (
|
||||
{pendingReviews.map((session) => (
|
||||
<article className="pf-session" key={session.session_id}>
|
||||
<span className="pf-session__dot" aria-hidden="true" />
|
||||
<div className="pf-session__main">
|
||||
|
|
@ -647,7 +649,7 @@ export default function Professor() {
|
|||
<code>{session.session_id}</code>
|
||||
</div>
|
||||
<div className="pf-session__meta">
|
||||
<span>{formatDateTime(session.ended_at)}</span>
|
||||
<span>{formatDateTime(session.ended_at ?? null)}</span>
|
||||
<Badge tone="neutral">교수자 리뷰 대기</Badge>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -669,11 +671,11 @@ export default function Professor() {
|
|||
<Kicker>최근 세션</Kicker>
|
||||
<h2>최근 회기 기록</h2>
|
||||
</div>
|
||||
<Badge tone="neutral">{dashboard?.recent_sessions.length ?? 0}건</Badge>
|
||||
<Badge tone="neutral">{recentSessions.length}건</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="pf-panel">
|
||||
{dashboard && dashboard.recent_sessions.length > 0 ? (
|
||||
{recentSessions.length > 0 ? (
|
||||
<div className="pf-recent-list">
|
||||
<div className="pf-recent-head" aria-hidden="true">
|
||||
<span>학습자</span>
|
||||
|
|
@ -684,7 +686,7 @@ export default function Professor() {
|
|||
<span>시작</span>
|
||||
<span>종료</span>
|
||||
</div>
|
||||
{dashboard.recent_sessions.map((session) => (
|
||||
{recentSessions.map((session) => (
|
||||
<article
|
||||
className="pf-recent-row"
|
||||
key={session.session_id}
|
||||
|
|
@ -712,7 +714,7 @@ export default function Professor() {
|
|||
{formatDateTime(session.started_at)}
|
||||
</span>
|
||||
<span className="pf-recent__cell tabular" data-label="종료">
|
||||
{formatDateTime(session.ended_at)}
|
||||
{formatDateTime(session.ended_at ?? null)}
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
|
|
@ -732,7 +734,9 @@ export default function Professor() {
|
|||
}
|
||||
|
||||
function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
|
||||
const recentPoints = learner.points.slice(-3).reverse();
|
||||
const points = learner.points ?? [];
|
||||
const topTechniques = learner.top_techniques ?? [];
|
||||
const recentPoints = points.slice(-3).reverse();
|
||||
return (
|
||||
<article className={`pf-growth-card trend-${learner.trend}`}>
|
||||
<div className="pf-growth-card__top">
|
||||
|
|
@ -763,14 +767,14 @@ function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
|
|||
</div>
|
||||
|
||||
<div className="pf-growth-bars" aria-label="회기별 적절성 추이">
|
||||
{learner.points.map((point) => (
|
||||
{points.map((point) => (
|
||||
<GrowthBar point={point} key={point.session_id} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pf-growth-card__tags">
|
||||
{learner.top_techniques.length > 0 ? (
|
||||
learner.top_techniques.map((tag) => <span key={tag}>{tag}</span>)
|
||||
{topTechniques.length > 0 ? (
|
||||
topTechniques.map((tag) => <span key={tag}>{tag}</span>)
|
||||
) : (
|
||||
<span>기법 태그 부족</span>
|
||||
)}
|
||||
|
|
@ -1092,6 +1096,9 @@ const PF_CSS = `
|
|||
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
|
||||
}
|
||||
.pf-growth-list{
|
||||
max-height:min(540px,52vh);
|
||||
overflow:auto;
|
||||
scrollbar-gutter:stable;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
gap:12px;
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ function CaseWorksheetCard({
|
|||
onJump: (id: string) => void;
|
||||
}) {
|
||||
const sections = worksheet?.sections ?? [];
|
||||
const limitations = worksheet?.limitations ?? [];
|
||||
return (
|
||||
<Card className="sr-card sr-card--side sr-card--worksheet">
|
||||
<Kicker>사례개념화 워크시트</Kicker>
|
||||
|
|
@ -147,8 +148,8 @@ function CaseWorksheetCard({
|
|||
<section className="sr-ws-section" key={section.key}>
|
||||
<h3>{section.title}</h3>
|
||||
<div className="sr-ws-items">
|
||||
{section.items.map((item) => {
|
||||
const evidence = item.evidence[0];
|
||||
{(section.items ?? []).map((item) => {
|
||||
const evidence = item.evidence?.[0];
|
||||
return (
|
||||
<div className="sr-ws-item" key={item.key}>
|
||||
<div className="sr-ws-item__head">
|
||||
|
|
@ -180,9 +181,9 @@ function CaseWorksheetCard({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
{(worksheet?.limitations ?? []).length > 0 ? (
|
||||
{limitations.length > 0 ? (
|
||||
<div className="sr-ws-limitations">
|
||||
{worksheet!.limitations.slice(0, 2).map((item) => (
|
||||
{limitations.slice(0, 2).map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -242,8 +243,9 @@ export default function SessionReview() {
|
|||
|
||||
function jumpToTurn(id: string) {
|
||||
if (!data) return;
|
||||
const turns = data.turns ?? [];
|
||||
setActiveTurn(id);
|
||||
if (learnerOnly && data.turns.find((turn) => turn.id === id)?.speaker === "client") {
|
||||
if (learnerOnly && turns.find((turn) => turn.id === id)?.speaker === "client") {
|
||||
setLearnerOnly(false);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
|
|
@ -283,12 +285,21 @@ export default function SessionReview() {
|
|||
);
|
||||
}
|
||||
|
||||
const hasValence = data.clientValence.length > 0 || data.counselorBaseline.length > 0;
|
||||
const turns = data.turns ?? [];
|
||||
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 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 = data.turns.length > 0;
|
||||
const hasTranscript = turns.length > 0;
|
||||
const reviewReadiness = hasTranscript
|
||||
? `${data.turns.length}개 발화 기반`
|
||||
? `${turns.length}개 발화 기반`
|
||||
: "축어록 저장 후 생성";
|
||||
|
||||
return (
|
||||
|
|
@ -384,9 +395,9 @@ export default function SessionReview() {
|
|||
<div style={{ marginTop: "var(--sp-4)" }}>
|
||||
{hasValence ? (
|
||||
<ValenceChart
|
||||
client={data.clientValence}
|
||||
baseline={data.counselorBaseline}
|
||||
xLabels={data.valenceAxis}
|
||||
client={clientValence}
|
||||
baseline={counselorBaseline}
|
||||
xLabels={valenceAxis}
|
||||
/>
|
||||
) : (
|
||||
<EmptyBlock
|
||||
|
|
@ -400,10 +411,10 @@ export default function SessionReview() {
|
|||
<Card className="sr-card sr-card--flow">
|
||||
<Kicker>회기 흐름</Kicker>
|
||||
<div className="sr-phasebar" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{data.phases.length > 0 ? (
|
||||
{phases.length > 0 ? (
|
||||
<>
|
||||
<div className="sr-phasebar__track">
|
||||
{data.phases.map((phase, index) => (
|
||||
{phases.map((phase, index) => (
|
||||
<div
|
||||
key={`${phase.key}-${index}`}
|
||||
className="sr-phase"
|
||||
|
|
@ -421,7 +432,7 @@ export default function SessionReview() {
|
|||
))}
|
||||
</div>
|
||||
<div className="sr-phasebar__axis tabular">
|
||||
{data.phaseAxis.map((label, index) => (
|
||||
{phaseAxis.map((label, index) => (
|
||||
<span key={`${label}-${index}`}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -441,7 +452,7 @@ export default function SessionReview() {
|
|||
className="sr-chip-toggle"
|
||||
aria-pressed={!learnerOnly}
|
||||
onClick={() => setLearnerOnly(false)}
|
||||
disabled={data.turns.length === 0}
|
||||
disabled={turns.length === 0}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
|
|
@ -450,7 +461,7 @@ export default function SessionReview() {
|
|||
className="sr-chip-toggle"
|
||||
aria-pressed={learnerOnly}
|
||||
onClick={() => setLearnerOnly(true)}
|
||||
disabled={data.turns.length === 0}
|
||||
disabled={turns.length === 0}
|
||||
>
|
||||
내 발화만
|
||||
</button>
|
||||
|
|
@ -483,7 +494,7 @@ export default function SessionReview() {
|
|||
<div className="sr-turn__body">
|
||||
<div className={`sr-turn__who sr-turn__who--${turn.speaker}`}>
|
||||
{turn.who}
|
||||
{turn.techniques.map((tech, i) => (
|
||||
{(turn.techniques ?? []).map((tech, i) => (
|
||||
<TechniqueChip key={`${tech.label}-${i}`} tech={tech} />
|
||||
))}
|
||||
{(turn.nonverbal ?? []).map((event, i) => (
|
||||
|
|
@ -520,8 +531,8 @@ export default function SessionReview() {
|
|||
<Card className="sr-card sr-card--side sr-card--rubric">
|
||||
<Kicker>기법 사용 분포</Kicker>
|
||||
<div className="sr-rubric" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{data.rubric.length > 0 ? (
|
||||
data.rubric.map((row) => (
|
||||
{rubric.length > 0 ? (
|
||||
rubric.map((row) => (
|
||||
<div key={row.name} className="sr-rubric__row">
|
||||
<div className="sr-rubric__top">
|
||||
<span className="sr-rubric__name">{row.name}</span>
|
||||
|
|
@ -557,12 +568,12 @@ export default function SessionReview() {
|
|||
<Card className="sr-card sr-card--side sr-card--good">
|
||||
<Kicker>좋았던 순간</Kicker>
|
||||
<div className="sr-points sr-points--good" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{data.goodMoments.length > 0 ? (
|
||||
data.goodMoments.map((point, i) => (
|
||||
{goodMoments.length > 0 ? (
|
||||
goodMoments.map((point, i) => (
|
||||
<JumpablePoint
|
||||
key={`${point.title}-${i}`}
|
||||
point={point}
|
||||
turns={data.turns}
|
||||
turns={turns}
|
||||
onJump={jumpToTurn}
|
||||
/>
|
||||
))
|
||||
|
|
@ -578,12 +589,12 @@ export default function SessionReview() {
|
|||
<Card className="sr-card sr-card--side sr-card--growth">
|
||||
<Kicker>다음 회기 개선점</Kicker>
|
||||
<div className="sr-points sr-points--grow" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{data.growthPoints.length > 0 ? (
|
||||
data.growthPoints.slice(0, 3).map((point, i) => (
|
||||
{growthPoints.length > 0 ? (
|
||||
growthPoints.slice(0, 3).map((point, i) => (
|
||||
<JumpablePoint
|
||||
key={`${point.title}-${i}`}
|
||||
point={point}
|
||||
turns={data.turns}
|
||||
turns={turns}
|
||||
onJump={jumpToTurn}
|
||||
/>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Vignette Handoff
|
||||
|
||||
> Updated: 2026-06-27 18:05 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다.
|
||||
> Updated: 2026-06-27 18:25 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
- X2 예산 경고/저비용 평가 라우팅: `ADMIN_USAGE_BUDGET_USD`가 0보다 크면 `/admin/usage`가 budget 상태(ok/warn/exceeded)를 반환하고 `/admin`이 예산 배너를 표시한다. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL`을 설정하면 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달한다. 비우면 기존 gateway default 라우팅을 유지한다.
|
||||
- H4 LLM call audit + 동의 게이트: 상담 생성(generate/stream)과 fast/deep 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다. 이번 패스에서 `app_user.consent_at` 기반 learner 동의 수락/철회 API와 세션 시작/voice dev persona 시작 하드게이트를 추가했다. 한국어 이름/기관 NER와 guardian/legal 서명 evidence는 후속.
|
||||
- RAG warm 동시성: E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 지연 로드되어 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py` embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py` warm task를 semaphore 1개로 제한했다. 최신 검증: `layout-visual-gate + session-layout` 11 passed.
|
||||
- 계약 SSOT 2차: `apps/web/src/lib/api.ts`의 수기 DTO 중 auth, persona, admin/user/engine, review leaf, teacher safety/growth leaf 타입을 `apps/web/src/lib/api.gen.ts`의 `ApiSchema` alias로 전환했다. generated optional/default 차이는 화면 form state와 notification default helper로 흡수했다. aggregate 응답 배열 optional 전환과 CI 연결은 후속. 검증: `npm run check:api-types`, `npm run typecheck`.
|
||||
- 계약 SSOT 3차: `apps/web/src/lib/api.ts`의 수기 DTO 중 auth, persona, admin/user/engine, review leaf, teacher safety/growth leaf, learner sessions, session review/worksheet aggregate, teacher dashboard aggregate 타입을 `apps/web/src/lib/api.gen.ts`의 `ApiSchema` alias로 전환했다. generated optional/default 차이는 화면 form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. CI 연결과 SessionDetail/SessionStart 계열 stage enum 정리는 후속. 검증: `npm run check:api-types`, `npm run typecheck`, `npm run build`, learner/session-review/teacher/layout focused E2E 32 passed.
|
||||
- 턴 런타임 리팩터 2차: `turn_runtime.finalize_completed_turn`으로 REST submit/stream/voice WS의 `record_completed_turn` + `record_safety_event` 호출쌍을 공통화했다. gateway `/v1/stream` route 레벨에서 `token/done/error` SSE 프레임 contract tests를 추가했다. 검증: focused backend 40 passed.
|
||||
- Phase3 artifact checker 강화: `scripts/check-phase3-artifacts.py`가 CSV enum, KPI report 필수 field, approved export의 PII pass·κ/ICC·withdrawn exclusion·consent scope·file sha256을 검증한다. `app/test_phase3_artifact_checker.py` 5 tests 추가. 실제 파일럿 evidence와 steward/legal/IAA gate는 그대로 외부 의존이다.
|
||||
- 세션 종료 UX/theme SSOT 정리: 드래그형 `SlideToEnd`를 명시 확인 다이얼로그로 교체하고, Topbar/Settings theme 토글을 `lib/theme.ts` 단일 경로로 통합했다. 저장값이 없으면 dark를 기본으로 두고 앱 부팅 시 theme를 먼저 적용해 초기 flash를 줄인다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts` 15 passed.
|
||||
|
|
@ -30,10 +30,10 @@
|
|||
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
|
||||
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다.
|
||||
- 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010`도 `ENVIRONMENT=dev`로 떠 있다.
|
||||
- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-06-27 17:25 KST 재확인: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, 공개 로그인 버튼 클릭 시 `accounts.google.com` OAuth URL로 이동.
|
||||
- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-06-27 18:25 KST 재확인: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google start 302 to `accounts.google.com` + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`.
|
||||
- `GET /personas`는 이제 인증 필요다. 검증 당시 로컬 `http://127.0.0.1:8000/personas`와 현재 공개 `https://api-vignette.chanpaca.net/personas` 모두 비로그인 401 확인 완료.
|
||||
- 로컬 웹이 `http://127.0.0.1:5175`처럼 다른 Vite 포트로 떠도 로그인은 `로컬 테스트 계정으로 계속` 버튼을 사용한다. Google OAuth 버튼은 로컬에서는 disabled로 둔다. 현재 OAuth callback이 공개 API로 돌아가기 때문에 로컬 Google OAuth는 로컬 세션에 붙을 수 없다.
|
||||
- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite web, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 `scripts/start-tailscale-runtime.ps1` 재기동 후 `login` 200, `/api/health` dev/db/engine true, direct Google start → `local_oauth_unavailable`, dev-login → `/learn` + `/api/auth/me` 200으로 재검증됐다.
|
||||
- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite web, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 `scripts/start-tailscale-runtime.ps1` 재기동 후 `login` 200, `/api/health` dev/db/engine true, `/api/auth/config` 200으로 재검증됐다.
|
||||
- `scripts/dev-up.ps1 -NoGateway -NoWeb`는 이제 gateway/web stale 정리를 건너뛰고 지정 `-ApiPort`의 API만 재기동한다. 검증 당시 8000 API-only 재기동 후 8001/8010/5173/9099/20241 listener가 보존됐다.
|
||||
- Docker `vignette-dev-db`는 실행 중이고 DB는 accepting connections다. 단, 기존 컨테이너라 healthcheck가 없고 `POSTGRES_USER=vignette` 기반이다. `vignette_app` role은 NOBYPASSRLS로 존재하지만 현재 API startup DDL이 owner 권한을 요구하므로 런타임 app-role 전환은 마이그레이션 owner/런타임 role 분리 후 진행한다.
|
||||
- 공개 런타임 재기동 스크립트:
|
||||
|
|
|
|||
|
|
@ -636,8 +636,8 @@
|
|||
<div class="scard-body"><div class="kv"><b>현재</b><p><code>app/turn_runtime.py</code>를 추가해 <code>load_owned_session</code>, <code>record_completed_turn</code>, <code>append_completed_turn</code>, <code>update_session_state</code>, <code>record_safety_event</code>를 공통화했다. 이번 패스에서 <code>finalize_completed_turn</code>을 추가해 <code>record_completed_turn</code> + <code>record_safety_event</code> 호출쌍을 REST submit/stream과 voice WS 라우트에서 제거했다. stream fast-loop 평가 누락, voice 세션 recall cache, visible_to/evaluator turn 분리는 보존.</p></div><div class="kv"><b>검증</b><p><code>pytest app/test_session_turn_persistence.py app/test_voice_ws.py engine_gateway/test_gateway_model.py app/test_phase3_artifact_checker.py app/test_dataset_export.py -q</code> 40 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>더 큰 <code>finalize_turn</code>/<code>SessionRepository</code> 경계, 장기적으로는 세션 저장소 인터페이스 SSOT화가 남아 있다.</p></div></div>
|
||||
</article>
|
||||
<article class="scard" data-status="doing" data-cat="리팩터·정리" data-owner="0">
|
||||
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">REFACTOR 2 2차 DONE</span><span class="scard-mid"><span class="scard-title">백엔드 Pydantic ↔ 프론트 <code>lib/api.ts</code> 계약 SSOT화</span><span class="scard-sum">OpenAPI→<code>api.gen.ts</code> 생성 + stale 체크 게이트. 수기 DTO 일부를 생성 타입 alias로 축소.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||
<div class="scard-body"><div class="kv"><b>현재</b><p><code>apps/api/scripts/export-openapi.py</code>와 <code>apps/web/scripts/generate-api-types.mjs</code>로 FastAPI OpenAPI를 <code>apps/web/src/lib/api.gen.ts</code>에 생성한다. <code>npm run check:api-types</code>가 재생성 누락을 잡고, <code>TurnResponse</code>/<code>SessionEndResponse</code>는 생성 타입 alias를 사용한다. 이번 패스에서 <code>MeResponse</code>, <code>AuthConfigResponse</code>, <code>ConsentResponse</code>, <code>PersonaSummary</code>, <code>PersonaReviewSummary</code>, admin/user/engine DTO, review leaf DTO, <code>CrisisResourceResponse</code>, teacher safety/growth leaf DTO를 <code>ApiSchema<...></code> alias로 전환했다. generated optional/default 차이는 UI form state와 notification default helper로 흡수했다.</p></div><div class="kv"><b>검증</b><p><code>npm run check:api-types</code>, <code>npm run typecheck</code> passed. Leaf DTO residual check에서 전환 대상은 <code>ApiSchema</code> alias로 확인.</p></div><div class="kv k-warn"><b>잔여</b><p>aggregate 응답(<code>SessionReviewResponse</code>, <code>TeacherDashboardResponse</code>, worksheet/review 배열 등)은 generated 배열 optional 차이를 UI fallback으로 먼저 흡수한 뒤 전환한다. 실제 CI 워크플로에 <code>npm run check:api-types</code>를 연결하는 작업도 남아 있다.</p></div></div>
|
||||
<button class="scard-head" aria-expanded="false"><span class="chip c-done">REFACTOR 2 3차 DONE</span><span class="scard-mid"><span class="scard-title">백엔드 Pydantic ↔ 프론트 <code>lib/api.ts</code> 계약 SSOT화</span><span class="scard-sum">OpenAPI→<code>api.gen.ts</code> 생성 + stale 체크 게이트. 주요 수기 DTO를 생성 타입 alias로 축소.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||
<div class="scard-body"><div class="kv"><b>현재</b><p><code>apps/api/scripts/export-openapi.py</code>와 <code>apps/web/scripts/generate-api-types.mjs</code>로 FastAPI OpenAPI를 <code>apps/web/src/lib/api.gen.ts</code>에 생성한다. <code>npm run check:api-types</code>가 재생성 누락을 잡는다. <code>MeResponse</code>, <code>AuthConfigResponse</code>, <code>ConsentResponse</code>, <code>PersonaSummary</code>, <code>PersonaReviewSummary</code>, admin/user/engine DTO, review leaf DTO, <code>CrisisResourceResponse</code>, teacher safety/growth leaf DTO에 이어 이번 패스에서 <code>LearnerSessionsResponse</code>, <code>SessionReviewResponse</code>, worksheet aggregate, <code>TeacherDashboardResponse</code>, <code>TeacherSessionSummary</code>, <code>TeacherLearnerGrowth</code>도 <code>ApiSchema<...></code> alias로 전환했다. generated optional/default 차이는 화면 form state, notification default helper, 배열 렌더링 fallback으로 흡수한다.</p></div><div class="kv"><b>검증</b><p><code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code> passed. 영향 범위 E2E: <code>learner.spec.ts</code> + <code>session-review.spec.ts</code> + <code>teacher.spec.ts</code> + <code>layout-visual-gate.spec.ts</code> 32 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>실제 CI 워크플로에 <code>npm run check:api-types</code>를 연결하는 작업은 남아 있다. <code>SessionDetail</code>/<code>SessionStart</code> 계열은 backend stage enum/guard를 먼저 정리한 뒤 생성 타입 alias로 전환한다.</p></div></div>
|
||||
</article>
|
||||
<article class="scard" data-status="done" data-cat="리팩터·정리" data-owner="0">
|
||||
<button class="scard-head" aria-expanded="false"><span class="chip c-done">REFACTOR 3 일부</span><span class="scard-mid"><span class="scard-title">죽은 확장점/계약 정리 — LogHook·tier 제거 적용</span><span class="scard-sum">caller 0 타입·분기·export 제거. 잔여는 패치 문서에 스테이징. 전체 API 119.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||
|
|
@ -774,7 +774,7 @@
|
|||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>레이아웃 감사팀 / 전면 레이아웃 개선</b><p>서브에이전트 전담으로 1차 overflow/clipping 수리 후 2차 전면 재설계를 진행했고, 이번 라운드에서 7개 권장 너비 시각 수용까지 닫았다. 세션, 학습자 홈/리뷰, 교수 콘솔, 관리자, 설정 화면을 작업 화면 기준으로 재배치했고, 과한 빈칸·튀어나감·버튼/텍스트 잘림·모바일 테이블 사용성을 줄였다. 엄격한 시각 게이트 + 화면별 적대적 시각 재검수에서 7개 화면 모두 accept(critical/major 0, 회귀 0)를 받아 DONE으로 닫는다.</p></div><div><b>산출물</b><p><code>docs/ops/layout-redesign-handoff-2026-06-26.md</code>, page-by-page redesign patches, <code>apps/web/e2e/layout-visual-gate.spec.ts</code></p></div><div><b>검증</b><p><code>npm run typecheck</code> OK, 엄격 시각 게이트 7 passed, layout focused E2E desktop/mobile 54 passed, 적대적 시각 재검수 7/7 accept·회귀 0.</p></div></div>
|
||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>엄격한 레이아웃 시각 게이트 구축 · 병렬 수정 · 적대적 재검수</b><p>핸드오프가 요구한 7개 권장 너비(390/720/861/900/1024/1280/1440) 시각 수용을 자동 게이트로 고정했다. 7개 재설계 화면을 각 너비에서 렌더링해 가로 overflow 0, 컨트롤 하드클립/텍스트클립 0을 강제하고 화면당 7장 총 49장 풀페이지 스크린샷을 남긴다. 화면별 1에이전트 병렬 시각 리뷰로 49장 직접 판독 → 6개 병렬 수정 에이전트(파일 비중첩)가 결함 수정 → 화면별 적대적 재검수로 해소·무회귀 확정.</p></div><div><b>산출물</b><p><code>apps/web/e2e/layout-visual-gate.spec.ts</code>, <code>node_modules/.tmp/layout-gate/*.png</code> 49장, 2× workflow(review/verify)·6× fix agent</p></div><div><b>검증</b><p>게이트 7 passed(재스크린샷). 해소된 핵심 결함: 학습자홈 1280/1440 3열 가운데 컬럼 붕괴(critical → 2열 분기 상향 + word-break:keep-all), 교수 'API 404' raw 배너 제거, 리뷰 빈상태 위계 역전, 관리자 스켈레톤, 설정 와이드 좌측 데드존 제거, 세션 모바일 44px 터치타깃. 적대적 재검수 7/7 accept, regression 0.</p></div></div>
|
||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>레이아웃 cosmetic minor 폴리시 일괄 처리</b><p>적대적 재검수 잔여 cosmetic minor를 5개 병렬 폴리시 에이전트로 처리했다. 학습자홈 헬퍼문구 고아 글자, 세션 1024 일시정지 아이콘 정리 + 보내기 버튼 대비 강화, 설정 2x2 칩 행 높이 균일, 교수 검토 큐 카드 갭 제거, 관리자 2열 높이 동기화를 적용했다.</p></div><div><b>산출물</b><p>page-by-page polish patches(LearnerHome/Session/Settings/Professor/Admin)</p></div><div><b>검증</b><p><code>npm run typecheck</code> OK, 시각 게이트 7 passed, 레이아웃 E2E desktop/mobile 54 passed, session-layout 8/8 — 회귀 0.</p></div></div>
|
||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>세션 종료 UX와 theme SSOT</b><p>세션 하단의 드래그형 종료 컴포넌트를 명시 확인 다이얼로그로 바꿔 모바일 오발동과 제스처 실패를 줄였다. Topbar/Settings의 theme 저장·초기화는 <code>lib/theme.ts</code> 단일 경로로 합치고, 저장값이 없으면 dark 기본값을 앱 부팅 전에 적용한다.</p></div><div><b>산출물</b><p><code>Session.tsx</code>, <code>session.css</code>, <code>session-review.css</code>, <code>lib/theme.ts</code>, <code>settings/useTheme.ts</code>, <code>session-layout.spec.ts</code></p></div><div><b>검증</b><p><code>npm run typecheck</code>, <code>npm run build</code>, strict layout gate 7 passed, <code>session-layout</code> desktop/mobile 8 passed, <code>settings</code> desktop/mobile 12 passed, <code>session-review</code> desktop/mobile 2 passed.</p></div></div>
|
||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>세션 종료 UX와 다크 테마 SSOT</b><p>세션 하단의 드래그형 종료 컴포넌트를 명시 확인 다이얼로그로 바꿔 모바일 오발동과 제스처 실패를 줄였다. Topbar/Settings의 theme 저장·초기화는 <code>lib/theme.ts</code> 단일 경로로 합치고, 저장값이 없으면 dark 기본값을 앱 부팅 전에 적용한다. API 기본 preference <code>system</code>은 Settings에서 light로 오해하지 않고 현재 초기 테마를 따르며, 회기 리뷰·설정 포함 주요 페이지의 흰 섹션 잔재를 dark 작업면으로 맞췄다.</p></div><div><b>산출물</b><p><code>Session.tsx</code>, <code>session.css</code>, <code>session-review.css</code>, <code>Settings.tsx</code>, <code>settings.css</code>, <code>lib/theme.ts</code>, <code>settings/useTheme.ts</code>, <code>session-layout.spec.ts</code></p></div><div><b>검증</b><p><code>npm run typecheck</code>, <code>npm run build</code>, <code>npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts e2e/settings.spec.ts e2e/session-review.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1</code> 30 passed. 추가 시각 감사: 7개 주요 페이지 × PC/태블릿/모바일 캡처에서 <code>theme=dark</code>, horizontal overflow 0, large bright surfaces 0.</p></div></div>
|
||||
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>잔여(비차단) — 공용 셸 단일 항목 → 처리</b><p>축소 사이드바 세로 구분선이 본문 전체 높이까지 닿지 않던 건을 <code>components/shell/shell.css</code>에서 처리했다. <code>.vg-nav border-right</code> 제거 후 <code>.vg-shell__body</code> 컨테이너 배경 하어라인으로 본문 그리드 전체 높이 구분선을 그리고, 그리드·구분선 폭을 <code>--nav-cur</code>로 동기화. learner-home 로딩 스켈레톤 밀도도 실제 카드 구조 모사로 보강했다.</p></div><div><b>판정</b><p>구현 완료. 검증: <code>npm run typecheck</code> PASS + <code>vite build</code> PASS. 전 페이지 시각 회귀 게이트는 web+api+DB 스택으로 이 워크스테이션 미실행 — 스택 가용 시 1회 시각 확인 권장.</p></div></div>
|
||||
</div>
|
||||
<div class="tab-subhead"><h4>검증 실행 상세 표</h4><p>요약하지 않고 실행 범위, 명령, 결과를 행 단위로 남긴다.</p></div>
|
||||
|
|
@ -783,6 +783,7 @@
|
|||
<tbody>
|
||||
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
|
||||
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
|
||||
<tr><td>Contract SSOT aggregate DTO</td><td><code>npm run typecheck</code> / <code>npm run build</code> / <code>learner + session-review + teacher + layout gate</code></td><td>Passed; learner sessions, session review/worksheet, teacher dashboard aggregates now use generated <code>ApiSchema</code> aliases with UI array fallback. Focused E2E 32 passed.</td></tr>
|
||||
<tr><td>Backend pytest</td><td><code>python -m pytest app/ -q</code></td><td>130 passed</td></tr>
|
||||
<tr><td>X2 evaluator model routing</td><td><code>python -m pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q</code></td><td>23 passed; fast/deep evaluator model override and blank-default routing covered</td></tr>
|
||||
<tr><td>C1 case worksheet</td><td><code>python -m pytest app/test_session_turn_persistence.py -q</code> / <code>session-review.spec.ts</code></td><td>14 passed; session review exposes transcript-grounded <code>caseWorksheet</code> and UI evidence jump</td></tr>
|
||||
|
|
@ -825,12 +826,12 @@
|
|||
<tr><td>Public login</td><td><code>auth.spec.ts --grep public login</code></td><td>1 passed</td></tr>
|
||||
<tr><td>Public runtime scripts</td><td><code>start/watch/install-public-runtime*.ps1</code></td><td>parser OK; watchdog check-only healthy. Default public checks exclude not-yet-live <code>api-vnet.18ka.net</code>; add future domains explicitly with <code>-AdditionalPublicHealthUrls</code>.</td></tr>
|
||||
<tr><td>Public API health</td><td><code>https://api-vignette.chanpaca.net/health</code></td><td>prod, db true, engine true</td></tr>
|
||||
<tr><td>Public/local/Tailnet login recovery</td><td><code>https://vignette.chanpaca.net/login</code> / <code>http://localhost:5173/login</code> / <code>https://alpaca-home.taile93291.ts.net/login</code></td><td>2026-06-27 17:25 KST 재확인: 모바일 502 시각의 cloudflared 로그는 <code>127.0.0.1:8001</code> origin refused였고 현재 public login 200, public API health prod/db/engine true. Public login button click reaches <code>accounts.google.com</code> with HttpOnly state cookie. Tailnet 403은 stale Vite allowedHosts 문제였고 <code>start-tailscale-runtime.ps1</code> 재기동 후 login 200 + <code>/api/health</code> dev/db/engine true + dev-login <code>/learn</code> 진입 + <code>/api/auth/me</code> 200. local/Tailnet Google direct → <code>local_oauth_unavailable</code>.</td></tr>
|
||||
<tr><td>Public/local/Tailnet login recovery</td><td><code>https://vignette.chanpaca.net/login</code> / <code>http://localhost:5173/login</code> / <code>https://alpaca-home.taile93291.ts.net/login</code></td><td>2026-06-27 18:25 KST 재확인: public login 200, public API health prod/db/engine true, public Google start 302 to <code>accounts.google.com</code> with HttpOnly state cookie, provider callback error → <code>/login?oauth=access_denied</code>. Tailnet 403은 stale Vite allowedHosts 문제였고 <code>start-tailscale-runtime.ps1</code> 재기동 후 Tailnet login 200 + <code>/api/health</code> dev/db/engine true + <code>/api/auth/config</code> 200. Docker <code>vignette-dev-db</code> accepting connections, 기존 컨테이너 healthcheck 없음.</td></tr>
|
||||
<tr><td>Local 5175 login</td><td><code>PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 auth.spec.ts</code></td><td>desktop/mobile passed</td></tr>
|
||||
<tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr>
|
||||
<tr><td>Learner screenshots</td><td><code>learn-empty-desktop.png / learn-empty-mobile-compact.png</code></td><td>document overflow 0, empty history visible</td></tr>
|
||||
<tr><td>Session layout/turn</td><td><code>session-layout.spec.ts desktop/mobile</code></td><td>8 passed</td></tr>
|
||||
<tr><td>Session end + theme UX</td><td><code>npm run typecheck</code>, <code>npm run build</code>, <code>layout-visual-gate</code>, <code>session-layout</code>, <code>settings</code>, <code>session-review</code></td><td>typecheck/build passed; strict layout gate 7 passed; session-layout desktop/mobile 8 passed; settings desktop/mobile 12 passed; session-review desktop/mobile 2 passed</td></tr>
|
||||
<tr><td>Session end + dark theme UX</td><td><code>npm run typecheck</code>, <code>npm run build</code>, <code>layout-visual-gate</code>, <code>session-layout</code>, <code>settings</code>, <code>session-review</code></td><td>typecheck/build passed; Playwright 묶음 30 passed(layout visual gate 7, session-layout desktop/mobile 8, settings desktop/mobile+single-run 13, session-review desktop/mobile 2). 7개 주요 페이지 × PC/태블릿/모바일 수동 캡처 감사에서 <code>theme=dark</code>, horizontal overflow 0, large bright surfaces 0.</td></tr>
|
||||
<tr><td>Voice/session focused</td><td><code>voice + voice-success + session-layout</code></td><td>15 passed</td></tr>
|
||||
<tr><td>Layout redesign handoff</td><td><code>docs/ops/layout-redesign-handoff-2026-06-26.md</code></td><td>subagent scopes, files, verification, remaining visual review recorded</td></tr>
|
||||
<tr><td>Layout redesign focused E2E</td><td><code>npx playwright test e2e/learner.spec.ts e2e/session-layout.spec.ts e2e/session-review.spec.ts e2e/admin.spec.ts e2e/settings.spec.ts e2e/teacher.spec.ts --project=chromium-desktop --project=chromium-mobile</code></td><td>54 passed after isolated Admin tablet flake rerun</td></tr>
|
||||
|
|
|
|||
|
|
@ -373,7 +373,9 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
|
|||
- SSE: `openSessionStream(sessionId, text, handlers)` — `fetch` 스트림을 직접 라인 파싱해
|
||||
`token | done | safety | ping | error` 이벤트를 콜백으로 전달.
|
||||
- 도메인 헬퍼: `sessionApi`(list/get/start/turn/end/review/stream), `personaApi`, `personaReviewApi`,
|
||||
`adminApi`, `adminEngineApi`, `teacherApi`, `userApi`. 응답 타입은 백엔드 라우트의 pydantic 모델 미러.
|
||||
`adminApi`, `adminEngineApi`, `teacherApi`, `userApi`. 주요 응답 타입은 FastAPI OpenAPI에서 생성한
|
||||
`src/lib/api.gen.ts`의 `ApiSchema<...>` alias를 사용하고, generated optional 배열은 화면 렌더링 계층에서
|
||||
빈 배열 fallback으로 흡수한다.
|
||||
- dev 환경: `vite.config`가 `/api` → `http://127.0.0.1:8000` 프록시(`/api` 프리픽스 제거).
|
||||
배포 호스트(`vignette.chanpaca.net`, `*.pages.dev`)에서는 `api-vignette.chanpaca.net`을 직접 가리킨다.
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
- P1 서연 음성 아트 PoC: Higgs v3 무참조 synthetic seed + 5개 정서/속도 변주(mp3/wav) 생성. 실존 reference voice 미사용. `/voice/ws`에는 dev-only sample TTS provider로 연결했고 live text_turn smoke에서 `p1-sample-poc` binary 20 chunks/78,573 bytes 수신 확인. 이 provider는 TTS override만 담당한다(마이크/STT는 OpenAI 키 필요). 프로덕션 탑재는 라이선스/권리 결정 전 금지.
|
||||
- 부수: RLS smoke 스크립트 cleanup RLS 컨텍스트 버그 수정, `vignette_app`(NOBYPASSRLS) dev 역할 프로비저닝.
|
||||
- 평가 SSOP 2차: `TurnRecord.evaluation`을 `app.feedback_scores`/`turn_technique`/`turn_client_state`/`supervisor_comment`/`alternative_utterance`로 정규화 적재·복원하고, submit/stream/voice 저장 경로를 `app.turn_runtime`으로 공통화했다. 원시 평가 테이블은 evaluator/admin RLS로 제한하고 서버가 evaluator 컨텍스트로 적재한다. `app.turns` insert는 `RETURNING id` 확인으로 silent success 제거. **검증: `pytest app/test_evaluation_persistence.py -q` 6 passed, 관련 회귀 45 passed, `pytest app/` 119 passed, `pytest engine_gateway/` 7 passed.**
|
||||
- 계약 SSOT 2차: FastAPI OpenAPI를 `apps/web/src/lib/api.gen.ts`로 생성하는 `npm run generate:api-types`와 stale 체크용 `npm run check:api-types`를 유지한다. `TurnResponse`/`SessionEndResponse`에 이어 `MeResponse`, `AuthConfigResponse`, `ConsentResponse`, `PersonaSummary`, `PersonaReviewSummary`, admin/user/engine DTO, review leaf DTO, `CrisisResourceResponse`, teacher safety/growth leaf DTO를 생성 타입 alias로 전환했다. generated optional/default 차이는 UI form state와 notification default helper로 흡수했다. aggregate 응답 배열 optional 전환과 CI 연결은 후속. **검증: `npm run check:api-types`, `npm run typecheck`.**
|
||||
- 계약 SSOT 3차: FastAPI OpenAPI를 `apps/web/src/lib/api.gen.ts`로 생성하는 `npm run generate:api-types`와 stale 체크용 `npm run check:api-types`를 유지한다. `TurnResponse`/`SessionEndResponse`에 이어 `MeResponse`, `AuthConfigResponse`, `ConsentResponse`, `PersonaSummary`, `PersonaReviewSummary`, admin/user/engine DTO, review leaf DTO, `CrisisResourceResponse`, teacher safety/growth leaf DTO, `LearnerSessionsResponse`, `SessionReviewResponse`, worksheet aggregate, `TeacherDashboardResponse`, `TeacherSessionSummary`, `TeacherLearnerGrowth`를 생성 타입 alias로 전환했다. generated optional/default 차이는 UI form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. CI 연결과 SessionDetail/SessionStart 계열의 stage enum 정리는 후속. **검증: `npm run check:api-types`, `npm run typecheck`, `npm run build`, `learner.spec.ts + session-review.spec.ts + teacher.spec.ts + layout-visual-gate.spec.ts` 32 passed.**
|
||||
- 위기게이트 109 1차: 실제 자해·자살 신호는 LLM/엔진 호출 전 중단하고 `crisis_resource(109)`와 `conversation_stopped`를 REST/SSE/voice 응답에 싣는다. `app.safety_events` detail 적재와 교수자 대시보드 안전 알림 큐까지 연결했다. 안전 이벤트 DB insert payload도 회귀화했다. 임상 문안, 실시간 push/메일, 지역 자원 확장은 후속. **검증: `pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q` 20 passed, `pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q` 21 passed.**
|
||||
- 교수자 성장 추적 1차: `teacher_dashboard`가 정규화된 턴 평가를 기반으로 학습자별 적절성·라포·기술 사용 추이를 집계하고, 교수 콘솔에 최근 회기/항목별 성장 카드와 안전 알림 큐를 함께 표시한다. **검증: `pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q` 21 passed, `pytest app/` 119 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`.**
|
||||
- 페르소나 저작 CRUD 1차: teacher/admin 전용 `POST/GET/PUT /personas/drafts` 경로를 추가해 draft 생성·조회·편집·검수요청을 열고, 교수 콘솔에 JSON 초안 작성/불러오기/수정 UI를 연결했다. 승인된 카드만 학습자 catalog에 노출하는 기존 경계는 유지한다. P4~P7 실제 임상 콘텐츠 입력은 후속. **검증: `pytest app/test_persona_review.py -q` 19 passed, `pytest app/` 119 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`.**
|
||||
|
|
@ -78,5 +78,5 @@
|
|||
- H4 LLM call audit 1차: 상담 생성(generate/stream)·fast-loop 평가·deep-loop 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않고 감사 실패는 상담 루프를 막지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다(session `2460d56c-a9cb-4a40-a175-9575d510a5e9`). 공개 Google OAuth 실제 `/turn` proof는 별도 B2 항목에 남긴다. **검증: `pytest app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py -q` 27 passed, `pytest app/ -q` 119 passed.**
|
||||
- H4 동의 게이트 1차: `app_user.consent_at`를 런타임 테이블 readiness와 `SessionUser`/`Principal`에 포함하고, learner 전용 `POST /auth/consent`·`DELETE /auth/consent`를 추가했다. 동의가 없는 learner는 `POST /sessions`와 voice dev persona 시작이 `consent_required`로 막히고, 프론트 세션 시작 전 동의 체크 UI가 먼저 저장하도록 안내한다. E2E dev-login helper는 세션 테스트 전에 동의를 seed한다. 한국어 이름/기관 NER, guardian/legal 서명 동의서 evidence, 공개 Google OAuth 실제 `/turn` proof는 후속. **검증: `pytest app/ -q` 125 passed, `pytest app/test_auth_providers.py app/test_session_turn_persistence.py app/test_persona_review.py app/test_voice_ws.py -q` 64 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, local `/sessions` no-consent 403 + `/auth/consent` 200.**
|
||||
- RAG warm 동시성 안정화: layout/session E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 여러 번 지연 로드되며 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py`에서 embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py`의 `_warm_rag_caches`를 semaphore 1개로 제한해 warm task가 상담 요청 경로를 막지 않도록 했다. **검증: `pytest app/ -q` 125 passed, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts --project=chromium-single-run --project=chromium-desktop --workers=1` 11 passed.**
|
||||
- 공개/로컬/Tailnet 로그인 복구: public API 530 원인은 prod에서 개발 전용 `VIGNETTE_VOICE_POC_SAMPLE_TTS=true`가 fail-close된 것과 DB `app.admin_engine_config` 기본 행 부재였다. 2026-06-27 모바일 502는 cloudflared 로그의 `127.0.0.1:8001` origin refused와 일치했다. `scripts/start-public-runtime.ps1`는 public prod 기동 시 샘플 TTS를 강제로 끄고, 운영 DB에는 `claude_cli`/`127.0.0.1:9099` engine config 행을 복구했다. 추가로 dev/Tailnet에서는 public OAuth callback이 로컬/Tailnet 세션으로 붙지 않으므로 Google 버튼과 직접 시작 URL을 `local_oauth_unavailable`로 차단하고 dev-login만 사용한다. public OAuth state는 HttpOnly 쿠키에 묶인 HMAC 서명 토큰으로 복구해 API 재시작 뒤 콜백이 `invalid_state`로 떨어지지 않는다. callback 실패는 비밀값 없는 reason/status로 남기고, 로그인 화면도 token/state/provider/identity 실패 메시지와 reason code를 분리한다. **검증: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google auth redirect 302 + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`, local/Tailnet Google direct는 각 origin의 `/login?oauth=local_oauth_unavailable`, `https://alpaca-home.taile93291.ts.net/login` 200 + `/api/health` dev/db/engine true, local auth E2E 7 passed, Tailnet auth/dev-login E2E 2 passed.**
|
||||
- 공개/로컬/Tailnet 로그인 복구: public API 530 원인은 prod에서 개발 전용 `VIGNETTE_VOICE_POC_SAMPLE_TTS=true`가 fail-close된 것과 DB `app.admin_engine_config` 기본 행 부재였다. 2026-06-27 모바일 502는 cloudflared 로그의 `127.0.0.1:8001` origin refused와 일치했다. `scripts/start-public-runtime.ps1`는 public prod 기동 시 샘플 TTS를 강제로 끄고, 운영 DB에는 `claude_cli`/`127.0.0.1:9099` engine config 행을 복구했다. 추가로 dev/Tailnet에서는 public OAuth callback이 로컬/Tailnet 세션으로 붙지 않으므로 Google 버튼과 직접 시작 URL을 `local_oauth_unavailable`로 차단하고 dev-login만 사용한다. public OAuth state는 HttpOnly 쿠키에 묶인 HMAC 서명 토큰으로 복구해 API 재시작 뒤 콜백이 `invalid_state`로 떨어지지 않는다. callback 실패는 비밀값 없는 reason/status로 남기고, 로그인 화면도 token/state/provider/identity 실패 메시지와 reason code를 분리한다. 2026-06-27 18:25 KST에 stale Vite `allowedHosts`로 생긴 Tailnet 403을 `scripts/start-tailscale-runtime.ps1` 재기동으로 복구했다. **검증: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google auth redirect 302 + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`, `https://alpaca-home.taile93291.ts.net/login` 200 + `/api/health` dev/db/engine true + `/api/auth/config` 200, local/Tailnet Google direct는 `local_oauth_unavailable`, local auth E2E 7 passed, Tailnet auth/dev-login E2E 2 passed.**
|
||||
- Docker dev DB 안전장치: `scripts/dev-up.ps1`가 `vignette-dev-db` healthcheck/pg_isready/role safety를 점검하고, 새 컨테이너 생성 시 `vignette_owner`와 앱 role을 분리해 healthcheck를 붙인다. `infra\.env` compose 필수값 누락도 사전 경고한다. `scripts/dev-down.ps1`은 기본 DB 보존, `-Db` 지정 시 컨테이너 중지로 정리했다. **검증: 현재 Docker DB accepting connections, `vignette_app:false:false`, 기존 컨테이너 healthcheck 없음. 추가 확인 결과 기존 DB는 `POSTGRES_USER=vignette` 기반이고 API startup DDL이 owner 권한을 요구해 런타임 app-role 전환은 마이그레이션 owner/런타임 role 분리 후 진행해야 한다.**
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
- Tailnet API `127.0.0.1:8010`
|
||||
- web `127.0.0.1:5173`
|
||||
- 2026-06-27 17:00 KST 재확인: Tailnet `GET /login`이 `403 Blocked request. This host ... is not allowed`를 반환하던 원인은 Vite가 stale env로 떠서 `VITE_ALLOWED_HOSTS=alpaca-home.taile93291.ts.net`를 반영하지 못한 상태였다. `scripts/start-tailscale-runtime.ps1 -ApiPort 8010 -WebPort 5173 -SkipServeUpdate` 재기동 후 `GET /login` 200, `GET /api/health` dev/db/engine true, direct Google start 302 `local_oauth_unavailable`로 복구 확인.
|
||||
- 2026-06-27 18:25 KST 재확인: 같은 stale Vite `allowedHosts` 증상이 재발해 `scripts/start-tailscale-runtime.ps1 -ApiPort 8010 -WebPort 5173`로 스택을 재기동했다. 현재 `https://alpaca-home.taile93291.ts.net/login` 200, `/api/health` 200(`dev`, `db:true`, `engine:true`), `/api/auth/config` 200이다. 함께 확인한 공개 경로도 `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` 200(`prod`, `db:true`, `engine:true`)이다.
|
||||
|
||||
## 공개 vnet.18ka.net 상태
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue