lct-15: самооценка курсанта до показа автооценки
Правило сервера, а не интерфейса: score.ready курсанту уходит только после self_assessment.submit, обойти его через DevTools нельзя. Преподаватель и монитор получают оценку сразу — им ждать нечего. Чек-лист для самооценки отдаётся отдельной точкой и только после конца звонка: во время разговора это содержимое подсказок, и открыть его значило бы выдать в контрольном режиме то, чего там быть не должно. Попутно подключён расчёт оценки при завершении звонка — детерминированный слой был написан в lct-12, но его никто не вызывал. Тест на отсутствие события смотрит очередь курсанта, а не ждёт из сокета: ожидание того, чего не будет, вешает прогон навсегда.
This commit is contained in:
parent
78f66cf3b5
commit
25143645d3
10 changed files with 260 additions and 2 deletions
|
|
@ -14,6 +14,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.db import repo
|
from app.db import repo
|
||||||
from app.db.base import get_session
|
from app.db.base import get_session
|
||||||
from app.domain.events import SessionMode
|
from app.domain.events import SessionMode
|
||||||
|
from app.scenarios import store
|
||||||
|
from app.session.hub import hub
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||||
|
|
||||||
|
|
@ -73,6 +75,34 @@ async def read(session_id: UUID, db: AsyncSession = Depends(get_session)) -> Ses
|
||||||
return _out(session)
|
return _out(session)
|
||||||
|
|
||||||
|
|
||||||
|
class ChecklistItemOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
question: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}/checklist", response_model=list[ChecklistItemOut])
|
||||||
|
async def checklist(session_id: UUID) -> list[ChecklistItemOut]:
|
||||||
|
"""Чек-лист для самооценки — **только после конца звонка**.
|
||||||
|
|
||||||
|
Во время звонка это содержимое подсказок: отдать его значит выдать
|
||||||
|
в контрольном режиме то, чего там быть не должно. После звонка курсант
|
||||||
|
по нему отмечает, что, по его мнению, пропустил.
|
||||||
|
"""
|
||||||
|
state = hub.get(session_id)
|
||||||
|
if state is None:
|
||||||
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
|
if not state.ended:
|
||||||
|
raise HTTPException(status_code=409, detail="call_not_ended")
|
||||||
|
scenario = store.get(state.scenario_id)
|
||||||
|
if scenario is None:
|
||||||
|
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||||
|
return [
|
||||||
|
ChecklistItemOut(id=item.id, question=item.question)
|
||||||
|
for item in scenario.checklist
|
||||||
|
if item.question
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SessionOut])
|
@router.get("", response_model=list[SessionOut])
|
||||||
async def listing(
|
async def listing(
|
||||||
trainee: UUID | None = None,
|
trainee: UUID | None = None,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from app.domain.events import (
|
||||||
)
|
)
|
||||||
from app.domain.events import BgStart
|
from app.domain.events import BgStart
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
|
from app.session.finish import finish, release_score
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
from app.session.state import now_utc
|
from app.session.state import now_utc
|
||||||
from app.voice.models import get_voice_models
|
from app.voice.models import get_voice_models
|
||||||
|
|
@ -125,10 +126,14 @@ async def _handle(session_id: UUID, state, event) -> None:
|
||||||
state.on_event("callback.dial")
|
state.on_event("callback.dial")
|
||||||
|
|
||||||
case "self_assessment.submit":
|
case "self_assessment.submit":
|
||||||
|
state.self_assessed = True
|
||||||
|
state.self_assessment = {"missed": event.missed, "comment": event.comment}
|
||||||
if hub.journal:
|
if hub.journal:
|
||||||
await hub.journal.self_assessment(
|
await hub.journal.self_assessment(
|
||||||
session_id, event.missed, event.comment, now_utc()
|
session_id, event.missed, event.comment, now_utc()
|
||||||
)
|
)
|
||||||
|
# Оценка могла быть готова раньше самооценки — теперь её можно отдать.
|
||||||
|
await release_score(session_id, state)
|
||||||
|
|
||||||
case "call.hangup":
|
case "call.hangup":
|
||||||
if state.voice is not None:
|
if state.voice is not None:
|
||||||
|
|
@ -142,6 +147,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
||||||
await hub.journal.session_ended(
|
await hub.journal.session_ended(
|
||||||
session_id, state.ended_at, CallEndReason.HANGUP.value
|
session_id, state.ended_at, CallEndReason.HANGUP.value
|
||||||
)
|
)
|
||||||
|
await finish(session_id, state)
|
||||||
|
|
||||||
|
|
||||||
def _start_voice(session_id: UUID, state) -> None:
|
def _start_voice(session_id: UUID, state) -> None:
|
||||||
|
|
|
||||||
53
backend/app/session/finish.py
Normal file
53
backend/app/session/finish.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""Завершение занятия: посчитать оценку и положить её в журнал.
|
||||||
|
|
||||||
|
Детерминированный слой считается сразу по завершении звонка (lct-12).
|
||||||
|
Курсанту `score.ready` уходит **только после самооценки**: сначала он сверяет
|
||||||
|
своё ощущение с объективной картиной, и расхождение — отдельный материал
|
||||||
|
для преподавателя (docs/product/DEBRIEF.md). Преподаватель и монитор получают
|
||||||
|
событие сразу: им ждать нечего.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.domain.events import ScoreReady
|
||||||
|
from app.scenarios import store
|
||||||
|
from app.scoring.competency import radar
|
||||||
|
from app.scoring.gost import evaluate
|
||||||
|
from app.session.hub import hub
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def finish(session_id: UUID, state) -> None:
|
||||||
|
scenario = store.get(state.scenario_id)
|
||||||
|
if scenario is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
result = evaluate(
|
||||||
|
scenario=scenario,
|
||||||
|
kio=state.kio,
|
||||||
|
timers=state.timers,
|
||||||
|
revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None,
|
||||||
|
end_reason=state.end_reason,
|
||||||
|
)
|
||||||
|
state.score = {
|
||||||
|
"score_auto": result.score,
|
||||||
|
"metrics": [metric.model_dump() for metric in result.metrics],
|
||||||
|
"findings": [finding.model_dump(mode="json") for finding in result.findings],
|
||||||
|
"competencies": [item.model_dump() for item in radar(result.metrics)],
|
||||||
|
"unavailable": result.unavailable,
|
||||||
|
}
|
||||||
|
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
|
||||||
|
|
||||||
|
if hub.journal:
|
||||||
|
await hub.journal.score(session_id, result.score, state.score)
|
||||||
|
|
||||||
|
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||||
|
await release_score(session_id, state)
|
||||||
|
|
||||||
|
|
||||||
|
async def release_score(session_id: UUID, state) -> None:
|
||||||
|
"""Отдать оценку курсанту, когда самооценка сдана."""
|
||||||
|
if state.score is not None and state.self_assessed:
|
||||||
|
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
||||||
|
|
@ -34,6 +34,7 @@ class Journal(Protocol):
|
||||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
|
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
|
||||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
|
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
|
||||||
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
|
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
|
||||||
|
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
|
||||||
async def session_started(self, session_id: UUID, at) -> None: ...
|
async def session_started(self, session_id: UUID, at) -> None: ...
|
||||||
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
|
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from sqlalchemy import update
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
|
||||||
from app.db import repo
|
from app.db import repo
|
||||||
from app.db.models import SelfAssessment, Session
|
from app.db.models import Score, SelfAssessment, Session
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -84,6 +84,13 @@ class DbJournal:
|
||||||
|
|
||||||
await self._write(lambda db: action(db))
|
await self._write(lambda db: action(db))
|
||||||
|
|
||||||
|
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None:
|
||||||
|
async def action(db):
|
||||||
|
db.add(Score(session_id=session_id, score_auto=score_auto, score_final=score_auto, report=report))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await self._write(lambda db: action(db))
|
||||||
|
|
||||||
async def session_started(self, session_id: UUID, at: datetime) -> None:
|
async def session_started(self, session_id: UUID, at: datetime) -> None:
|
||||||
async def action(db):
|
async def action(db):
|
||||||
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
|
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,11 @@ class SessionState:
|
||||||
audio_frames: int = 0
|
audio_frames: int = 0
|
||||||
bad_frames: int = 0
|
bad_frames: int = 0
|
||||||
|
|
||||||
|
# Самооценка сдана — до неё курсант не видит автооценку.
|
||||||
|
self_assessed: bool = False
|
||||||
|
self_assessment: dict | None = None
|
||||||
|
score: dict | None = None
|
||||||
|
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
ended_at: datetime | None = None
|
ended_at: datetime | None = None
|
||||||
end_reason: CallEndReason | None = None
|
end_reason: CallEndReason | None = None
|
||||||
|
|
|
||||||
|
|
@ -194,3 +194,67 @@ def test_events_still_work_between_audio_frames(client):
|
||||||
trainee.send_bytes(b"\x00\x00" * 320)
|
trainee.send_bytes(b"\x00\x00" * 320)
|
||||||
wait_for(lambda: state.kio.floor == "5")
|
wait_for(lambda: state.kio.floor == "5")
|
||||||
assert state.audio_frames == 2
|
assert state.audio_frames == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_waits_for_self_assessment(client):
|
||||||
|
"""Курсант сначала сверяет своё ощущение с объективной картиной:
|
||||||
|
расхождение самооценки с автооценкой — отдельный материал для преподавателя."""
|
||||||
|
with lesson(client) as (session_id, _):
|
||||||
|
state = hub.get(session_id)
|
||||||
|
with client.websocket_connect(f"/ws/observe/{session_id}") as observer:
|
||||||
|
observer.receive_json()
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
trainee.send_json({"type": "call.answer"})
|
||||||
|
trainee.send_json({"type": "kio.patch", "fields": {"address": "улица Ленина, 14"}})
|
||||||
|
trainee.send_json({"type": "call.hangup"})
|
||||||
|
|
||||||
|
# Преподавателю и монитору — сразу: им ждать нечего.
|
||||||
|
assert read_until(observer, "score.ready")["session_id"] == str(session_id)
|
||||||
|
wait_for(lambda: state.score is not None)
|
||||||
|
|
||||||
|
trainee.send_json({"type": "self_assessment.submit",
|
||||||
|
"missed": ["q_people"], "comment": "растерялся на адресе"})
|
||||||
|
message = read_until(trainee, "score.ready")
|
||||||
|
|
||||||
|
assert message["session_id"] == str(session_id)
|
||||||
|
assert state.self_assessment == {"missed": ["q_people"], "comment": "растерялся на адресе"}
|
||||||
|
assert state.score["score_auto"] >= 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_trainee_gets_no_score_without_self_assessment(client):
|
||||||
|
"""Отсутствие события проверяется подпиской на очередь курсанта:
|
||||||
|
ждать его из сокета нечем — чтение заблокируется навсегда."""
|
||||||
|
from app.domain.events import ScoreReady
|
||||||
|
|
||||||
|
with lesson(client) as (session_id, _):
|
||||||
|
state = hub.get(session_id)
|
||||||
|
with hub.trainee(session_id) as queue:
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
trainee.send_json({"type": "call.answer"})
|
||||||
|
trainee.send_json({"type": "call.hangup"})
|
||||||
|
wait_for(lambda: state.score is not None)
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
delivered = []
|
||||||
|
while not queue.empty():
|
||||||
|
delivered.append(queue.get_nowait())
|
||||||
|
|
||||||
|
assert not any(isinstance(event, ScoreReady) for event in delivered), (
|
||||||
|
"оценка ушла курсанту до самооценки"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_checklist_is_closed_until_the_call_is_over(client):
|
||||||
|
"""Во время звонка чек-лист — это содержимое подсказок."""
|
||||||
|
with lesson(client) as (session_id, _):
|
||||||
|
during = client.get(f"/api/sessions/{session_id}/checklist")
|
||||||
|
assert during.status_code == 409, during.text
|
||||||
|
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
trainee.send_json({"type": "call.answer"})
|
||||||
|
trainee.send_json({"type": "call.hangup"})
|
||||||
|
wait_for(lambda: hub.get(session_id).ended)
|
||||||
|
|
||||||
|
after = client.get(f"/api/sessions/{session_id}/checklist")
|
||||||
|
assert after.status_code == 200
|
||||||
|
assert all(item["question"] for item in after.json())
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { type Capture, startCapture } from "@/shared/audio/capture";
|
||||||
import { EnergyGate } from "@/shared/audio/levels";
|
import { EnergyGate } from "@/shared/audio/levels";
|
||||||
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
|
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
|
||||||
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
|
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
|
||||||
|
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
|
||||||
import type { CallIncoming, DDSCode, ServerToTrainee, TimerSnapshot } from "@/shared/types/generated";
|
import type { CallIncoming, DDSCode, ServerToTrainee, TimerSnapshot } from "@/shared/types/generated";
|
||||||
|
|
||||||
/** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */
|
/** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */
|
||||||
|
|
@ -33,6 +34,9 @@ export function useCall(sessionId: string | null) {
|
||||||
const [callerSpeaking, setCallerSpeaking] = useState(false);
|
const [callerSpeaking, setCallerSpeaking] = useState(false);
|
||||||
const [micOn, setMicOn] = useState(false);
|
const [micOn, setMicOn] = useState(false);
|
||||||
const [card, setCard] = useState<CardState>(emptyCard);
|
const [card, setCard] = useState<CardState>(emptyCard);
|
||||||
|
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
|
||||||
|
const [selfAssessed, setSelfAssessed] = useState(false);
|
||||||
|
const [scoreReady, setScoreReady] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
||||||
|
|
@ -98,6 +102,15 @@ export function useCall(sessionId: string | null) {
|
||||||
case "call.ended":
|
case "call.ended":
|
||||||
setPhase("ended");
|
setPhase("ended");
|
||||||
audio.current?.ambience.stop();
|
audio.current?.ambience.stop();
|
||||||
|
// Чек-лист открывается только после звонка: во время него это
|
||||||
|
// содержимое подсказок.
|
||||||
|
void fetch(`/api/sessions/${sessionId}/checklist`)
|
||||||
|
.then((response) => (response.ok ? response.json() : []))
|
||||||
|
.then(setChecklist)
|
||||||
|
.catch(() => setChecklist([]));
|
||||||
|
break;
|
||||||
|
case "score.ready":
|
||||||
|
setScoreReady(true);
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
setError(event.message);
|
setError(event.message);
|
||||||
|
|
@ -162,8 +175,14 @@ export function useCall(sessionId: string | null) {
|
||||||
channel.current?.send({ type: "dds.dispatch", service });
|
channel.current?.send({ type: "dds.dispatch", service });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
|
||||||
|
channel.current?.send({ type: "self_assessment.submit", missed, comment });
|
||||||
|
setSelfAssessed(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
|
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
|
||||||
answer, hangup, hint, patchKio, dispatch,
|
checklist, selfAssessed, scoreReady,
|
||||||
|
answer, hangup, hint, patchKio, dispatch, submitSelfAssessment,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
frontend/src/features/self-assessment/SelfAssessment.tsx
Normal file
65
frontend/src/features/self-assessment/SelfAssessment.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// Самооценка курсанта — до показа автооценки.
|
||||||
|
//
|
||||||
|
// Педагогический смысл — рефлексия: курсант сверяет своё ощущение с объективной
|
||||||
|
// оценкой, а расхождение — отдельный материал для преподавателя. Курсант,
|
||||||
|
// не заметивший, что пропустил вопрос о пострадавших, — более важный случай,
|
||||||
|
// чем сама ошибка (docs/product/DEBRIEF.md).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export interface ChecklistItem {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelfAssessment({
|
||||||
|
checklist,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
checklist: ChecklistItem[];
|
||||||
|
onSubmit: (missed: string[], comment: string) => void;
|
||||||
|
}) {
|
||||||
|
const [missed, setMissed] = useState<string[]>([]);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setMissed((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2>Самооценка</h2>
|
||||||
|
<p>Отметьте вопросы, которые, по вашему мнению, вы пропустили. Оценка откроется после.</p>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{checklist.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<th>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={missed.includes(item.id)} onChange={() => toggle(item.id)} />{" "}
|
||||||
|
пропустил
|
||||||
|
</label>
|
||||||
|
</th>
|
||||||
|
<td>{item.question}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr>
|
||||||
|
<th>Комментарий</th>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={comment}
|
||||||
|
placeholder="например: запнулся на адресе, растерялся"
|
||||||
|
onChange={(event) => setComment(event.target.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<button type="button" onClick={() => onSubmit(missed, comment)}>
|
||||||
|
Отправить самооценку
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import { KioCard } from "@/features/kio-card/KioCard";
|
import { KioCard } from "@/features/kio-card/KioCard";
|
||||||
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
|
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
|
||||||
|
import { SelfAssessment } from "@/features/self-assessment/SelfAssessment";
|
||||||
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
||||||
import { display } from "@/features/kio-card/merge";
|
import { display } from "@/features/kio-card/merge";
|
||||||
import { useCall } from "@/features/call/useCall";
|
import { useCall } from "@/features/call/useCall";
|
||||||
|
|
@ -54,6 +55,13 @@ export function Call() {
|
||||||
|
|
||||||
{call.error && <p className="violated">{call.error}</p>}
|
{call.error && <p className="violated">{call.error}</p>}
|
||||||
|
|
||||||
|
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && (
|
||||||
|
<SelfAssessment checklist={call.checklist} onSubmit={call.submitSelfAssessment} />
|
||||||
|
)}
|
||||||
|
{call.selfAssessed && (
|
||||||
|
<p>{call.scoreReady ? "Оценка готова — разбор открывает преподаватель." : "Самооценка принята, оценка считается…"}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="columns">
|
<div className="columns">
|
||||||
<section>
|
<section>
|
||||||
<h2>Карточка информационного обмена</h2>
|
<h2>Карточка информационного обмена</h2>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue