092_agent_api / at_risk.py
anhkhoiphan's picture
Thêm logic scan tìm vấn đề học viên
c34b712
Raw
History Blame Contribute Delete
11.4 kB
"""
At-risk student analysis — called on-demand by NestJS.
Flow: nhận room_ids + hours → fetch Redis → LLM detect signals per room
→ Python tính wait_minutes cho unanswered questions → merge theo student.
"""
import logging
from datetime import datetime, timedelta, timezone
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel, Field
from src.redis_client import redis_client
from src.tools.base import get_llm
logger = logging.getLogger(__name__)
# ── Public output schemas (dùng cho api.py response_model) ────────────────────
class SignalItem(BaseModel):
text: str
room_id: str
timestamp: str
suggested_points: int
class UnansweredQuestion(BaseModel):
text: str
room_id: str
timestamp: str
wait_minutes: int
suggested_points: int
class StudentSignals(BaseModel):
stuck_phrases: list[SignalItem]
unanswered_questions: list[UnansweredQuestion]
frustration_phrases: list[SignalItem]
class StudentResult(BaseModel):
sender_id: str
sender_name: str
signals: StudentSignals
class AnalysisResult(BaseModel):
analyzed_at: str
students: list[StudentResult]
# ── Internal LLM output schemas ────────────────────────────────────────────────
class _Signal(BaseModel):
sender_id: str = Field(description="sender_id chép nguyên văn từ tin nhắn")
sender_name: str = Field(description="tên người gửi chép nguyên văn từ tin nhắn")
text: str = Field(description="đoạn văn bản gốc từ tin nhắn")
timestamp: str = Field(description="timestamp chép nguyên văn từ tin nhắn")
suggested_points: int = Field(description="điểm mức độ nghiêm trọng")
class _Unanswered(BaseModel):
sender_id: str = Field(description="sender_id chép nguyên văn")
sender_name: str = Field(description="tên người gửi chép nguyên văn")
text: str = Field(description="câu hỏi gốc")
timestamp: str = Field(description="timestamp chép nguyên văn")
class _RoomAnalysis(BaseModel):
stuck_phrases: list[_Signal] = Field(default_factory=list)
unanswered_questions: list[_Unanswered] = Field(default_factory=list)
frustration_phrases: list[_Signal] = Field(default_factory=list)
# ── LLM Prompt ─────────────────────────────────────────────────────────────────
_SYSTEM = """\
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.
Đọ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.
Chỉ trả về JSON. Không giải thích.
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.
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".
suggested_points: 2 (lúng túng nhẹ) đến 4 (bỏ cuộc, tuyệt vọng).
TÍN HIỆU 2 — unanswered_questions: Câu hỏi học thuật KHÔNG có ai trả lời.
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).
Nếu có tin nhắn của người khác reply sau câu hỏi đó trong cuộc trò chuyện
thì KHÔNG tính là chưa trả lời.
Bỏ qua câu hỏi xã giao ("ai online?", "mọi người ăn chưa?").
Không cần trả về suggested_points — sẽ tính tự động theo thời gian chờ.
TÍN HIỆU 3 — frustration_phrases: Chán nản, mệt mỏi liên quan đến học tập.
Ví dụ: "chán quá", "học mãi không vô", "mệt rồi", "căng thẳng quá".
suggested_points: 2 (nhẹ) đến 3 (rõ ràng).
QUY TẮC:
Mỗi tin nhắn chỉ thuộc MỘT loại tín hiệu.
sender_id, sender_name, timestamp: chép NGUYÊN VĂN từ tin nhắn.
Không tìm thấy tín hiệu nào thì trả danh sách rỗng.
"""
_HUMAN = """\
Room: {room_id}
Tin nhắn (định dạng: [timestamp] sender_name (sender_id): nội dung):
{messages}
{format_instructions}
"""
# ── Helpers ────────────────────────────────────────────────────────────────────
# Tất cả field name có thể chứa username, theo thứ tự ưu tiên
_NAME_FIELDS = ["sender_username", "username", "u_username", "name", "u_name",
"senderName", "displayName", "display_name", "fullName", "sender_id"]
_ID_FIELDS = ["sender_id", "u_id", "userId", "user_id", "sender_username", "username"]
def _parse_ts(raw: str) -> datetime | None:
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except Exception:
return None
def _get_field(m: dict, fields: list[str]) -> str:
for f in fields:
v = m.get(f)
if v and str(v).strip():
return str(v).strip()
return ""
def _filter_recent(messages: list[dict], hours: int) -> list[dict]:
cutoff = datetime.now(tz=timezone.utc) - timedelta(hours=hours)
return [
m for m in messages
if (dt := _parse_ts(m.get("created_at") or m.get("timestamp", ""))) and dt >= cutoff
]
def _format_messages(messages: list[dict]) -> str:
if messages:
logger.debug("[AtRisk] Sample message keys: %s", list(messages[0].keys()))
lines = []
for m in messages:
ts = m.get("created_at") or m.get("timestamp", "")
name = _get_field(m, _NAME_FIELDS) or "unknown"
sid = _get_field(m, _ID_FIELDS) or name
content = m.get("content") or m.get("text") or m.get("msg", "")
lines.append(f"[{ts}] {name} ({sid}): {content}")
return "\n".join(lines)
def _sender_key(m: dict) -> str:
return _get_field(m, _ID_FIELDS)
def _wait_minutes(question_ts: str, question_sender: str, messages: list[dict]) -> int:
"""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ó)."""
q_dt = _parse_ts(question_ts)
if q_dt is None:
return 0
for m in messages:
if _sender_key(m) == question_sender:
continue
dt = _parse_ts(m.get("created_at") or m.get("timestamp", ""))
if dt and dt > q_dt:
return max(0, int((dt - q_dt).total_seconds() / 60))
return max(0, int((datetime.now(tz=timezone.utc) - q_dt).total_seconds() / 60))
def _points_from_wait(minutes: int) -> int:
if minutes >= 120: return 4
if minutes >= 60: return 3
if minutes >= 30: return 2
if minutes >= 15: return 1
return 0 # < 15 phút → chưa đủ để tính
# ── LLM call ─────────────────────────────────────────────────────────────────
def _analyze_room(room_id: str, messages: list[dict]) -> _RoomAnalysis:
parser = JsonOutputParser(pydantic_object=_RoomAnalysis)
prompt = ChatPromptTemplate.from_messages([
("system", _SYSTEM),
("human", _HUMAN),
])
result = (prompt | get_llm() | parser).invoke({
"room_id": room_id,
"messages": _format_messages(messages),
"format_instructions": parser.get_format_instructions(),
})
return _RoomAnalysis(**result)
# ── Accumulator ───────────────────────────────────────────────────────────────
def _add(store: dict, sender_id: str, sender_name: str, category: str, item: dict):
if sender_id not in store:
store[sender_id] = {
"sender_name": sender_name,
"stuck_phrases": [],
"unanswered_questions": [],
"frustration_phrases": [],
}
store[sender_id][category].append(item)
# ── Public entry point ────────────────────────────────────────────────────────
def analyze_rooms(room_ids: list[str], hours: int = 24) -> AnalysisResult:
"""
Phân tích tín hiệu nguy cơ của học viên trong các room được chỉ định.
Chạy đồng bộ — gọi từ thread pool trong FastAPI endpoint.
"""
store: dict[str, dict] = {}
for room_id in room_ids:
try:
redis_room_id = room_id.removeprefix("room-")
all_msgs = redis_client.get_room_messages(redis_room_id, limit=500)
recent = _filter_recent(all_msgs, hours)
if not recent:
continue
raw = _analyze_room(room_id, recent)
for s in raw.stuck_phrases:
_add(store, s.sender_id, s.sender_name, "stuck_phrases", {
"text": s.text,
"room_id": room_id,
"timestamp": s.timestamp,
"suggested_points": min(max(s.suggested_points, 2), 4),
})
for q in raw.unanswered_questions:
wait = _wait_minutes(q.timestamp, q.sender_id, recent)
pts = _points_from_wait(wait)
if pts == 0:
continue # câu hỏi < 15 phút → bỏ qua
_add(store, q.sender_id, q.sender_name, "unanswered_questions", {
"text": q.text,
"room_id": room_id,
"timestamp": q.timestamp,
"wait_minutes": wait,
"suggested_points": pts,
})
for f in raw.frustration_phrases:
_add(store, f.sender_id, f.sender_name, "frustration_phrases", {
"text": f.text,
"room_id": room_id,
"timestamp": f.timestamp,
"suggested_points": min(max(f.suggested_points, 2), 3),
})
except Exception:
logger.exception("[AtRisk] Lỗi khi phân tích room '%s'", room_id)
students = [
StudentResult(
sender_id=sid,
sender_name=data["sender_name"],
signals=StudentSignals(
stuck_phrases=[SignalItem(**x) for x in data["stuck_phrases"]],
unanswered_questions=[UnansweredQuestion(**x) for x in data["unanswered_questions"]],
frustration_phrases=[SignalItem(**x) for x in data["frustration_phrases"]],
),
)
for sid, data in store.items()
]
return AnalysisResult(
analyzed_at=datetime.now(tz=timezone.utc).isoformat(),
students=students,
)