Spaces:
Sleeping
Sleeping
Commit ·
c34b712
1
Parent(s): 2eba2a0
Thêm logic scan tìm vấn đề học viên
Browse files- api.py +55 -0
- at_risk.py +280 -0
api.py
CHANGED
|
@@ -165,6 +165,11 @@ class IndexPdfResponse(BaseModel):
|
|
| 165 |
conversation_id: str
|
| 166 |
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
# ── Helper ────────────────────────────────────────────────────────────────────
|
| 169 |
|
| 170 |
def _request_id(request: Request) -> str:
|
|
@@ -583,6 +588,56 @@ async def summary_chart(request: Request, body: ChatRequest):
|
|
| 583 |
)
|
| 584 |
|
| 585 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
# ── Exception handlers ────────────────────────────────────────────────────────
|
| 587 |
|
| 588 |
@app.exception_handler(404)
|
|
|
|
| 165 |
conversation_id: str
|
| 166 |
|
| 167 |
|
| 168 |
+
class AtRiskRequest(BaseModel):
|
| 169 |
+
room_ids: list[str] = Field(..., description="Danh sách room ID cần quét")
|
| 170 |
+
hours: int = Field(24, ge=1, le=168, description="Cửa sổ thời gian nhìn lại (giờ), mặc định 24")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
# ── Helper ────────────────────────────────────────────────────────────────────
|
| 174 |
|
| 175 |
def _request_id(request: Request) -> str:
|
|
|
|
| 588 |
)
|
| 589 |
|
| 590 |
|
| 591 |
+
# ── At-risk endpoint ──────────────────────────────────────────────────────────
|
| 592 |
+
|
| 593 |
+
@app.post(
|
| 594 |
+
"/api/v1/at-risk/analyze",
|
| 595 |
+
status_code=status.HTTP_200_OK,
|
| 596 |
+
summary="Phân tích tín hiệu nguy cơ học viên từ Redis",
|
| 597 |
+
tags=["At-Risk"],
|
| 598 |
+
responses={
|
| 599 |
+
400: {"model": ErrorDetail, "description": "room_ids rỗng"},
|
| 600 |
+
500: {"model": ErrorDetail, "description": "Lỗi xử lý nội bộ"},
|
| 601 |
+
},
|
| 602 |
+
)
|
| 603 |
+
async def at_risk_analyze(request: Request, body: AtRiskRequest):
|
| 604 |
+
"""
|
| 605 |
+
Nhận danh sách room_ids và cửa sổ thời gian (hours), fetch tin nhắn từ Redis,
|
| 606 |
+
dùng LLM phát hiện tín hiệu nguy cơ per-student:
|
| 607 |
+
- stuck_phrases: học viên bị kẹt / không hiểu nội dung
|
| 608 |
+
- unanswered_questions: câu hỏi học thuật chưa ai trả lời (kèm wait_minutes và suggested_points)
|
| 609 |
+
- frustration_phrases: biểu đạt chán nản / burn out
|
| 610 |
+
|
| 611 |
+
suggested_points cho unanswered_questions được tính từ thời gian chờ:
|
| 612 |
+
15-30 phút → 1, 30-60 phút → 2, 1-2 giờ → 3, >2 giờ → 4.
|
| 613 |
+
|
| 614 |
+
NestJS tự cộng absence_points (từ Supabase profiles) để ra total_score.
|
| 615 |
+
"""
|
| 616 |
+
if not body.room_ids:
|
| 617 |
+
raise HTTPException(
|
| 618 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 619 |
+
detail="room_ids không được để trống.",
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
from src.at_risk import analyze_rooms, AnalysisResult
|
| 623 |
+
loop = get_running_loop()
|
| 624 |
+
try:
|
| 625 |
+
result: AnalysisResult = await loop.run_in_executor(
|
| 626 |
+
_executor,
|
| 627 |
+
lambda: analyze_rooms(body.room_ids, body.hours),
|
| 628 |
+
)
|
| 629 |
+
except Exception:
|
| 630 |
+
logger.exception(
|
| 631 |
+
"Unhandled error in POST /api/v1/at-risk/analyze [rid=%s]", _request_id(request)
|
| 632 |
+
)
|
| 633 |
+
raise HTTPException(
|
| 634 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 635 |
+
detail="Lỗi phân tích. Xem log để biết thêm.",
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
+
return result
|
| 639 |
+
|
| 640 |
+
|
| 641 |
# ── Exception handlers ────────────────────────────────────────────────────────
|
| 642 |
|
| 643 |
@app.exception_handler(404)
|
at_risk.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
At-risk student analysis — called on-demand by NestJS.
|
| 3 |
+
|
| 4 |
+
Flow: nhận room_ids + hours → fetch Redis → LLM detect signals per room
|
| 5 |
+
→ Python tính wait_minutes cho unanswered questions → merge theo student.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime, timedelta, timezone
|
| 10 |
+
|
| 11 |
+
from langchain_core.output_parsers import JsonOutputParser
|
| 12 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
from src.redis_client import redis_client
|
| 16 |
+
from src.tools.base import get_llm
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ── Public output schemas (dùng cho api.py response_model) ────────────────────
|
| 22 |
+
|
| 23 |
+
class SignalItem(BaseModel):
|
| 24 |
+
text: str
|
| 25 |
+
room_id: str
|
| 26 |
+
timestamp: str
|
| 27 |
+
suggested_points: int
|
| 28 |
+
|
| 29 |
+
class UnansweredQuestion(BaseModel):
|
| 30 |
+
text: str
|
| 31 |
+
room_id: str
|
| 32 |
+
timestamp: str
|
| 33 |
+
wait_minutes: int
|
| 34 |
+
suggested_points: int
|
| 35 |
+
|
| 36 |
+
class StudentSignals(BaseModel):
|
| 37 |
+
stuck_phrases: list[SignalItem]
|
| 38 |
+
unanswered_questions: list[UnansweredQuestion]
|
| 39 |
+
frustration_phrases: list[SignalItem]
|
| 40 |
+
|
| 41 |
+
class StudentResult(BaseModel):
|
| 42 |
+
sender_id: str
|
| 43 |
+
sender_name: str
|
| 44 |
+
signals: StudentSignals
|
| 45 |
+
|
| 46 |
+
class AnalysisResult(BaseModel):
|
| 47 |
+
analyzed_at: str
|
| 48 |
+
students: list[StudentResult]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ── Internal LLM output schemas ────────────────────────────────────────────────
|
| 52 |
+
|
| 53 |
+
class _Signal(BaseModel):
|
| 54 |
+
sender_id: str = Field(description="sender_id chép nguyên văn từ tin nhắn")
|
| 55 |
+
sender_name: str = Field(description="tên người gửi chép nguyên văn từ tin nhắn")
|
| 56 |
+
text: str = Field(description="đoạn văn bản gốc từ tin nhắn")
|
| 57 |
+
timestamp: str = Field(description="timestamp chép nguyên văn từ tin nhắn")
|
| 58 |
+
suggested_points: int = Field(description="điểm mức độ nghiêm trọng")
|
| 59 |
+
|
| 60 |
+
class _Unanswered(BaseModel):
|
| 61 |
+
sender_id: str = Field(description="sender_id chép nguyên văn")
|
| 62 |
+
sender_name: str = Field(description="tên người gửi chép nguyên văn")
|
| 63 |
+
text: str = Field(description="câu hỏi gốc")
|
| 64 |
+
timestamp: str = Field(description="timestamp chép nguyên văn")
|
| 65 |
+
|
| 66 |
+
class _RoomAnalysis(BaseModel):
|
| 67 |
+
stuck_phrases: list[_Signal] = Field(default_factory=list)
|
| 68 |
+
unanswered_questions: list[_Unanswered] = Field(default_factory=list)
|
| 69 |
+
frustration_phrases: list[_Signal] = Field(default_factory=list)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ── LLM Prompt ─────────────────────────────────────────────────────────────────
|
| 73 |
+
|
| 74 |
+
_SYSTEM = """\
|
| 75 |
+
Bạn là chuyên gia phân tích hội thoại cho hệ thống giám sát học viên.
|
| 76 |
+
Đọc tin nhắn từ một phòng học, xác định các tín hiệu nguy cơ theo từng học viên.
|
| 77 |
+
Chỉ trả về JSON. Không giải thích.
|
| 78 |
+
|
| 79 |
+
TÍN HIỆU 1 — stuck_phrases: Học viên bị kẹt hoặc không hiểu NỘI DUNG HỌC.
|
| 80 |
+
Ví dụ: "không hiểu bài", "bí rồi", "không làm được bài tập", "chưa hiểu phần X".
|
| 81 |
+
suggested_points: 2 (lúng túng nhẹ) đến 4 (bỏ cuộc, tuyệt vọng).
|
| 82 |
+
|
| 83 |
+
TÍN HIỆU 2 — unanswered_questions: Câu hỏi học thuật KHÔNG có ai trả lời.
|
| 84 |
+
Chỉ tính câu hỏi về nội dung học (bài tập, bài giảng, khái niệm kỹ thuật).
|
| 85 |
+
Nếu có tin nhắn của người khác reply sau câu hỏi đó trong cuộc trò chuyện
|
| 86 |
+
thì KHÔNG tính là chưa trả lời.
|
| 87 |
+
Bỏ qua câu hỏi xã giao ("ai online?", "mọi người ăn chưa?").
|
| 88 |
+
Không cần trả về suggested_points — sẽ tính tự động theo thời gian chờ.
|
| 89 |
+
|
| 90 |
+
TÍN HIỆU 3 — frustration_phrases: Chán nản, mệt mỏi liên quan đến học tập.
|
| 91 |
+
Ví dụ: "chán quá", "học mãi không vô", "mệt rồi", "căng thẳng quá".
|
| 92 |
+
suggested_points: 2 (nhẹ) đến 3 (rõ ràng).
|
| 93 |
+
|
| 94 |
+
QUY TẮC:
|
| 95 |
+
Mỗi tin nhắn chỉ thuộc MỘT loại tín hiệu.
|
| 96 |
+
sender_id, sender_name, timestamp: chép NGUYÊN VĂN từ tin nhắn.
|
| 97 |
+
Không tìm thấy tín hiệu nào thì trả danh sách rỗng.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
_HUMAN = """\
|
| 101 |
+
Room: {room_id}
|
| 102 |
+
|
| 103 |
+
Tin nhắn (định dạng: [timestamp] sender_name (sender_id): nội dung):
|
| 104 |
+
{messages}
|
| 105 |
+
|
| 106 |
+
{format_instructions}
|
| 107 |
+
"""
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ── Helpers ────────────────────────────────────────────────────────────────────
|
| 111 |
+
|
| 112 |
+
# Tất cả field name có thể chứa username, theo thứ tự ưu tiên
|
| 113 |
+
_NAME_FIELDS = ["sender_username", "username", "u_username", "name", "u_name",
|
| 114 |
+
"senderName", "displayName", "display_name", "fullName", "sender_id"]
|
| 115 |
+
_ID_FIELDS = ["sender_id", "u_id", "userId", "user_id", "sender_username", "username"]
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _parse_ts(raw: str) -> datetime | None:
|
| 119 |
+
try:
|
| 120 |
+
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
| 121 |
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
| 122 |
+
except Exception:
|
| 123 |
+
return None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _get_field(m: dict, fields: list[str]) -> str:
|
| 127 |
+
for f in fields:
|
| 128 |
+
v = m.get(f)
|
| 129 |
+
if v and str(v).strip():
|
| 130 |
+
return str(v).strip()
|
| 131 |
+
return ""
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _filter_recent(messages: list[dict], hours: int) -> list[dict]:
|
| 135 |
+
cutoff = datetime.now(tz=timezone.utc) - timedelta(hours=hours)
|
| 136 |
+
return [
|
| 137 |
+
m for m in messages
|
| 138 |
+
if (dt := _parse_ts(m.get("created_at") or m.get("timestamp", ""))) and dt >= cutoff
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _format_messages(messages: list[dict]) -> str:
|
| 143 |
+
if messages:
|
| 144 |
+
logger.debug("[AtRisk] Sample message keys: %s", list(messages[0].keys()))
|
| 145 |
+
|
| 146 |
+
lines = []
|
| 147 |
+
for m in messages:
|
| 148 |
+
ts = m.get("created_at") or m.get("timestamp", "")
|
| 149 |
+
name = _get_field(m, _NAME_FIELDS) or "unknown"
|
| 150 |
+
sid = _get_field(m, _ID_FIELDS) or name
|
| 151 |
+
content = m.get("content") or m.get("text") or m.get("msg", "")
|
| 152 |
+
lines.append(f"[{ts}] {name} ({sid}): {content}")
|
| 153 |
+
return "\n".join(lines)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _sender_key(m: dict) -> str:
|
| 157 |
+
return _get_field(m, _ID_FIELDS)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _wait_minutes(question_ts: str, question_sender: str, messages: list[dict]) -> int:
|
| 161 |
+
"""Tính số phút từ câu hỏi đến reply đầu tiên của người khác (hoặc đến now nếu chưa có)."""
|
| 162 |
+
q_dt = _parse_ts(question_ts)
|
| 163 |
+
if q_dt is None:
|
| 164 |
+
return 0
|
| 165 |
+
|
| 166 |
+
for m in messages:
|
| 167 |
+
if _sender_key(m) == question_sender:
|
| 168 |
+
continue
|
| 169 |
+
dt = _parse_ts(m.get("created_at") or m.get("timestamp", ""))
|
| 170 |
+
if dt and dt > q_dt:
|
| 171 |
+
return max(0, int((dt - q_dt).total_seconds() / 60))
|
| 172 |
+
|
| 173 |
+
return max(0, int((datetime.now(tz=timezone.utc) - q_dt).total_seconds() / 60))
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _points_from_wait(minutes: int) -> int:
|
| 177 |
+
if minutes >= 120: return 4
|
| 178 |
+
if minutes >= 60: return 3
|
| 179 |
+
if minutes >= 30: return 2
|
| 180 |
+
if minutes >= 15: return 1
|
| 181 |
+
return 0 # < 15 phút → chưa đủ để tính
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ── LLM call ─────────────────────────────────────────────────────────────────
|
| 185 |
+
|
| 186 |
+
def _analyze_room(room_id: str, messages: list[dict]) -> _RoomAnalysis:
|
| 187 |
+
parser = JsonOutputParser(pydantic_object=_RoomAnalysis)
|
| 188 |
+
prompt = ChatPromptTemplate.from_messages([
|
| 189 |
+
("system", _SYSTEM),
|
| 190 |
+
("human", _HUMAN),
|
| 191 |
+
])
|
| 192 |
+
result = (prompt | get_llm() | parser).invoke({
|
| 193 |
+
"room_id": room_id,
|
| 194 |
+
"messages": _format_messages(messages),
|
| 195 |
+
"format_instructions": parser.get_format_instructions(),
|
| 196 |
+
})
|
| 197 |
+
return _RoomAnalysis(**result)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ── Accumulator ───────────────────────────────────────────────────────────────
|
| 201 |
+
|
| 202 |
+
def _add(store: dict, sender_id: str, sender_name: str, category: str, item: dict):
|
| 203 |
+
if sender_id not in store:
|
| 204 |
+
store[sender_id] = {
|
| 205 |
+
"sender_name": sender_name,
|
| 206 |
+
"stuck_phrases": [],
|
| 207 |
+
"unanswered_questions": [],
|
| 208 |
+
"frustration_phrases": [],
|
| 209 |
+
}
|
| 210 |
+
store[sender_id][category].append(item)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ── Public entry point ────────────────────────────────────────────────────────
|
| 214 |
+
|
| 215 |
+
def analyze_rooms(room_ids: list[str], hours: int = 24) -> AnalysisResult:
|
| 216 |
+
"""
|
| 217 |
+
Phân tích tín hiệu nguy cơ của học viên trong các room được chỉ định.
|
| 218 |
+
Chạy đồng bộ — gọi từ thread pool trong FastAPI endpoint.
|
| 219 |
+
"""
|
| 220 |
+
store: dict[str, dict] = {}
|
| 221 |
+
|
| 222 |
+
for room_id in room_ids:
|
| 223 |
+
try:
|
| 224 |
+
redis_room_id = room_id.removeprefix("room-")
|
| 225 |
+
all_msgs = redis_client.get_room_messages(redis_room_id, limit=500)
|
| 226 |
+
recent = _filter_recent(all_msgs, hours)
|
| 227 |
+
if not recent:
|
| 228 |
+
continue
|
| 229 |
+
|
| 230 |
+
raw = _analyze_room(room_id, recent)
|
| 231 |
+
|
| 232 |
+
for s in raw.stuck_phrases:
|
| 233 |
+
_add(store, s.sender_id, s.sender_name, "stuck_phrases", {
|
| 234 |
+
"text": s.text,
|
| 235 |
+
"room_id": room_id,
|
| 236 |
+
"timestamp": s.timestamp,
|
| 237 |
+
"suggested_points": min(max(s.suggested_points, 2), 4),
|
| 238 |
+
})
|
| 239 |
+
|
| 240 |
+
for q in raw.unanswered_questions:
|
| 241 |
+
wait = _wait_minutes(q.timestamp, q.sender_id, recent)
|
| 242 |
+
pts = _points_from_wait(wait)
|
| 243 |
+
if pts == 0:
|
| 244 |
+
continue # câu hỏi < 15 phút → bỏ qua
|
| 245 |
+
_add(store, q.sender_id, q.sender_name, "unanswered_questions", {
|
| 246 |
+
"text": q.text,
|
| 247 |
+
"room_id": room_id,
|
| 248 |
+
"timestamp": q.timestamp,
|
| 249 |
+
"wait_minutes": wait,
|
| 250 |
+
"suggested_points": pts,
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
for f in raw.frustration_phrases:
|
| 254 |
+
_add(store, f.sender_id, f.sender_name, "frustration_phrases", {
|
| 255 |
+
"text": f.text,
|
| 256 |
+
"room_id": room_id,
|
| 257 |
+
"timestamp": f.timestamp,
|
| 258 |
+
"suggested_points": min(max(f.suggested_points, 2), 3),
|
| 259 |
+
})
|
| 260 |
+
|
| 261 |
+
except Exception:
|
| 262 |
+
logger.exception("[AtRisk] Lỗi khi phân tích room '%s'", room_id)
|
| 263 |
+
|
| 264 |
+
students = [
|
| 265 |
+
StudentResult(
|
| 266 |
+
sender_id=sid,
|
| 267 |
+
sender_name=data["sender_name"],
|
| 268 |
+
signals=StudentSignals(
|
| 269 |
+
stuck_phrases=[SignalItem(**x) for x in data["stuck_phrases"]],
|
| 270 |
+
unanswered_questions=[UnansweredQuestion(**x) for x in data["unanswered_questions"]],
|
| 271 |
+
frustration_phrases=[SignalItem(**x) for x in data["frustration_phrases"]],
|
| 272 |
+
),
|
| 273 |
+
)
|
| 274 |
+
for sid, data in store.items()
|
| 275 |
+
]
|
| 276 |
+
|
| 277 |
+
return AnalysisResult(
|
| 278 |
+
analyzed_at=datetime.now(tz=timezone.utc).isoformat(),
|
| 279 |
+
students=students,
|
| 280 |
+
)
|