회기 집계와 기록 복귀 및 감독 근거 링크 수정
Some checks failed
API contract / OpenAPI type drift (push) Failing after 4m46s

This commit is contained in:
Yun Chan 2026-09-09 14:41:54 +09:00
parent aa951c6c4a
commit 1306c524c1
20 changed files with 4631 additions and 60 deletions

View file

@ -81,7 +81,7 @@ def _signal_id(*parts: object) -> str:
def _route(session_id: object) -> str:
return f"/teacher/sessions/{session_id}"
return f"/teach/session/{session_id}/review"
def _pointer(
@ -94,7 +94,7 @@ def _pointer(
ledger=ledger,
event_id=str(event_id),
session_id=str(session_id) if session_id else None,
route_hint=_route(session_id) if session_id else "/teacher/dashboard",
route_hint=_route(session_id) if session_id else "/teach",
)

View file

@ -301,10 +301,15 @@ def dashboard_achievements(
sessions: list[InProcSession],
review_ready: dict[str, bool],
) -> list[LearnerDashboardAchievement]:
completed = sum(1 for sess in sessions if sess.ended)
practice_sessions = [
sess
for sess in sessions
if any(turn.speaker in ("counselor", "learner") for turn in sess.turns)
]
completed = sum(1 for sess in practice_sessions if sess.ended)
active = sum(1 for sess in sessions if not sess.ended)
review_count = sum(1 for ready in review_ready.values() if ready)
by_persona = Counter(sess.persona_code for sess in sessions)
by_persona = Counter(sess.persona_code for sess in practice_sessions)
max_persona_sessions = max(by_persona.values(), default=0)
persona_coverage = len(by_persona)
return [

View file

@ -6,8 +6,9 @@ from uuid import uuid4
from app.deps import Principal, Role
from app.services import session_metrics, state_machine
from app.services.persona import P1, P2
from app.services.persona import P1, P2, P3
from app.session_dashboard_projection import (
dashboard_achievements,
dashboard_overview,
dashboard_persona_progress,
dashboard_training_exposure,
@ -23,7 +24,7 @@ def _make_session(
ended: bool = True,
turns: list[TurnRecord] | None = None,
) -> InProcSession:
persona = {P1.code: P1, P2.code: P2}.get(persona_code, P1)
persona = {P1.code: P1, P2.code: P2, P3.code: P3}.get(persona_code, P1)
state = state_machine.init_state(params=persona.openness_params())
state.stage = state_machine.Stage.EXPLORE
sess = InProcSession(
@ -118,6 +119,121 @@ class DashboardProjectionTDDTests(unittest.TestCase):
self.assertEqual(p2_row.sessions, 1)
self.assertEqual(p2_row.completed_sessions, 1)
def test_achievements_ignore_zero_turn_ended_sessions(self) -> None:
"""무발화 종료 회기는 성취를 열지 않는다."""
learner_id = "00000000-0000-0000-0000-000000000111"
zero_turn = _make_session("sess-zero", 1, learner_id, ended=True, turns=[])
achievements = {
item.id: item
for item in dashboard_achievements(
[zero_turn],
{},
)
}
self.assertEqual(achievements["first_session_complete"].state, "locked")
self.assertEqual(achievements["review_ready"].state, "locked")
self.assertEqual(achievements["review_ready"].detail, "현재 리뷰 가능한 회기 0건입니다.")
def test_achievements_ignore_zero_turn_repeat_and_coverage(self) -> None:
"""무발화 회기는 반복 및 페르소나 연습 기록으로 집계하지 않는다."""
learner_id = "00000000-0000-0000-0000-000000000111"
zero_turns = [
_make_session(
"sess-zero-1", 1, learner_id, ended=True, turns=[]
),
_make_session(
"sess-zero-2", 2, learner_id, persona_code="P2", ended=True, turns=[]
),
_make_session(
"sess-zero-3", 3, learner_id, persona_code="P3", ended=True, turns=[]
),
]
achievements = {
item.id: item
for item in dashboard_achievements(zero_turns, {})
}
self.assertEqual(achievements["persona_repeat"].state, "locked")
self.assertEqual(achievements["persona_coverage"].state, "locked")
def test_achievements_ignore_client_only_ended_session(self) -> None:
"""내담자 발화만 있는 종료 회기는 학습 성취를 만들지 않는다."""
learner_id = "00000000-0000-0000-0000-000000000111"
client_only = _make_session("sess-client-only", 1, learner_id, ended=True)
client_only.turns[0].speaker = "client"
achievements = {
item.id: item
for item in dashboard_achievements(
[client_only],
{},
)
}
self.assertEqual(achievements["first_session_complete"].state, "locked")
self.assertEqual(achievements["review_ready"].state, "locked")
self.assertEqual(achievements["persona_repeat"].state, "locked")
def test_achievements_keep_dialogue_sessions_and_active_progress(self) -> None:
"""학습자 발화와 진행 중 회기의 기존 성취 흐름을 유지한다."""
learner_id = "00000000-0000-0000-0000-000000000111"
completed = [
_make_session(f"sess-p1-{index}", index, learner_id, ended=True)
for index in range(1, 4)
]
completed.extend(
[
_make_session("sess-p2", 4, learner_id, persona_code="P2", ended=True),
_make_session("sess-p3", 5, learner_id, persona_code="P3", ended=True),
]
)
active = _make_session("sess-active", 6, learner_id, ended=False)
achievements = {
item.id: item
for item in dashboard_achievements(
[*completed, active],
{completed[0].session_id: True},
)
}
self.assertEqual(achievements["first_session_complete"].state, "done")
self.assertEqual(achievements["review_ready"].state, "done")
self.assertEqual(achievements["persona_repeat"].state, "done")
self.assertEqual(achievements["persona_coverage"].state, "done")
active_only = {
item.id: item
for item in dashboard_achievements([active], {})
}
self.assertEqual(active_only["first_session_complete"].state, "available")
self.assertEqual(active_only["persona_repeat"].state, "available")
learner_alias = _make_session("sess-learner", 7, learner_id, ended=True)
learner_alias.turns[0].speaker = "learner"
learner_alias_achievements = {
item.id: item
for item in dashboard_achievements(
[learner_alias],
{learner_alias.session_id: True},
)
}
self.assertEqual(learner_alias_achievements["first_session_complete"].state, "done")
self.assertEqual(learner_alias_achievements["review_ready"].state, "done")
empty_active = _make_session(
"sess-empty-active", 8, learner_id, ended=False, turns=[]
)
empty_active_achievements = {
item.id: item
for item in dashboard_achievements([empty_active], {})
}
self.assertEqual(empty_active_achievements["first_session_complete"].state, "available")
self.assertEqual(empty_active_achievements["persona_repeat"].state, "locked")
class TeacherDashboardProjectionTDDTests(unittest.IsolatedAsyncioTestCase):
"""Teacher dashboard projection alignment for zero-turn sessions."""
@ -212,4 +328,3 @@ class TeacherDashboardProjectionTDDTests(unittest.IsolatedAsyncioTestCase):
1,
"Teacher learner analysis summary.ended_sessions must exclude 0-turn ended sessions",
)

View file

@ -225,6 +225,66 @@ class SupervisionResearchProducerDerivationTest(TestCase):
serialized = str([item.signal.model_dump(mode="json") for item in derived])
self.assertNotIn("transcript", serialized.lower())
def test_produced_pointer_uses_canonical_session_review_route(self) -> None:
"""세션이 있는 생산 신호는 교수자 세션 리뷰 경로를 가리킨다."""
derived = derive_attention_signals(
trajectory_rows=[
{
"learner_id": LEARNER_A,
"revision_id": UUID("20000000-0000-0000-0000-000000000003"),
"anchor_session_id": SESSION_A,
"revision_no": 1,
"assessment": {
"sessions": [
{
"session_no": 1,
"status": "off_track",
"axes": [{"uncertainty": 0.2}],
}
]
},
}
],
rupture_rows=[],
calibration_rows=[],
transfer_rows=[],
practice_rows=[],
)
self.assertEqual(len(derived), 1)
self.assertEqual(
derived[0].signal.evidence[0].route_hint,
f"/teach/session/{SESSION_A}/review",
)
def test_produced_pointer_without_session_falls_back_to_teach(self) -> None:
"""세션 없는 생산 신호는 교수자 첫 화면으로 안전하게 돌아간다."""
derived = derive_attention_signals(
trajectory_rows=[],
rupture_rows=[],
calibration_rows=[],
transfer_rows=[
{
"learner_id": LEARNER_A,
"competency_id": "competency.empathy",
"transfer_assessment_id": UUID(
"50000000-0000-0000-0000-000000000003"
),
"session_id": None,
"assessment_payload": {
"eligible": True,
"transfer_verified": False,
"error_interval": {"lower": 0.1, "upper": 0.3},
},
"observed_sequence": 1,
}
],
practice_rows=[],
)
self.assertEqual(len(derived), 1)
self.assertEqual(derived[0].signal.evidence[0].route_hint, "/teach")
def test_requires_repeated_overconfidence_and_retry_for_persistent_signals(
self,
) -> None:

View file

@ -99,6 +99,23 @@ async function routeReviewShell(page: Page, reviewSessionId = SESSION_ID) {
);
}
function routeLearnerReadModel(
page: Page,
expectedSessionIds: readonly string[],
handler: (route: Route) => void | Promise<void>,
) {
return page.route(
(url) =>
url.pathname === "/api/calibration/learners/me" &&
url.searchParams.has("session_id"),
async (route) => {
const sessionId = new URL(route.request().url()).searchParams.get("session_id");
expect(expectedSessionIds).toContain(sessionId);
await handler(route);
},
);
}
function initialReadModel(
requestedView: "learner" | "supervisor" = "learner",
): CalibrationTransferReadModelResponse {
@ -516,7 +533,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", (route) =>
await routeLearnerReadModel(page, [SESSION_ID], (route) =>
fulfillReadModel(route, readModel),
);
await page.route("**/api/calibration/predictions/revisions", async (route) => {
@ -687,7 +704,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", (route) =>
await routeLearnerReadModel(page, [SESSION_ID], (route) =>
fulfillReadModel(route, readModel),
);
@ -805,7 +822,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page, PRACTICE_SESSION_ID);
await page.route("**/api/calibration/learners/me", async (route) => {
await routeLearnerReadModel(page, [PRACTICE_SESSION_ID], async (route) => {
readRequests += 1;
await fulfillReadModel(route, readModel);
});
@ -935,7 +952,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeAuth(page, "learner");
await routeReviewShell(page, PRACTICE_SESSION_ID);
await routeReviewShell(page, SESSION_ID);
await page.route("**/api/calibration/learners/me", (route) =>
await routeLearnerReadModel(page, [SESSION_ID, PRACTICE_SESSION_ID], (route) =>
fulfillReadModel(route, readModel),
);
await page.route("**/api/calibration/transfer-executions", async (route) => {
@ -997,7 +1014,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", (route) =>
await routeLearnerReadModel(page, [SESSION_ID], (route) =>
fulfillReadModel(route, readModel),
);
@ -1026,7 +1043,7 @@ test.describe("G5 calibration mirror and unseen transfer", () => {
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", async (route) => {
await routeLearnerReadModel(page, [SESSION_ID], async (route) => {
if (responseMode === "pending") {
await firstResponse;
}

View file

@ -239,8 +239,8 @@ test.describe("uc learner practice/history", () => {
"직장 내 대인관계 갈등을 호소",
);
await page.getByRole("button", { name: "새 회기 시작" }).click();
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
await page.getByRole("button", { name: "완전히 새로 시작" }).click();
await expect(page).toHaveURL(/\/learn\/session\/P2\?continuity=fresh$/);
});
// usecase: 기록이 없는 신규 학습자에게 기초 난도부터 추천하되, 사용 불가
@ -438,6 +438,96 @@ test.describe("uc learner practice/history", () => {
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
});
test("검색과 리뷰 필터를 URL에 보존해 리뷰 왕복과 새로고침 뒤에도 복구한다", async ({
page,
}) => {
await installFixtures(page);
await page.goto("/learn/history?source=dashboard");
const search = page.getByLabel("기록 검색");
const chips = page.locator(".lh-history-filter");
const historyLength = await page.evaluate(() => window.history.length);
await search.pressSequentially("성하늘");
await expect(search).toHaveValue("성하늘");
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe("성하늘");
await expect
.poll(() => page.evaluate(() => window.history.length))
.toBe(historyLength);
await chips.getByRole("button", { name: "리뷰 필요" }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get("status"))
.toBe("review");
await expect
.poll(() => new URL(page.url()).searchParams.get("source"))
.toBe("dashboard");
const revCard = page.locator(".lh-session-card", {
hasText: "AI 피드백 대기",
});
await revCard.getByRole("button", { name: "리뷰", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
await page.goBack();
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe("성하늘");
await expect
.poll(() => new URL(page.url()).searchParams.get("status"))
.toBe("review");
await expect(search).toHaveValue("성하늘");
await expect(chips.getByRole("button", { name: "리뷰 필요" })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.locator(".lh-session-card")).toHaveCount(1);
await page.reload();
await expect(search).toHaveValue("성하늘");
await expect(chips.getByRole("button", { name: "리뷰 필요" })).toHaveAttribute(
"aria-pressed",
"true",
);
await search.fill("");
await chips.getByRole("button", { name: "전체" }).click();
await expect
.poll(() => new URL(page.url()).searchParams.has("q"))
.toBe(false);
await expect
.poll(() => new URL(page.url()).searchParams.has("status"))
.toBe(false);
await expect
.poll(() => new URL(page.url()).searchParams.get("source"))
.toBe("dashboard");
});
test("잘못된 상태 query는 전체 필터로 안전하게 처리한다", async ({ page }) => {
await installFixtures(page);
await page.goto("/learn/history?status=unknown&q=%EA%B9%80%EB%8F%84%EC%9C%A4&source=dashboard");
const chips = page.locator(".lh-history-filter");
await expect(page.getByLabel("기록 검색")).toHaveValue("김도윤");
await expect(chips.getByRole("button", { name: "전체" })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.locator(".lh-session-card")).toHaveCount(1);
await chips.getByRole("button", { name: "리뷰 필요" }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get("status"))
.toBe("review");
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe("김도윤");
await expect
.poll(() => new URL(page.url()).searchParams.get("source"))
.toBe("dashboard");
});
// usecase: 종료된 기록 카드에서 다음 회기를 열어 같은 내담자의 맥락을 이어간다.
test("종료 기록의 다음 회기 이어가기 버튼이 같은 내담자 새 회기로 이동한다", async ({
page,
@ -450,7 +540,7 @@ test.describe("uc learner practice/history", () => {
await endCard
.getByRole("button", { name: "다음 회기 이어가기", exact: true })
.click();
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
await expect(page).toHaveURL(/\/learn\/session\/P2\?continuity=continue$/);
});
// usecase: 상태 필터 칩과 검색어를 함께 걸어 조건을 좁히고, 조건이 어긋나면

View file

@ -760,9 +760,9 @@ test.describe("회기 대화 — 텍스트 턴과 SSE 스트림", () => {
await resumeSession(page);
const transcript = page.locator(".sx-transcript__scroll");
// 조사까지 자연스럽게 바뀐다: [NAME]은→익명 내담자는, [NAME]이→익명 내담자가.
await expect(transcript).toContainText("익명 내담자는 제 얘기를 끝까지 듣지 않아요.");
await expect(transcript).toContainText("익명 내담자가 자꾸 저를 몰아세워요.");
// 조사까지 자연스럽게 바뀐다: [NAME]은→익명 인물은, [NAME]이→익명 인물이.
await expect(transcript).toContainText("익명 인물은 제 얘기를 끝까지 듣지 않아요.");
await expect(transcript).toContainText("익명 인물이 자꾸 저를 몰아세워요.");
await expect(transcript).not.toContainText("[NAME]");
});

View file

@ -562,6 +562,59 @@ test.describe("uc-teacher-console: 수퍼비전 attention 큐", () => {
page.locator(".pf-depth-strip .is-current"),
).toContainText("학습자 상세");
});
test("legacy 감독 근거 링크는 엄격히 교사 경로로 정규화하고 canonical 경로는 유지한다", async ({
page,
}) => {
const legacySessionId = "62000000-0000-0000-0000-000000000801";
const canonicalSessionId = "62000000-0000-0000-0000-000000000802";
await routeUnmockedApi(page);
await routeAuthedTeacher(page);
await routeSupervision(page, [
attentionItemFixture(1, "legacy-session", "growth_stagnation", [
`/teacher/sessions/${legacySessionId}/`,
]),
attentionItemFixture(2, "canonical-session", "deterioration", [
`/teach/session/${canonicalSessionId}/review`,
]),
attentionItemFixture(3, "legacy-dashboard", "safety_boundary", [
"/teacher/dashboard",
]),
]);
await routeConsole(page, {
pending_reviews: [
{
...pendingReviewFixture(1, "62000000-0000-0000-0000-000000000111"),
session_id: legacySessionId,
},
],
});
await routeTeacherSessionReview(page, legacySessionId);
await routeSupervisorCalibration(page, emptyCalibrationReadModelFixture());
await page.goto("/teach/supervision");
await expect(page.locator(".g6-drilldown a")).toHaveAttribute(
"href",
`/teach/session/${legacySessionId}/review`,
);
await page.getByRole("button", { name: /canonical-session/ }).click();
await expect(page.locator(".g6-drilldown a")).toHaveAttribute(
"href",
`/teach/session/${canonicalSessionId}/review`,
);
await page.getByRole("button", { name: /legacy-dashboard/ }).click();
await expect(page.locator(".g6-drilldown a")).toHaveAttribute("href", "/teach");
await page.getByRole("button", { name: /legacy-session/ }).click();
await page.locator(".g6-drilldown a").click();
await expect(page).toHaveURL(
new RegExp(`/teach/session/${legacySessionId}/review$`),
);
await expect(
page.getByText("교수자 검토 화면", { exact: true }),
).toBeVisible();
});
});
test.describe("uc-teacher-console: 학습자 상세 측정 근거", () => {

View file

@ -126,6 +126,10 @@ interface LearnerHomeProps {
view?: LearnerHomeView;
}
function isHistoryFilter(value: string | null): value is HistoryFilter {
return HISTORY_FILTERS.some((item) => item.value === value);
}
export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@ -168,8 +172,18 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
);
const [memoryLoadState, setMemoryLoadState] =
useState<MemoryLoadState>("idle");
const [historyFilter, setHistoryFilter] = useState<HistoryFilter>("all");
const [historyQuery, setHistoryQuery] = useState("");
const [localHistoryFilter, setLocalHistoryFilter] =
useState<HistoryFilter>("all");
const [localHistoryQuery, setLocalHistoryQuery] = useState("");
const historyStatusParam = searchParams.get("status");
const historyFilter =
view === "history"
? isHistoryFilter(historyStatusParam)
? historyStatusParam
: "all"
: localHistoryFilter;
const historyQuery =
view === "history" ? (searchParams.get("q") ?? "") : localHistoryQuery;
const [archiveBusyId, setArchiveBusyId] = useState<string | null>(null);
const [archiveNotice, setArchiveNotice] = useState<string | null>(null);
// 중간 폭(≤1180px)에서는 대시보드 섹션을 상단 가로 탭으로 전환한다(2026-07-15 소유자 피드백).
@ -177,6 +191,46 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
const [dashTab, setDashTab] = useState<DashboardTab>("today");
const dashTabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const updateHistorySearchParams = (
update: (params: URLSearchParams) => void,
) => {
const params = new URLSearchParams(searchParams);
update(params);
const nextSearch = params.toString();
navigate(
{ search: nextSearch ? `?${nextSearch}` : "" },
{ replace: true },
);
};
const setHistoryFilter = (nextFilter: HistoryFilter) => {
if (view !== "history") {
setLocalHistoryFilter(nextFilter);
return;
}
updateHistorySearchParams((params) => {
if (nextFilter === "all") {
params.delete("status");
} else {
params.set("status", nextFilter);
}
});
};
const setHistoryQuery = (nextQuery: string) => {
if (view !== "history") {
setLocalHistoryQuery(nextQuery);
return;
}
updateHistorySearchParams((params) => {
if (nextQuery.trim()) {
params.set("q", nextQuery);
} else {
params.delete("q");
}
});
};
function handleDashTabKeyDown(
event: ReactKeyboardEvent<HTMLButtonElement>,
currentIndex: number,

View file

@ -78,6 +78,17 @@ function shortId(value: string | undefined, length = 12): string {
return value.length > length ? `${value.slice(0, length)}` : value;
}
const LEGACY_TEACHER_SESSION_ROUTE =
/^\/teacher\/sessions\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i;
function normalizeDrilldownRoute(route: string): string {
if (route === "/teacher/dashboard") return "/teach";
const legacySession = LEGACY_TEACHER_SESSION_ROUTE.exec(route);
return legacySession
? `/teach/session/${legacySession[1]}/review`
: route;
}
function driftLabel(status: DriftStatus): string {
if (status === "stable") return "안정";
if (status === "drift_flagged") return "드리프트 확인 필요";
@ -217,7 +228,9 @@ function AttentionWorkbench({ items }: { items: AttentionItem[] }) {
}
const evidence = selected.evidence_pointer_ids.slice(0, 3);
const drilldowns = selected.drilldown_routes.slice(0, 3);
const drilldowns = selected.drilldown_routes
.slice(0, 3)
.map(normalizeDrilldownRoute);
return (
<div className="g6-workbench">