anhkhoiphan commited on
Commit
c34b712
·
1 Parent(s): 2eba2a0

Thêm logic scan tìm vấn đề học viên

Browse files
Files changed (2) hide show
  1. api.py +55 -0
  2. 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
+ )