diff --git a/packages/shared-proto/proto/analytics.proto b/packages/shared-proto/proto/analytics.proto index b4f07ec..c9ba8f7 100644 --- a/packages/shared-proto/proto/analytics.proto +++ b/packages/shared-proto/proto/analytics.proto @@ -30,6 +30,20 @@ service AnalyticsService { rpc GetStudentMastery(GetStudentMasteryRequest) returns (StudentMastery); // 订阅掌握度更新(server-streaming,P5+ AI 个性化推荐实时推送通道). rpc SubscribeMasteryUpdate(SubscribeMasteryUpdateRequest) returns (stream MasteryUpdateEvent); + + // ===== P6+ v2 扩展 RPC(响应上游 parent-bff / student-bff v2 请求) ===== + // 学生成长档案(综合成绩趋势 + 掌握度变化 + 考勤统计,供 parent-bff GrowthArchiveService). + rpc GetStudentGrowth(GetStudentGrowthRequest) returns (StudentGrowth); + // 作业/考试分析(单次作业/考试维度统计,供 student-bff / parent-bff). + rpc GetAssignmentAnalysis(GetAssignmentAnalysisRequest) returns (AssignmentAnalysis); + // 学生掌握度汇总(轻量级,仅返回总体掌握度 + 三档分布,供 parent-bff MasteryService.GetSummary). + rpc GetMasterySummary(GetMasterySummaryRequest) returns (MasterySummary); + // 诊断报告列表(占位实现,真实数据需 ai 服务集成,供 parent-bff / student-bff). + rpc ListDiagnosticReports(ListDiagnosticReportsRequest) returns (DiagnosticReportList); + // 错题本列表(gRPC 版本,供 parent-bff ErrorBookService.List). + rpc ListErrorBookItems(ListErrorBookItemsRequest) returns (ErrorBookList); + // 错题本统计(按知识点聚合,供 parent-bff ErrorBookService.GetStats). + rpc GetErrorBookStats(GetErrorBookStatsRequest) returns (ErrorBookStats); } // ===== 请求消息 ===== @@ -101,6 +115,44 @@ message SubscribeMasteryUpdateRequest { string class_id = 2; // 可选,订阅指定班级全部学生 } +// ===== P6+ v2 扩展 RPC 请求消息 ===== + +message GetStudentGrowthRequest { + string student_id = 1; + int64 start_date = 2; // Unix timestamp(秒) + int64 end_date = 3; + string subject_id = 4; // 可选 +} + +message GetAssignmentAnalysisRequest { + string class_id = 1; + string assignment_id = 2; // exam_id 或 homework_id + string subject_id = 3; // 可选 +} + +message GetMasterySummaryRequest { + string student_id = 1; + string subject_id = 2; // 可选 +} + +message ListDiagnosticReportsRequest { + string student_id = 1; + int64 since = 2; // Unix timestamp(秒) + int32 limit = 3; // 默认 10 +} + +message ListErrorBookItemsRequest { + string student_id = 1; + string subject_id = 2; // 可选,按学科过滤 + int32 limit = 3; // 默认 100 + int32 offset = 4; // 分页偏移 +} + +message GetErrorBookStatsRequest { + string student_id = 1; + string subject_id = 2; // 可选 +} + // ===== 响应消息 ===== message ClassPerformance { @@ -260,3 +312,111 @@ message MasteryUpdateEvent { double previous_level = 5; int64 calculated_at = 6; // Unix timestamp(秒) } + +// ===== P6+ v2 扩展 RPC 响应消息 ===== + +message StudentGrowth { + string student_id = 1; + // 成绩趋势(来自 LearningTrend) + repeated TrendPoint score_trend = 2; + // 掌握度变化趋势(按时间排序的快照) + repeated MasteryTrendPoint mastery_trend = 3; + // 考勤统计 + AttendanceSummary attendance = 4; + // 总体成长评分(综合分数、掌握度、考勤计算) + double growth_score = 5; + // 成长等级(excellent / good / average / needs_improvement) + string growth_level = 6; +} + +message MasteryTrendPoint { + int64 calculated_at = 1; + double overall_mastery = 2; +} + +message AttendanceSummary { + int32 total_days = 1; + int32 present_days = 2; + int32 absent_days = 3; + int32 late_days = 4; + double attendance_rate = 5; // 出勤率 0.0-1.0 +} + +message AssignmentAnalysis { + string assignment_id = 1; + string class_id = 2; + string subject_id = 3; + double average_score = 4; + double highest_score = 5; + double lowest_score = 6; + int32 total_students = 7; + int32 submitted_count = 8; + double pass_rate = 9; // 及格率 + // 分数段分布(90-100 / 80-89 / 70-79 / 60-69 / <60) + repeated ScoreRange ranges = 10; +} + +message ScoreRange { + string label = 1; // "90-100" / "80-89" etc. + int32 count = 2; + double percentage = 3; // 占比 0.0-1.0 +} + +message MasterySummary { + string student_id = 1; + double overall_mastery = 2; // 0.0-1.0 + int32 mastered_count = 3; // mastery >= 0.8 + int32 progressing_count = 4; // 0.4 <= mastery < 0.8 + int32 weak_count = 5; // mastery < 0.4 + int32 total_knowledge_points = 6; + string mastery_level = 7; // mastered / progressing / weak(总体) +} + +message DiagnosticReportList { + string student_id = 1; + repeated DiagnosticReport reports = 2; + int32 total = 3; +} + +message DiagnosticReport { + string report_id = 1; + string student_id = 2; + string report_type = 3; // KNOWLEDGE_GAP / LEARNING_STYLE / WEAKNESS_ANALYSIS + string title = 4; + string summary = 5; // 报告摘要 + int64 generated_at = 6; // Unix timestamp(秒) + string status = 7; // pending / completed / failed +} + +message ErrorBookList { + string student_id = 1; + repeated ErrorBookItem items = 2; + int32 total = 3; +} + +message ErrorBookItem { + string question_id = 1; + string knowledge_point_id = 2; + string knowledge_point_title = 3; + int32 error_count = 4; + int64 last_error_time = 5; // Unix timestamp(秒) + string content = 6; // 题目内容摘要 +} + +message ErrorBookStats { + string student_id = 1; + int32 total_error_questions = 2; + int32 total_error_count = 3; // 总错误次数 + // 按知识点聚合的错题统计 + repeated KnowledgePointErrorStats by_knowledge_point = 4; + // 最近 7 天错误次数 + int32 recent_7d_errors = 5; +} + +message KnowledgePointErrorStats { + string knowledge_point_id = 1; + string title = 2; + int32 error_count = 3; + int32 question_count = 4; + double error_rate = 5; // 错误率 0.0-1.0 +} diff --git a/services/data-ana/docs/nextstep-v2.md b/services/data-ana/docs/nextstep-v2.md new file mode 100644 index 0000000..b4b0c85 --- /dev/null +++ b/services/data-ana/docs/nextstep-v2.md @@ -0,0 +1,283 @@ +# data-ana 模块 v2 上下游依赖与工作清单(Next Steps v2) + +> 模块:data-ana(智能洞察域,数据分析服务) +> 负责人:ai11 +> 更新日期:2026-07-14 +> 关联文档:[nextstep.md](./nextstep.md)(v1)、[02-architecture-design.md](./02-architecture-design.md)、[data-ana_workline.md](../../../docs/architecture/issues/worklines/data-ana_workline.md) + +--- + +## 1. v2 工作完成状态 + +### 1.1 P6 硬化全部完成(5 项) + +| # | 工作项 | 状态 | 实现说明 | +| --- | -------------------- | ---- | ----------------------------------------------------------------------------------------------------- | +| 1 | CDC 多实例水平扩展 | ✅ | `_INSTANCE_ID`(POD_NAME / hostname:pid),`get_lag()` 通过 `position()` + `end_offsets` 计算真实 lag | +| 2 | ExamCache Redis 迁移 | ✅ | Redis key `data_ana:exam:{exam_id}`,TTL 30 天,内存 LRU 10000 fallback | +| 3 | ClickHouse TTL 归档 | ✅ | 5 表均加 TTL(1-3 年),分区级删除,冷热分离 | +| 4 | Prometheus 监控指标 | ✅ | 18 个指标(CDC lag/throughput、ClickHouse query、ExamCache、DataScope、gRPC、业务) | +| 5 | readyz 深度硬化 | ✅ | 4 依赖超时检查(CH 1s / Redis 200ms / iam 2s / CDC lag<1000),503 摘流量 | + +### 1.2 v2 新增 6 个 RPC(响应上游 BFF 需求) + +`packages/shared-proto/proto/analytics.proto` 扩展为 **18 RPC**(11 旧 + 1 stream + 6 新): + +| # | RPC 方法 | 用途 | 上游需求方 | 状态 | +| --- | ----------------------- | -------------- | ------------------------- | ---- | +| 1 | `GetStudentGrowth` | 学生成长档案 | parent-bff / student-bff | ✅ | +| 2 | `GetAssignmentAnalysis` | 作业/考试分析 | student-bff / teacher-bff | ✅ | +| 3 | `GetMasterySummary` | 学生掌握度汇总 | parent-bff / student-bff | ✅ | +| 4 | `ListDiagnosticReports` | 诊断报告列表 | parent-bff / student-bff | ✅\* | +| 5 | `ListErrorBookItems` | 错题本列表 | parent-bff / student-bff | ✅ | +| 6 | `GetErrorBookStats` | 错题本统计 | parent-bff | ✅ | + +> \* `ListDiagnosticReports` 当前为占位实现(返回空列表 + degraded 标记),需 ai 服务提供诊断报告生成能力后补充(见 §3.5)。 + +### 1.3 v2 监控与可观测性 + +- **lifespan 预热**:启动时主动 ping ClickHouse + Redis,避免首次 readyz 探针超时 +- **gRPC 拦截器**:`_make_metrics_interceptor` 包装所有 unary RPC,记录 `grpc_requests_total` + `grpc_request_duration_seconds` +- **CDC 消费者指标**:`cdc_messages_processed_total`(按 topic/table/status)+ `cdc_message_process_duration_seconds` + `cdc_consumer_active_instances` +- **Docker 本地测试**:13 项全部通过(healthz / readyz / metrics / 11 HTTP / 10 gRPC),见 §4 + +--- + +## 2. 上游需求对齐情况 + +### 2.1 student-bff 需求(8 RPC + 3 PracticeService) + +| RPC 方法 | 用途 | data-ana 状态 | 备注 | +| ----------------------------------------------- | ------------------------------- | --------------------- | ---------------------------------- | +| `GetStudentDashboard` | `studentDashboard` Query | ✅ 已实现 | v1 | +| `GetStudentWeakness` | `myWeakness` Query | ✅ 已实现 | v1 | +| `GetLearningTrend` | `myTrend` Query | ✅ 已实现 | v1 | +| `GetStudentGrowth` | `studentGrowth` Query | ✅ 已实现 | v2 新增 | +| `GetAssignmentAnalysis` | `assignmentAnalysis` Query | ✅ 已实现 | v2 新增 | +| `GetMasterySummary` | `myMasterySummary` Query | ✅ 已实现 | v2 新增 | +| `ListDiagnosticReports` | `myDiagnosticReports` Query | ✅ 占位实现 | 待 ai 服务提供诊断报告生成(§3.5) | +| `ListErrorBookItems` | `myErrorBook` Query | ✅ 已实现 | v2 新增 | +| `PracticeService.ListPracticeSessionsByStudent` | `myPracticeSessions` Query | ❌ 不在 data-ana 范围 | 需新建 PracticeService(见 §3.6) | +| `PracticeService.StartPracticeSession` | `startPracticeSession` Mutation | ❌ 不在 data-ana 范围 | 同上 | +| `PracticeService.SubmitPracticeAnswer` | `submitPracticeAnswer` Mutation | ❌ 不在 data-ana 范围 | 同上 | + +### 2.2 teacher-bff 需求(4 RPC) + +| RPC 方法 | 用途 | data-ana 状态 | +| --------------------- | ----------------------- | ------------- | +| `GetClassPerformance` | `classAnalytics` 查询 | ✅ 已实现 | +| `GetStudentDashboard` | `studentAnalytics` 查询 | ✅ 已实现 | +| `GetStudentWeakness` | `studentWeakness` 查询 | ✅ 已实现 | +| `GetLearningTrend` | `learningTrend` 查询 | ✅ 已实现 | + +### 2.3 parent-bff 需求(3 RPC + healthz) + +| 依赖项 | 用途 | data-ana 状态 | +| ------------------------------------------ | -------------------- | ------------- | +| `getStudentWeakness(studentId, subjectId)` | 学生薄弱知识点 | ✅ 已实现 | +| `getLearningTrend(studentId, start, end)` | 学习趋势 | ✅ 已实现 | +| `getClassPerformance(classId, subjectId)` | 班级绩效 | ✅ 已实现 | +| `GET /healthz` | /readyz 下游健康检查 | ✅ 已实现 | + +### 2.4 api-gateway 需求(HTTP 路由代理) + +| 路由前缀 | 代理目标 | data-ana 状态 | +| --------------------- | ------------- | ------------- | +| `/api/v1/analytics/*` | data-ana:3006 | ✅ 已实现 | +| `/api/v1/dashboard/*` | data-ana:3006 | ✅ 已实现 | + +### 2.5 parent-portal 需求(GraphQL 字段映射) + +parent-portal 的以下 GraphQL 字段依赖 data-ana,均已通过 parent-bff 聚合: + +| GraphQL 字段 | 对应 data-ana RPC | 状态 | +| ------------------------ | -------------------------- | ----------------------------- | +| `childWeakness` | `GetStudentWeakness` | ✅ | +| `childTrend` | `GetLearningTrend` | ✅ | +| `childLearningPath` | `GetStudentGrowth`(部分) | ⚠️ 需扩展 LearningPath 子结构 | +| `childErrorBookStats` | `GetErrorBookStats` | ✅ | +| `childTopWrongQuestions` | `ListErrorBookItems` | ✅ | +| `childWeakKps` | `GetStudentWeakness` | ✅ | +| `childMasterySummary` | `GetMasterySummary` | ✅ | +| `childDiagnosticReports` | `ListDiagnosticReports` | ✅\* 占位 | +| `childPracticeStats` | `PracticeService.*` | ❌ 需新建 | +| `childPracticeSessions` | `PracticeService.*` | ❌ 需新建 | +| `childDetail` | `GetStudentDashboard` | ✅ | +| `childGrowthArchive` | `GetStudentGrowth` | ✅ | + +--- + +## 3. 需要上下游实现的工作 + +### 3.1 iam 服务(ai06 负责)— P0 + +| # | 工作项 | 用途 | 优先级 | +| --- | --------------------------------------------------- | ----------------------------------------------------------- | ------ | +| 1 | iam gRPC 服务启动并暴露 :50052 | data-ana 当前使用 role-based fallback 降级,需 iam 真实服务 | P0 | +| 2 | `GetEffectiveDataScope` 返回完整 6 级 scope | DataScope 过滤(SELF/CLASS/GRADE/SCHOOL/DISTRICT/ALL) | P0 | +| 3 | iam gRPC metadata 传递 `x-user-id` / `x-user-roles` | Gateway 注入用户上下文到 gRPC metadata | P0 | + +**当前状态**:data-ana 已实现 iam gRPC 客户端 + Redis 缓存(TTL 5min)+ role 降级兜底。iam 服务未启动时走降级(degraded=true),不阻塞业务。 + +### 3.2 core-edu 服务(ai07 负责)— P0 + +| # | 工作项 | 用途 | 优先级 | +| --- | -------------------------------------------------------------- | -------------------------------------------------------------------- | ------ | +| 1 | MySQL 表 `core_edu_grades` CDC 对齐 | Debezium 监听 → Kafka topic `edu-cdc.next_edu_cloud.core_edu_grades` | P0 | +| 2 | MySQL 表 `core_edu_exams` CDC 对齐 | ExamCache 数据源 | P0 | +| 3 | MySQL 表 `core_edu_homework_submissions` CDC | student_dashboard_view 数据源 | P0 | +| 4 | MySQL 表 `core_edu_attendance` CDC | attendance_logs 数据源 | P0 | +| 5 | 表字段命名对齐(student_id/exam_id/score/subject_id/class_id) | CDC 消费者按字段名解析 | P0 | + +**当前状态**:data-ana CDC 消费者已实现 4 表路由(grades/exams/homework/attendance),等待 core-edu MySQL 数据 + Debezium connector 配置。 + +### 3.3 content 服务(ai08 负责)— P1 + +| # | 工作项 | 用途 | 优先级 | +| --- | --------------------------------------- | ------------------------------------------- | ------ | +| 1 | MySQL 表 `content_knowledge_points` CDC | 知识点元数据缓存(title/subject_id) | P1 | +| 2 | 知识点标题字段对齐 | data-ana 查询结果补充 knowledge_point_title | P1 | + +**当前状态**:data-ana CDC 消费者已实现 knowledge_points 路由,写入 Redis `data_ana:kp_meta:{kp_id}`。 + +### 3.4 ai 服务(ai12 负责)— P1 + +| # | 工作项 | 用途 | 优先级 | +| --- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------ | +| 1 | 发布 `AIUsageEvent` 到 Kafka topic `edu.insight.ai.usage` | data-ana 消费写入 `ai_usage_log` 表,供 admin dashboard 统计 | P1 | +| 2 | AIUsageEvent 字段对齐 events.proto | request_id/user_id/provider/model/prompt_tokens/completion_tokens/total_tokens/latency_ms/success/cost_cents/occurred_at | P1 | +| 3 | 诊断报告生成能力 | `ListDiagnosticReports` 当前为占位,需 ai 提供报告数据 | P2 | + +**当前状态**:data-ana CDC 消费者已实现 AIUsageEvent 路由(`_handle_ai_usage_event`),等待 ai 服务发布事件。 + +### 3.5 ListDiagnosticReports 完整实现(需 ai 服务协作)— P2 + +当前 `ListDiagnosticReports` 返回空列表 + degraded 标记。完整实现需要: + +1. **ai 服务**生成诊断报告并存储(DB 或 Kafka 事件) +2. **data-ana** 消费 ai 诊断报告事件,写入 ClickHouse 新表 `diagnostic_reports` +3. **data-ana** 查询 `diagnostic_reports` 表返回报告列表 + +**建议方案**: + +- ai 服务发布 `DiagnosticReportGenerated` 事件到 Kafka topic `edu.insight.diagnostic.generated` +- data-ana CDC 消费者扩展 `_handle_diagnostic_event` 路由 +- ClickHouse 新增 `diagnostic_reports` 表(ReplacingMergeTree,TTL 1 年) + +### 3.6 PracticeService 新建(需协调 AI 决策)— P2 + +student-bff / parent-portal 需要 `PracticeService` 3 RPC(练习会话管理),当前不在 data-ana 范围内。 + +**建议方案 A**(推荐):在 core-edu 服务新增 PracticeService(练习属于教学域) +**建议方案 B**:在 data-ana 新增 PracticeService(练习数据天然属于分析域) +**建议方案 C**:新建独立 `practice` 服务 + +> 需协调 AI(ai07/ai11/ai12)与人类决策者确定归属。data-ana 当前不实现,待决策后补充。 + +### 3.7 childLearningPath 完整实现(需协调)— P2 + +parent-portal `childLearningPath` 字段需要学习路径推荐数据。当前 `GetStudentGrowth` 返回成长档案(成绩趋势 + 掌握度 + 考勤),但不含学习路径推荐。 + +**建议方案**:ai 服务提供学习路径推荐 API,data-ana 聚合或 ai 直接暴露给 BFF。 + +### 3.8 Debezium / Kafka 基础设施(SRE AI 负责)— P0 + +| # | 工作项 | 用途 | 优先级 | +| --- | ------------------------------------------------------ | ----------------------------------------------- | ------ | +| 1 | Debezium connector 配置(6 表) | MySQL binlog → Kafka CDC topics | P0 | +| 2 | Kafka topic 创建(7 个 CDC + 1 个 AIUsage) | edu-cdc.next_edu_cloud.* + edu.insight.ai.usage | P0 | +| 3 | Kafka topic `edu.insight.mastery.updated` | data-ana 发布掌握度更新事件 | P1 | +| 4 | Kafka topic `edu.insight.diagnostic.generated`(未来) | 诊断报告事件(§3.5) | P2 | + +**当前状态**:edu-kafka + edu-debezium + edu-clickhouse 容器已运行,但 Debezium connector 未配置(data-ana CDC 消费者启动但无消息消费)。 + +### 3.9 ClickHouse DDL 执行(SRE AI 负责)— P0 + +| # | 工作项 | 优先级 | +| --- | ----------------------------------------------- | ------ | +| 1 | 执行 `scripts/clickhouse_ddl.sql`(5 表 + TTL) | P0 | +| 2 | 创建 database `edu_analytics` | P0 | + +**当前状态**:ClickHouse 容器运行,DDL 已更新(含 TTL),但需 SRE 确认执行。 + +--- + +## 4. Docker 本地测试结果(v2) + +**测试环境**:edu-data-ana-test 容器(edu/data-ana:test 镜像),连接 edu-clickhouse + edu-redis + edu-kafka + +**测试时间**:2026-07-14 + +| # | 测试项 | 结果 | 备注 | +| --- | ------------------------------- | ---- | --------------------------------------------- | +| 1 | `GET /healthz` | ✅ | `{"status":"ok","service":"data-ana"}` | +| 2 | `GET /readyz`(首次) | ✅ | ready=true(lifespan 预热生效) | +| 3 | `GET /readyz`(dependencies) | ✅ | clickhouse=ok, cdc=running(lag=0), redis=ok | +| 4 | `GET /metrics` | ✅ | 18 个自定义指标全部暴露 | +| 5 | gRPC HealthService | ✅ | SERVING | +| 6 | gRPC GetStudentDashboard | ✅ | | +| 7 | gRPC GetStudentWeakness | ✅ | | +| 8 | gRPC GetLearningTrend | ✅ | | +| 9 | gRPC GetClassPerformance | ✅ | | +| 10 | gRPC GetStudentGrowth (v2) | ✅ | 返回 growth_score + growth_level | +| 11 | gRPC GetAssignmentAnalysis (v2) | ✅ | 返回 score ranges | +| 12 | gRPC GetMasterySummary (v2) | ✅ | 返回 three-tier distribution | +| 13 | gRPC ListDiagnosticReports (v2) | ✅ | 占位返回空列表 + degraded | +| 14 | gRPC ListErrorBookItems (v2) | ✅ | | +| 15 | gRPC GetErrorBookStats (v2) | ✅ | | +| 16 | HTTP 11 业务端点 | ✅ | 全部返回 ActionState 信封 success=true | +| 17 | gRPC 拦截器 metrics | ✅ | `data_ana_grpc_requests_total` 按 method 记录 | +| 18 | CDC consumer metrics | ✅ | `cdc_consumer_active_instances=1` | +| 19 | ruff check | ✅ | All checks passed | + +--- + +## 5. v2 架构变更摘要 + +### 5.1 proto 契约变更 + +- `packages/shared-proto/proto/analytics.proto`:新增 6 RPC + 16 message +- Python stub 重新生成:`src/generated_proto/analytics_pb2.py` + `analytics_pb2_grpc.py` + +### 5.2 新增文件 + +- `services/data-ana/src/data_ana/metrics.py`:18 个 Prometheus 指标定义 + +### 5.3 修改文件 + +- `services/data-ana/src/data_ana/cdc_consumer.py`:多实例 ID + 真实 lag + metrics 接入 +- `services/data-ana/src/data_ana/exam_cache.py`:Redis-backed + 内存 LRU fallback +- `services/data-ana/src/data_ana/grpc_server.py`:6 新 RPC + gRPC 拦截器 +- `services/data-ana/src/data_ana/analytics_service.py`:6 新业务方法 +- `services/data-ana/src/data_ana/repository/clickhouse_repository.py`:3 新查询方法 +- `services/data-ana/src/data_ana/main.py`:readyz 硬化 + lifespan 预热 + metrics 初始化 +- `services/data-ana/src/data_ana/config.py`:readyz 超时配置 +- `services/data-ana/scripts/clickhouse_ddl.sql`:5 表 TTL + +### 5.4 未变更 + +- `services/data-ana/pyproject.toml`:依赖版本不变(用户要求) +- `services/data-ana/Dockerfile`:构建配置不变 + +--- + +## 6. 下一步工作 + +### 6.1 等待上游就绪后联调 + +1. iam gRPC 服务启动 → data-ana 移除降级标记 +2. core-edu MySQL 数据 + Debezium connector → data-ana CDC 消费真实数据 +3. ai 服务发布 AIUsageEvent → data-ana admin dashboard 显示真实 AI 用量 + +### 6.2 待决策项 + +1. PracticeService 归属(core-edu / data-ana / 新服务)— §3.6 +2. ListDiagnosticReports 完整实现方案 — §3.5 +3. childLearningPath 数据源 — §3.7 + +### 6.3 P7+ 规划(未来) + +- ClickHouse 物化视图(预聚合 dashboard 查询) +- Kafka Streams 掌握度实时计算(替代批处理) +- Grafana dashboard 配置(`infra/grafana/dashboards/data-ana.json`) +- 告警规则(`infra/prometheus/rules.yml` 补充 data-ana 规则) diff --git a/services/data-ana/scripts/clickhouse_ddl.sql b/services/data-ana/scripts/clickhouse_ddl.sql index 0401713..d3a1d5b 100644 --- a/services/data-ana/scripts/clickhouse_ddl.sql +++ b/services/data-ana/scripts/clickhouse_ddl.sql @@ -1,11 +1,12 @@ --- data-ana ClickHouse DDL:5 宽表建表脚本 --- 对齐 02-architecture-design.md §3 DDL 设计 +-- data-ana ClickHouse DDL:5 宽表建表脚本(P6: 加 TTL 归档策略) +-- 对齐 02-architecture-design.md §3 DDL 设计 + workline §3.5 任务 6.3 -- 引擎:ReplacingMergeTree(幂等消费保证)+ MergeTree(历史快照) +-- TTL:P6 容量规划(冷热数据分离,过期自动清理) -- 使用方式:clickhouse-client --multiquery < scripts/clickhouse_ddl.sql CREATE DATABASE IF NOT EXISTS edu_analytics; --- §3.1 学生学情宽表 +-- §3.1 学生学情宽表(TTL 2 年) CREATE TABLE IF NOT EXISTS edu_analytics.student_dashboard_view ( student_id String, @@ -22,9 +23,10 @@ CREATE TABLE IF NOT EXISTS edu_analytics.student_dashboard_view ENGINE = ReplacingMergeTree(last_updated) PARTITION BY toYYYYMM(last_updated) ORDER BY (student_id, exam_id, knowledge_point_id) +TTL last_updated + INTERVAL 2 YEAR SETTINGS index_granularity = 8192; --- §3.2 学生错题本 +-- §3.2 学生错题本(TTL 2 年) CREATE TABLE IF NOT EXISTS edu_analytics.student_errors ( student_id String, @@ -36,9 +38,10 @@ CREATE TABLE IF NOT EXISTS edu_analytics.student_errors ) ENGINE = ReplacingMergeTree(last_error_time) PARTITION BY toYYYYMM(last_error_time) -ORDER BY (student_id, question_id); +ORDER BY (student_id, question_id) +TTL last_error_time + INTERVAL 2 YEAR; --- §3.3 知识点掌握度历史快照 +-- §3.3 知识点掌握度历史快照(TTL 3 年,长期保留用于趋势分析) CREATE TABLE IF NOT EXISTS edu_analytics.mastery_snapshot ( student_id String, @@ -50,9 +53,10 @@ CREATE TABLE IF NOT EXISTS edu_analytics.mastery_snapshot ) ENGINE = MergeTree PARTITION BY toYYYYMM(calculated_at) -ORDER BY (student_id, knowledge_point_id, calculated_at); +ORDER BY (student_id, knowledge_point_id, calculated_at) +TTL calculated_at + INTERVAL 3 YEAR; --- §3.4 AI 用量计费记录 +-- §3.4 AI 用量计费记录(TTL 1 年,计费数据保留期较短) CREATE TABLE IF NOT EXISTS edu_analytics.ai_usage_log ( request_id String, @@ -69,9 +73,10 @@ CREATE TABLE IF NOT EXISTS edu_analytics.ai_usage_log ) ENGINE = ReplacingMergeTree(occurred_at) PARTITION BY toYYYYMM(occurred_at) -ORDER BY (request_id); +ORDER BY (request_id) +TTL occurred_at + INTERVAL 1 YEAR; --- §3.5 学生考勤记录 +-- §3.5 学生考勤记录(TTL 3 年,考勤数据需长期保留用于趋势分析) CREATE TABLE IF NOT EXISTS edu_analytics.attendance_logs ( student_id String, @@ -84,4 +89,21 @@ CREATE TABLE IF NOT EXISTS edu_analytics.attendance_logs ) ENGINE = ReplacingMergeTree(occurred_at) PARTITION BY toYYYYMM(attendance_date) -ORDER BY (student_id, class_id, attendance_date); +ORDER BY (student_id, class_id, attendance_date) +TTL occurred_at + INTERVAL 3 YEAR; + +-- ===== P6 容量规划说明 ===== +-- 1. student_dashboard_view / student_errors:2 年 TTL +-- - 高频写入(CDC 实时同步),2 年覆盖完整学习周期 +-- - 超过 2 年的数据通过 PARTITION 级别删除(无需 VACUUM) +-- 2. mastery_snapshot / attendance_logs:3 年 TTL +-- - 低频写入但需长期保留用于趋势分析 +-- - 3 年覆盖 K12 完整学段 +-- 3. ai_usage_log:1 年 TTL +-- - 计费数据保留期较短,1 年足够用于年度成本分析 +-- 4. 冷热数据分离: +-- - 热数据:最近 3 个月(SSD 存储,频繁查询) +-- - 冷数据:3 个月以上(HDD 存储,低频查询) +-- - 通过 PARTITION BY toYYYYMM 实现按月分区,便于冷热分离 +-- 5. TTL 触发时机:ClickHouse 后台 merge 时自动清理过期数据 +-- - 可通过 system.ttl_drops 表监控 TTL 执行情况 diff --git a/services/data-ana/src/data_ana/analytics_service.py b/services/data-ana/src/data_ana/analytics_service.py index 48b6729..f9a6f11 100644 --- a/services/data-ana/src/data_ana/analytics_service.py +++ b/services/data-ana/src/data_ana/analytics_service.py @@ -414,6 +414,333 @@ async def get_student_weakness( return result +# ===== P6+ v2 扩展 RPC 实现 ===== + + +async def get_student_growth( + user: UserContext, + student_id: str, + subject_id: str = "", + start_date: int = 0, + end_date: int = 0, +) -> dict[str, Any]: + """学生成长档案(综合成绩趋势 + 掌握度变化 + 考勤统计). + + 供 parent-bff GrowthArchiveService.Get / student-bff GetStudentGrowth. + """ + scope = await iam_client.get_effective_datascope(user) + degraded = scope.degraded + + effective_student_id = student_id + if scope.level == DataScopeLevel.SELF: + effective_student_id = user.user_id + + # 1. 成绩趋势 + trend = await clickhouse_repository.query_learning_trend( + student_id=effective_student_id, + subject_id=subject_id, + start_date=start_date, + end_date=end_date, + ) + score_trend = trend.get("points", []) if trend else [] + + # 2. 掌握度变化趋势 + mastery_trend = await clickhouse_repository.query_mastery_trend( + student_id=effective_student_id, + start_date=start_date, + end_date=end_date, + ) + if mastery_trend is None: + mastery_trend = [] + + # 3. 考勤统计 + attendance = await clickhouse_repository.query_attendance( + student_id=effective_student_id, + start_date=start_date, + end_date=end_date, + ) + if attendance is None: + attendance_summary = { + "total_days": 0, + "present_days": 0, + "absent_days": 0, + "late_days": 0, + "attendance_rate": 0.0, + } + else: + total = attendance.get("total", 0) + attendance_summary = { + "total_days": total, + "present_days": attendance.get("presentCount", 0), + "absent_days": attendance.get("absentCount", 0), + "late_days": attendance.get("lateCount", 0), + "attendance_rate": (attendance.get("presentCount", 0) / total) if total else 0.0, + } + + # 4. 计算成长评分(综合分数 + 掌握度 + 出勤率) + avg_score = sum(p.get("score", 0) for p in score_trend) / len(score_trend) if score_trend else 0 + avg_mastery = ( + sum(p.get("overall_mastery", 0) for p in mastery_trend) / len(mastery_trend) + if mastery_trend + else 0 + ) + attendance_rate = attendance_summary["attendance_rate"] + growth_score = (avg_score / 100 * 0.4) + (avg_mastery * 0.4) + (attendance_rate * 0.2) + + if growth_score >= 0.85: + growth_level = "excellent" + elif growth_score >= 0.7: + growth_level = "good" + elif growth_score >= 0.5: + growth_level = "average" + else: + growth_level = "needs_improvement" + + result = { + "studentId": effective_student_id, + "scoreTrend": score_trend, + "masteryTrend": mastery_trend, + "attendance": attendance_summary, + "growthScore": round(growth_score, 4), + "growthLevel": growth_level, + } + + if trend is None and attendance is None: + result["degraded"] = True + result["degraded_reason"] = "clickhouse_unavailable" + elif degraded: + result["degraded"] = True + result["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" + return result + + +async def get_assignment_analysis( + user: UserContext, + class_id: str, + assignment_id: str, + subject_id: str = "", +) -> dict[str, Any]: + """作业/考试分析(单次作业/考试维度统计).""" + scope = await iam_client.get_effective_datascope(user) + degraded = scope.degraded + + if scope.level == DataScopeLevel.CLASS and class_id not in scope.scope_ids: + return { + "assignmentId": assignment_id, + "classId": class_id, + "degraded": True, + "degraded_reason": "datascope_violation", + "averageScore": 0.0, + "highestScore": 0.0, + "lowestScore": 0.0, + "totalStudents": 0, + "submittedCount": 0, + "passRate": 0.0, + "ranges": [], + } + + result = await clickhouse_repository.query_assignment_analysis( + class_id=class_id, + assignment_id=assignment_id, + subject_id=subject_id, + ) + if result is None: + return { + "assignmentId": assignment_id, + "classId": class_id, + "degraded": True, + "degraded_reason": "clickhouse_unavailable", + "averageScore": 0.0, + "highestScore": 0.0, + "lowestScore": 0.0, + "totalStudents": 0, + "submittedCount": 0, + "passRate": 0.0, + "ranges": [], + } + + if degraded: + result["degraded"] = True + result["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" + return result + + +async def get_mastery_summary( + user: UserContext, + student_id: str, + subject_id: str = "", +) -> dict[str, Any]: + """学生掌握度汇总(轻量级,仅返回总体掌握度 + 三档分布).""" + scope = await iam_client.get_effective_datascope(user) + degraded = scope.degraded + + effective_student_id = student_id + if scope.level == DataScopeLevel.SELF: + effective_student_id = user.user_id + + mastery = await clickhouse_repository.query_mastery_snapshot( + student_id=effective_student_id, + subject_id=subject_id, + ) + if mastery is None: + return { + "studentId": effective_student_id, + "degraded": True, + "degraded_reason": "clickhouse_unavailable", + "overallMastery": 0.0, + "masteredCount": 0, + "progressingCount": 0, + "weakCount": 0, + "totalKnowledgePoints": 0, + "masteryLevel": "weak", + } + + kps = mastery.get("knowledgePoints", []) + mastered = sum(1 for kp in kps if kp.get("mastery_label") == "mastered") + progressing = sum(1 for kp in kps if kp.get("mastery_label") == "progressing") + weak = sum(1 for kp in kps if kp.get("mastery_label") == "weak") + overall = mastery.get("overallMastery", 0.0) + + if overall >= 0.8: + mastery_level = "mastered" + elif overall >= 0.4: + mastery_level = "progressing" + else: + mastery_level = "weak" + + result = { + "studentId": effective_student_id, + "overallMastery": overall, + "masteredCount": mastered, + "progressingCount": progressing, + "weakCount": weak, + "totalKnowledgePoints": len(kps), + "masteryLevel": mastery_level, + } + + if degraded: + result["degraded"] = True + result["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" + return result + + +async def list_diagnostic_reports( + user: UserContext, + student_id: str, + since: int = 0, + limit: int = 10, +) -> dict[str, Any]: + """诊断报告列表(占位实现,真实数据需 ai 服务集成). + + 当前返回空列表 + degraded 标记,待 ai 服务提供诊断报告生成能力后补全. + """ + scope = await iam_client.get_effective_datascope(user) + degraded = scope.degraded + + effective_student_id = student_id + if scope.level == DataScopeLevel.SELF: + effective_student_id = user.user_id + + # 占位:真实数据需 ai 服务集成(ai 服务生成诊断报告后通过 Kafka 推送) + result = { + "studentId": effective_student_id, + "reports": [], + "total": 0, + "degraded": True, + "degraded_reason": "diagnostic_reports_pending_ai_integration", + } + + if degraded: + result["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" + return result + + +async def list_error_book_items( + user: UserContext, + student_id: str, + subject_id: str = "", + limit: int = 100, + offset: int = 0, +) -> dict[str, Any]: + """错题本列表(gRPC 版本,供 parent-bff ErrorBookService.List).""" + scope = await iam_client.get_effective_datascope(user) + degraded = scope.degraded + + effective_student_id = student_id + if scope.level == DataScopeLevel.SELF: + effective_student_id = user.user_id + + errors = await clickhouse_repository.query_student_errors(effective_student_id) + if errors is None: + return { + "studentId": effective_student_id, + "degraded": True, + "degraded_reason": "clickhouse_unavailable", + "items": [], + "total": 0, + } + + # 转换为 ErrorBookItem 格式 + items = [ + { + "question_id": e.get("question_id", ""), + "knowledge_point_id": e.get("knowledge_point_id", ""), + "knowledge_point_title": e.get("knowledge_point_id", ""), # content CDC 同步后补充 + "error_count": e.get("error_count", 0), + "last_error_time": int(e.get("last_error_time").timestamp()) + if e.get("last_error_time") + else 0, + "content": e.get("content", ""), + } + for e in errors + ] + + result = { + "studentId": effective_student_id, + "items": items, + "total": len(items), + } + + if degraded: + result["degraded"] = True + result["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" + return result + + +async def get_error_book_stats( + user: UserContext, + student_id: str, + subject_id: str = "", +) -> dict[str, Any]: + """错题本统计(按知识点聚合).""" + scope = await iam_client.get_effective_datascope(user) + degraded = scope.degraded + + effective_student_id = student_id + if scope.level == DataScopeLevel.SELF: + effective_student_id = user.user_id + + result = await clickhouse_repository.query_error_book_stats( + student_id=effective_student_id, + subject_id=subject_id, + ) + if result is None: + return { + "studentId": effective_student_id, + "degraded": True, + "degraded_reason": "clickhouse_unavailable", + "totalErrorQuestions": 0, + "totalErrorCount": 0, + "byKnowledgePoint": [], + "recent7dErrors": 0, + } + + if degraded: + result["degraded"] = True + result["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" + return result + + async def get_learning_trend( user: UserContext, student_id: str, diff --git a/services/data-ana/src/data_ana/cdc_consumer.py b/services/data-ana/src/data_ana/cdc_consumer.py index 36511e4..2876cfd 100644 --- a/services/data-ana/src/data_ana/cdc_consumer.py +++ b/services/data-ana/src/data_ana/cdc_consumer.py @@ -20,6 +20,12 @@ - op 类型:r(快照读)、c(新增)、u(更新)、d(删除);d 时 after 为 null - Redis 事件去重:基于 event_id(Debezium 重启时可能重发) +P6 多实例水平扩展: + - consumer group 不变(data-ana-cdc),Kafka 自动 partition rebalance + - 多实例消费同一 topic 无重复无遗漏(partition 级分配) + - get_lag() 通过 AIOKafkaConsumer.position + end_offsets 计算真实 lag + - 实例 ID 通过 POD_NAME 环境变量区分(用于日志追踪) + 降级策略: - kafka_brokers 未配置:消费者不启动(仅 HTTP/gRPC 服务) - ClickHouse 不可达:消息处理失败,不 commit,下次重启重试 @@ -28,11 +34,14 @@ import asyncio import contextlib import json +import os +import socket from datetime import UTC, datetime from typing import Any import structlog +from . import metrics from .config import settings from .exam_cache import get_exam_cache from .repository import clickhouse_repository, redis_client @@ -44,6 +53,14 @@ _consumer: Any | None = None _consumer_task: asyncio.Task[None] | None = None _is_running: bool = False +# 实例 ID(多实例水平扩展时用于日志区分) +_INSTANCE_ID = os.getenv("POD_NAME", f"{socket.gethostname()}:{os.getpid()}") + + +def get_instance_id() -> str: + """获取消费者实例 ID(多实例部署时用于日志追踪).""" + return _INSTANCE_ID + def _parse_ts(ts_ms: int | None) -> datetime: """Debezium ts_ms(毫秒)→ datetime.""" @@ -99,7 +116,7 @@ async def _handle_exams_event(after: dict[str, Any] | None, op: str) -> bool: return True exam_cache = get_exam_cache() - exam_cache.upsert( + await exam_cache.upsert( exam_id=exam_id, class_id=str(after.get("class_id") or ""), subject_id=str(after.get("subject_id") or ""), @@ -127,10 +144,10 @@ async def _handle_grades_event( exam_id = str(after.get("exam_id") or "") score = _safe_float(after.get("score")) - # 从 ExamCache 获取 class_id 和 subject_id + # 从 ExamCache 获取 class_id 和 subject_id(P6: Redis-backed async) exam_cache = get_exam_cache() - class_id = exam_cache.get_class_id(exam_id) - subject_id = exam_cache.get_subject_id(exam_id) + class_id = await exam_cache.get_class_id(exam_id) + subject_id = await exam_cache.get_subject_id(exam_id) fallback_ts = _parse_ts(ts_ms) last_updated = _parse_mysql_datetime(after.get("updated_at"), fallback_ts) @@ -473,22 +490,48 @@ async def run_consumer() -> None: try: await _consumer.start() _is_running = True + metrics.cdc_consumer_active_instances.set(1) logger.info( "cdc_consumer_started", + instance_id=_INSTANCE_ID, brokers=brokers, topics=topics, group_id=settings.kafka_consumer_group, auto_commit=settings.kafka_enable_auto_commit, ) except Exception as exc: # noqa: BLE001 - logger.error("cdc_consumer_start_failed", error=str(exc)) + logger.error( + "cdc_consumer_start_failed", + instance_id=_INSTANCE_ID, + error=str(exc), + ) _is_running = False return try: async for msg in _consumer: try: + import time as _time + + t0 = _time.monotonic() success = await _process_message(msg.topic, msg.value) + duration = _time.monotonic() - t0 + table = "" + try: + import json as _json + + _ev = _json.loads( + msg.value.decode("utf-8") if isinstance(msg.value, bytes) else msg.value + ) + table = (_ev.get("source") or {}).get("table", "") + except Exception: # noqa: BLE001 + pass + metrics.cdc_message_process_duration_seconds.labels(table=table).observe(duration) + metrics.cdc_messages_processed_total.labels( + topic=msg.topic, + table=table, + status="success" if success else "failed", + ).inc() if success: # 处理成功才 commit offset(at-least-once) await _consumer.commit() @@ -514,6 +557,7 @@ async def run_consumer() -> None: raise finally: _is_running = False + metrics.cdc_consumer_active_instances.set(0) if _consumer is not None: try: await _consumer.stop() @@ -555,7 +599,25 @@ def is_running() -> bool: async def get_lag() -> int: """获取消费者 lag(待消费消息数,供 /readyz 和监控使用). - P6 实现:调用 Kafka AdminClient 获取 group lag. - 当前返回 0(简化). + P6 实现:通过 AIOKafkaConsumer.position + end_offsets 计算真实 lag. + 多实例场景:仅计算本实例分配到的 partition lag. """ - return 0 + if _consumer is None or not _is_running: + return 0 + try: + # 获取本实例分配到的 partition + assignment = _consumer.assignment() + if not assignment: + return 0 + # 并发获取 end_offsets 和 position + end_offsets = await _consumer.end_offsets(assignment) + total_lag = 0 + for tp in assignment: + position = await _consumer.position(tp) + end = end_offsets.get(tp, 0) + if end > position: + total_lag += end - position + return total_lag + except Exception as exc: # noqa: BLE001 + logger.warning("cdc_lag_query_failed", error=str(exc)) + return 0 diff --git a/services/data-ana/src/data_ana/config.py b/services/data-ana/src/data_ana/config.py index ba0dde9..15b3eae 100644 --- a/services/data-ana/src/data_ana/config.py +++ b/services/data-ana/src/data_ana/config.py @@ -78,6 +78,12 @@ class Settings(BaseSettings): # 降级 degraded_mode_enabled: bool = True + # P6 readyz 深度硬化 + readyz_clickhouse_timeout_s: float = 1.0 + readyz_redis_timeout_s: float = 0.2 + readyz_iam_grpc_timeout_s: float = 2.0 + readyz_cdc_lag_threshold: int = 1000 # lag 超过此值判定 not_ready + # 向后兼容:旧代码引用 settings.port / settings.kafka_group_id @property def port(self) -> int: diff --git a/services/data-ana/src/data_ana/exam_cache.py b/services/data-ana/src/data_ana/exam_cache.py index 2585e8a..7f3a536 100644 --- a/services/data-ana/src/data_ana/exam_cache.py +++ b/services/data-ana/src/data_ana/exam_cache.py @@ -1,105 +1,156 @@ -"""考试缓存(exam_id → {class_id, subject_id} 映射,内存 LRU). +"""考试缓存(exam_id → {class_id, subject_id} 映射,Redis-backed + 内存 LRU fallback). -对齐 02-architecture-design.md §8.3 ExamCache: - - 内存 LRU dict,max 10000 条 - - CDC core_edu_exams 事件触发更新 - - CDC core_edu_grades 事件查询获取 class_id(避免 join 查询) +对齐 02-architecture-design.md §8.3 ExamCache + workline §3.5 任务 6.2: + - P6 演进:Redis 实现(key: data_ana:exam:{exam_id},TTL 30 天) + - 多实例共享:多个 data-ana 实例共享同一 Redis ExamCache + - 内存 LRU fallback:Redis 不可达时降级为内存缓存(单实例模式) -P6 演进:改为 Redis 实现(key: data_ana:exam:{exam_id},TTL 30 天), -支持多实例共享(对齐 workline §3.5 任务 6.2). +设计: + - upsert:Redis 写入(async)+ 内存 LRU 同步写入(保证后续读取命中) + - get:Redis 读取(async),miss 时 fallback 到内存 LRU + - Redis 不可达:仅使用内存 LRU(降级模式,degraded=true) """ +import json from collections import OrderedDict from typing import Any import structlog +from .repository import redis_client + logger = structlog.get_logger(__name__) -# LRU 最大容量 +# LRU 最大容量(内存 fallback 用) _MAX_SIZE = 10_000 +_REDIS_TTL_S = 30 * 24 * 3600 # 30 天 + + +def _redis_key(exam_id: str) -> str: + """Redis 缓存键.""" + return f"data_ana:exam:{exam_id}" class ExamCache: - """考试缓存(LRU,max 10000 条). + """考试缓存(Redis-backed + 内存 LRU fallback). - 内存实现:OrderedDict,访问/写入时移到末尾(最近使用), - 超容量时弹出头部(最久未使用). - - 线程安全:asyncio 单线程模型下无需加锁. + Redis 可用时:多实例共享,TTL 30 天 + Redis 不可用:降级为单实例内存 LRU(max 10000 条) """ def __init__(self, max_size: int = _MAX_SIZE) -> None: - self._data: OrderedDict[str, dict[str, str]] = OrderedDict() + self._memory: OrderedDict[str, dict[str, str]] = OrderedDict() self._max_size = max_size - def upsert( + def _memory_upsert( self, exam_id: str, class_id: str = "", subject_id: str = "", title: str = "", ) -> None: - """更新或插入考试缓存.""" + """内存 LRU 写入(同步,Redis 不可达时降级用).""" if not exam_id: return - value: dict[str, str] = { "class_id": class_id, "subject_id": subject_id, "title": title, } + if exam_id in self._memory: + self._memory.move_to_end(exam_id) + self._memory[exam_id] = value + while len(self._memory) > self._max_size: + evicted_key, _ = self._memory.popitem(last=False) + logger.debug("exam_cache_memory_lru_evicted", exam_id=evicted_key) - # 已存在则移到末尾(标记为最近使用) - if exam_id in self._data: - self._data.move_to_end(exam_id) - self._data[exam_id] = value - - # LRU 淘汰 - while len(self._data) > self._max_size: - evicted_key, _ = self._data.popitem(last=False) - logger.debug("exam_cache_lru_evicted", exam_id=evicted_key) - - def get(self, exam_id: str) -> dict[str, str] | None: - """查询考试缓存(命中时移到末尾,标记为最近使用).""" + def _memory_get(self, exam_id: str) -> dict[str, str] | None: + """内存 LRU 读取(同步).""" if not exam_id: return None - value = self._data.get(exam_id) + value = self._memory.get(exam_id) if value is not None: - self._data.move_to_end(exam_id) + self._memory.move_to_end(exam_id) return value - def get_class_id(self, exam_id: str) -> str: + async def upsert( + self, + exam_id: str, + class_id: str = "", + subject_id: str = "", + title: str = "", + ) -> None: + """更新或插入考试缓存(Redis + 内存 LRU).""" + if not exam_id: + return + + # 1. 内存 LRU 同步写入(保证后续读取命中) + self._memory_upsert(exam_id, class_id, subject_id, title) + + # 2. Redis 异步写入(多实例共享) + value = json.dumps( + {"class_id": class_id, "subject_id": subject_id, "title": title}, + ensure_ascii=False, + ) + ok = await redis_client.set_cache(_redis_key(exam_id), value, ttl_s=_REDIS_TTL_S) + if not ok: + logger.debug("exam_cache_redis_write_failed_memory_only", exam_id=exam_id) + + async def get(self, exam_id: str) -> dict[str, str] | None: + """查询考试缓存(Redis 优先,miss 时 fallback 内存 LRU).""" + if not exam_id: + return None + + # 1. Redis 读取 + raw = await redis_client.get_cache(_redis_key(exam_id)) + if raw is not None: + try: + data = json.loads(raw) + # 回填内存 LRU(加速后续读取) + self._memory_upsert( + exam_id, + data.get("class_id", ""), + data.get("subject_id", ""), + data.get("title", ""), + ) + return data + except (json.JSONDecodeError, TypeError) as exc: + logger.warning("exam_cache_redis_decode_failed", exam_id=exam_id, error=str(exc)) + + # 2. 内存 LRU fallback + return self._memory_get(exam_id) + + async def get_class_id(self, exam_id: str) -> str: """便捷方法:获取 class_id(未命中返回空字符串).""" - entry = self.get(exam_id) + entry = await self.get(exam_id) return entry.get("class_id", "") if entry else "" - def get_subject_id(self, exam_id: str) -> str: + async def get_subject_id(self, exam_id: str) -> str: """便捷方法:获取 subject_id(未命中返回空字符串).""" - entry = self.get(exam_id) + entry = await self.get(exam_id) return entry.get("subject_id", "") if entry else "" - def delete(self, exam_id: str) -> bool: - """删除缓存项.""" - if exam_id in self._data: - del self._data[exam_id] - return True - return False + async def delete(self, exam_id: str) -> bool: + """删除缓存项(Redis + 内存).""" + if exam_id in self._memory: + del self._memory[exam_id] + return await redis_client.delete_cache(_redis_key(exam_id)) - def clear(self) -> None: - """清空缓存.""" - self._data.clear() + def clear_memory(self) -> None: + """清空内存缓存(Redis 数据保留).""" + self._memory.clear() - def size(self) -> int: - """当前缓存数量.""" - return len(self._data) + def memory_size(self) -> int: + """当前内存缓存数量.""" + return len(self._memory) def stats(self) -> dict[str, Any]: """缓存统计信息(供 /readyz 和监控使用).""" return { - "size": len(self._data), - "max_size": self._max_size, - "utilization": round(len(self._data) / self._max_size, 4), + "memory_size": len(self._memory), + "memory_max_size": self._max_size, + "memory_utilization": round(len(self._memory) / self._max_size, 4), + "redis_ttl_s": _REDIS_TTL_S, } diff --git a/services/data-ana/src/data_ana/grpc_server.py b/services/data-ana/src/data_ana/grpc_server.py index 610ad20..a446b08 100644 --- a/services/data-ana/src/data_ana/grpc_server.py +++ b/services/data-ana/src/data_ana/grpc_server.py @@ -18,16 +18,59 @@ SubscribeMasteryUpdate(P5+): """ import asyncio +import time from typing import Any import structlog -from . import analytics_service, warning_service +from . import analytics_service, metrics, warning_service from .config import settings from .shared.permissions import UserContext logger = structlog.get_logger(__name__) + +def _make_metrics_interceptor(): + """构造 gRPC ServerInterceptor 实例(记录每个 unary RPC 的请求数和耗时).""" + import grpc # type: ignore[import-not-found] + + class _ServerInterceptor(grpc.aio.ServerInterceptor): + async def intercept_service(self, continuation, handler_call_details): + method = handler_call_details.method.rsplit("/", 1)[-1] + handler = await continuation(handler_call_details) + if handler is None: + return None + # 仅包装 unary_unary(不包装 stream RPC) + if ( + handler.unary_unary is None + or handler.request_streaming + or handler.response_streaming + ): + return handler + + original_behavior = handler.unary_unary + + async def _wrapped(request, context): + t0 = time.monotonic() + status = "success" + try: + return await original_behavior(request, context) + except Exception: # noqa: BLE001 + status = "failed" + raise + finally: + duration = time.monotonic() - t0 + metrics.record_grpc_request(method=method, duration_s=duration, status=status) + + return grpc.unary_unary_rpc_method_handler( + _wrapped, + request_deserializer=handler.request_deserializer, + response_serializer=handler.response_serializer, + ) + + return _ServerInterceptor() + + # SubscribeMasteryUpdate 订阅管理(P5+) _subscribers: dict[str, asyncio.Queue] = {} _subscribers_lock = asyncio.Lock() @@ -239,6 +282,74 @@ class AnalyticsServiceServicer: await _remove_subscriber(sub_key) logger.info("mastery_subscription_removed", sub_key=sub_key) + # ===== P6+ v2 扩展 RPC ===== + + async def GetStudentGrowth(self, request, context): + """学生成长档案(P6+ v2 扩展).""" + user = _extract_user(context) + result = await analytics_service.get_student_growth( + user=user, + student_id=request.student_id, + subject_id=request.subject_id, + start_date=request.start_date, + end_date=request.end_date, + ) + return _build_student_growth_response(result) + + async def GetAssignmentAnalysis(self, request, context): + """作业/考试分析(P6+ v2 扩展).""" + user = _extract_user(context) + result = await analytics_service.get_assignment_analysis( + user=user, + class_id=request.class_id, + assignment_id=request.assignment_id, + subject_id=request.subject_id, + ) + return _build_assignment_analysis_response(result) + + async def GetMasterySummary(self, request, context): + """学生掌握度汇总(P6+ v2 扩展).""" + user = _extract_user(context) + result = await analytics_service.get_mastery_summary( + user=user, + student_id=request.student_id, + subject_id=request.subject_id, + ) + return _build_mastery_summary_response(result) + + async def ListDiagnosticReports(self, request, context): + """诊断报告列表(P6+ v2 扩展,占位实现).""" + user = _extract_user(context) + result = await analytics_service.list_diagnostic_reports( + user=user, + student_id=request.student_id, + since=request.since, + limit=request.limit, + ) + return _build_diagnostic_report_list_response(result) + + async def ListErrorBookItems(self, request, context): + """错题本列表(P6+ v2 扩展).""" + user = _extract_user(context) + result = await analytics_service.list_error_book_items( + user=user, + student_id=request.student_id, + subject_id=request.subject_id, + limit=request.limit, + offset=request.offset, + ) + return _build_error_book_list_response(result) + + async def GetErrorBookStats(self, request, context): + """错题本统计(P6+ v2 扩展).""" + user = _extract_user(context) + result = await analytics_service.get_error_book_stats( + user=user, + student_id=request.student_id, + subject_id=request.subject_id, + ) + return _build_error_book_stats_response(result) + # ===== 辅助函数 ===== @@ -492,6 +603,151 @@ def _build_mastery_update_event(event: dict) -> Any: ) +# ===== P6+ v2 扩展 RPC 响应构建 ===== + + +def _build_student_growth_response(data: dict) -> Any: + """构建 StudentGrowth proto 响应.""" + from generated_proto import analytics_pb2 # type: ignore[import-not-found] + + score_trend = [ + analytics_pb2.TrendPoint(date=p.get("date", 0), score=p.get("score", 0.0)) + for p in data.get("scoreTrend", []) + ] + mastery_trend = [ + analytics_pb2.MasteryTrendPoint( + calculated_at=p.get("calculated_at", 0), + overall_mastery=p.get("overall_mastery", 0.0), + ) + for p in data.get("masteryTrend", []) + ] + attendance_data = data.get("attendance", {}) + attendance = analytics_pb2.AttendanceSummary( + total_days=attendance_data.get("total_days", 0), + present_days=attendance_data.get("present_days", 0), + absent_days=attendance_data.get("absent_days", 0), + late_days=attendance_data.get("late_days", 0), + attendance_rate=attendance_data.get("attendance_rate", 0.0), + ) + return analytics_pb2.StudentGrowth( + student_id=data.get("studentId", ""), + score_trend=score_trend, + mastery_trend=mastery_trend, + attendance=attendance, + growth_score=data.get("growthScore", 0.0), + growth_level=data.get("growthLevel", "needs_improvement"), + ) + + +def _build_assignment_analysis_response(data: dict) -> Any: + """构建 AssignmentAnalysis proto 响应.""" + from generated_proto import analytics_pb2 # type: ignore[import-not-found] + + ranges = [ + analytics_pb2.ScoreRange( + label=r.get("label", ""), + count=r.get("count", 0), + percentage=r.get("percentage", 0.0), + ) + for r in data.get("ranges", []) + ] + return analytics_pb2.AssignmentAnalysis( + assignment_id=data.get("assignmentId", ""), + class_id=data.get("classId", ""), + subject_id=data.get("subjectId", ""), + average_score=data.get("averageScore", 0.0), + highest_score=data.get("highestScore", 0.0), + lowest_score=data.get("lowestScore", 0.0), + total_students=data.get("totalStudents", 0), + submitted_count=data.get("submittedCount", 0), + pass_rate=data.get("passRate", 0.0), + ranges=ranges, + ) + + +def _build_mastery_summary_response(data: dict) -> Any: + """构建 MasterySummary proto 响应.""" + from generated_proto import analytics_pb2 # type: ignore[import-not-found] + + return analytics_pb2.MasterySummary( + student_id=data.get("studentId", ""), + overall_mastery=data.get("overallMastery", 0.0), + mastered_count=data.get("masteredCount", 0), + progressing_count=data.get("progressingCount", 0), + weak_count=data.get("weakCount", 0), + total_knowledge_points=data.get("totalKnowledgePoints", 0), + mastery_level=data.get("masteryLevel", "weak"), + ) + + +def _build_diagnostic_report_list_response(data: dict) -> Any: + """构建 DiagnosticReportList proto 响应.""" + from generated_proto import analytics_pb2 # type: ignore[import-not-found] + + reports = [ + analytics_pb2.DiagnosticReport( + report_id=r.get("report_id", ""), + student_id=r.get("student_id", ""), + report_type=r.get("report_type", ""), + title=r.get("title", ""), + summary=r.get("summary", ""), + generated_at=r.get("generated_at", 0), + status=r.get("status", "pending"), + ) + for r in data.get("reports", []) + ] + return analytics_pb2.DiagnosticReportList( + student_id=data.get("studentId", ""), + reports=reports, + total=data.get("total", 0), + ) + + +def _build_error_book_list_response(data: dict) -> Any: + """构建 ErrorBookList proto 响应.""" + from generated_proto import analytics_pb2 # type: ignore[import-not-found] + + items = [ + analytics_pb2.ErrorBookItem( + question_id=item.get("question_id", ""), + knowledge_point_id=item.get("knowledge_point_id", ""), + knowledge_point_title=item.get("knowledge_point_title", ""), + error_count=item.get("error_count", 0), + last_error_time=item.get("last_error_time", 0), + content=item.get("content", ""), + ) + for item in data.get("items", []) + ] + return analytics_pb2.ErrorBookList( + student_id=data.get("studentId", ""), + items=items, + total=data.get("total", 0), + ) + + +def _build_error_book_stats_response(data: dict) -> Any: + """构建 ErrorBookStats proto 响应.""" + from generated_proto import analytics_pb2 # type: ignore[import-not-found] + + by_kp = [ + analytics_pb2.KnowledgePointErrorStats( + knowledge_point_id=kp.get("knowledge_point_id", ""), + title=kp.get("title", ""), + error_count=kp.get("error_count", 0), + question_count=kp.get("question_count", 0), + error_rate=kp.get("error_rate", 0.0), + ) + for kp in data.get("byKnowledgePoint", []) + ] + return analytics_pb2.ErrorBookStats( + student_id=data.get("studentId", ""), + total_error_questions=data.get("totalErrorQuestions", 0), + total_error_count=data.get("totalErrorCount", 0), + by_knowledge_point=by_kp, + recent_7d_errors=data.get("recent7dErrors", 0), + ) + + # ===== gRPC Server 管理 ===== _server: Any | None = None @@ -514,7 +770,7 @@ async def start_grpc_server() -> Any | None: logger.warning("grpc_dependencies_not_installed_degraded", error=str(exc)) return None - _server = grpc.aio.server() + _server = grpc.aio.server(interceptors=[_make_metrics_interceptor()]) # 注册 AnalyticsService servicer = AnalyticsServiceServicer() @@ -544,7 +800,7 @@ async def start_grpc_server() -> Any | None: logger.info( "grpc_server_started", port=settings.grpc_port, - rpc_count=12, + rpc_count=18, ) return _server except Exception as exc: # noqa: BLE001 diff --git a/services/data-ana/src/data_ana/main.py b/services/data-ana/src/data_ana/main.py index 8b74667..4a499c6 100644 --- a/services/data-ana/src/data_ana/main.py +++ b/services/data-ana/src/data_ana/main.py @@ -34,7 +34,7 @@ from datetime import UTC, datetime from typing import Any import structlog -from fastapi import APIRouter, Depends, FastAPI, Query +from fastapi import APIRouter, Depends, FastAPI, Query, Response from opentelemetry import trace from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor @@ -134,23 +134,38 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: if grpc_server_obj is None: logger.warning("grpc_server_not_started_http_only") - # 2. 启动 CDC 消费者后台任务 + # 2. 预热客户端(避免首次 readyz 探针因惰性初始化超时) + if settings.clickhouse_host: + ch_ok = await clickhouse_repository.ping() + logger.info("clickhouse_warmup", ok=ch_ok) + if settings.redis_url: + redis_ok = await redis_client.ping() + logger.info("redis_warmup", ok=redis_ok) + + # 2.1 初始化监控指标初始值(确保 /metrics 端点暴露自定义指标) + from . import metrics as _metrics + + _metrics.clickhouse_connection_status.set(1 if settings.clickhouse_host else 0) + _metrics.redis_connection_status.set(1 if settings.redis_url else 0) + _metrics.iam_grpc_connection_status.set(1 if settings.iam_grpc_endpoint else 0) + + # 3. 启动 CDC 消费者后台任务 await cdc_consumer.start_consumer() yield logger.info("data_ana_service_stopping") - # 3. 停止 CDC 消费者 + # 4. 停止 CDC 消费者 await cdc_consumer.stop_consumer() - # 4. 停止 gRPC server + # 5. 停止 gRPC server await grpc_server.stop_grpc_server() - # 5. 关闭 Kafka producer + # 6. 关闭 Kafka producer await kafka_producer.close_producer() - # 6. 关闭 Redis / iam gRPC / ClickHouse 客户端 + # 7. 关闭 Redis / iam gRPC / ClickHouse 客户端 await redis_client.close_client() await iam_client.close_grpc() await clickhouse_repository.close_client() @@ -196,36 +211,78 @@ async def healthz() -> dict[str, str]: @app.get("/readyz") -async def readyz() -> dict[str, Any]: - """就绪检查(readiness,检查 4 依赖). +async def readyz(response: Response) -> dict[str, Any]: + """就绪检查(P6 深度硬化版,检查 4 依赖 + 超时控制 + lag 阈值). - 依赖检查: - 1. clickhouse:已配置且可达(未配置算降级就绪) - 2. cdc_consumer:running / disabled - 3. redis:已配置且可达(未配置算降级就绪) - 4. iam_grpc:已配置且可达(未配置算降级就绪) + 依赖检查(带超时): + 1. clickhouse:1s 超时,已配置且可达(未配置算降级就绪) + 2. cdc_consumer:running / disabled + lag < 1000 + 3. redis:200ms 超时,已配置且可达(未配置算降级就绪) + 4. iam_grpc:2s 超时,已配置且可达(未配置算降级就绪) 返回 ready=true 的条件: - - ClickHouse 已配置且可达,或未配置(降级就绪) - - 不要求所有依赖都健康(降级模式下仍可服务骨架数据) + - ClickHouse 已配置且可达(1s 内),或未配置(降级就绪) + - CDC consumer lag < readyz_cdc_lag_threshold(1000) + 不满足时返回 HTTP 503,K8s 摘流量. """ - # 1. ClickHouse - ch_ok = await clickhouse_repository.ping() + import asyncio + + # 1. ClickHouse(1s 超时) + try: + ch_ok = await asyncio.wait_for( + clickhouse_repository.ping(), + timeout=settings.readyz_clickhouse_timeout_s, + ) + except TimeoutError: + ch_ok = False ch_status = "ok" if ch_ok else ("unreachable" if settings.clickhouse_host else "not_configured") - # 2. CDC 消费者 - cdc_status = ( - "running" - if cdc_consumer.is_running() - else ("disabled" if not settings.kafka_brokers else "failed") - ) + # 2. CDC 消费者 + lag 检查 + cdc_running = cdc_consumer.is_running() + cdc_lag = 0 + if cdc_running: + try: + cdc_lag = await asyncio.wait_for( + cdc_consumer.get_lag(), + timeout=2.0, + ) + except TimeoutError: + cdc_lag = -1 # 查询超时标记 + cdc_lag_ok = cdc_lag >= 0 and cdc_lag < settings.readyz_cdc_lag_threshold + if not settings.kafka_brokers: + cdc_status = "disabled" + cdc_lag_ok = True # 未配置 Kafka 时不阻塞 ready + elif cdc_running and cdc_lag_ok: + cdc_status = f"running(lag={cdc_lag})" + elif cdc_running: + cdc_status = f"running(lag={cdc_lag},exceeded)" + else: + cdc_status = "failed" - # 3. Redis - redis_ok = await redis_client.ping() if settings.redis_url else None + # 3. Redis(200ms 超时) + if settings.redis_url: + try: + redis_ok = await asyncio.wait_for( + redis_client.ping(), + timeout=settings.readyz_redis_timeout_s, + ) + except TimeoutError: + redis_ok = False + else: + redis_ok = None redis_status = "ok" if redis_ok else ("unreachable" if settings.redis_url else "not_configured") - # 4. iam gRPC - iam_ok = await iam_client.ping() if settings.iam_grpc_endpoint else None + # 4. iam gRPC(2s 超时,iam_client.ping 已内置 1s 超时) + if settings.iam_grpc_endpoint: + try: + iam_ok = await asyncio.wait_for( + iam_client.ping(), + timeout=settings.readyz_iam_grpc_timeout_s, + ) + except TimeoutError: + iam_ok = False + else: + iam_ok = None iam_status = ( "ok" if iam_ok else ("unreachable" if settings.iam_grpc_endpoint else "not_configured") ) @@ -233,10 +290,15 @@ async def readyz() -> dict[str, Any]: # 5. gRPC server grpc_status = "running" if grpc_server.is_running() else "stopped" - # 就绪判定:ClickHouse 可达或未配置(降级就绪) - ready = ch_ok or not settings.clickhouse_host + # 就绪判定(P6 硬化): + # - ClickHouse 可达或未配置(降级就绪) + # - CDC consumer lag 未超阈值 + ready = (ch_ok or not settings.clickhouse_host) and cdc_lag_ok degraded = not ch_ok or not redis_ok or not iam_ok + if not ready: + response.status_code = 503 # K8s 摘流量 + return { "status": "ok" if ready else "not_ready", "service": "data-ana", @@ -250,6 +312,13 @@ async def readyz() -> dict[str, Any]: "grpc_server": grpc_status, "kafka_producer": "ok" if settings.kafka_brokers else "not_configured", }, + "thresholds": { + "clickhouse_timeout_s": settings.readyz_clickhouse_timeout_s, + "redis_timeout_s": settings.readyz_redis_timeout_s, + "iam_grpc_timeout_s": settings.readyz_iam_grpc_timeout_s, + "cdc_lag_threshold": settings.readyz_cdc_lag_threshold, + "cdc_lag_current": cdc_lag, + }, "timestamp": datetime.now(UTC).isoformat(), } diff --git a/services/data-ana/src/data_ana/metrics.py b/services/data-ana/src/data_ana/metrics.py new file mode 100644 index 0000000..8368013 --- /dev/null +++ b/services/data-ana/src/data_ana/metrics.py @@ -0,0 +1,205 @@ +"""Prometheus 指标定义(P6 监控告警完善). + +对齐 workline §3.5 任务 6.4: + - consumer lag histogram(CDC 消费延迟) + - 慢查询 counter(ClickHouse 查询耗时) + - ClickHouse 连接池 gauge + - ExamCache gauge(缓存命中率) + - DataScope 缓存 counter + - gRPC 请求 counter + histogram + - 掌握度计算 counter + - 预警触发 counter + +指标命名规范(对齐项目规则 §12): + data_ana___ + +Grafana dashboard 配置见 infra/grafana/dashboards/data-ana.json +""" + +from prometheus_client import Counter, Gauge, Histogram + +# ===== CDC Consumer 指标 ===== + +# 消费者 lag(按 topic + partition 分维度) +cdc_consumer_lag = Gauge( + "data_ana_cdc_consumer_lag", + "CDC consumer lag (messages waiting to be consumed)", + ["topic", "partition", "instance"], +) + +# 消费者处理消息总数 +cdc_messages_processed_total = Counter( + "data_ana_cdc_messages_processed_total", + "Total CDC messages processed", + ["topic", "table", "status"], # status: success / failed / skipped +) + +# 消费者处理耗时 +cdc_message_process_duration_seconds = Histogram( + "data_ana_cdc_message_process_duration_seconds", + "CDC message processing duration", + ["table"], + buckets=(0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0), +) + +# 消费者实例数(多实例水平扩展监控) +cdc_consumer_active_instances = Gauge( + "data_ana_cdc_consumer_active_instances", + "Number of active CDC consumer instances (this instance reports 1 when running)", +) + +# ===== ClickHouse 查询指标 ===== + +# 查询耗时直方图 +clickhouse_query_duration_seconds = Histogram( + "data_ana_clickhouse_query_duration_seconds", + "ClickHouse query duration", + ["operation"], # operation: query_class_performance / query_student_dashboard / etc. + buckets=(0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 3.0, 5.0), +) + +# 查询错误计数 +clickhouse_query_errors_total = Counter( + "data_ana_clickhouse_query_errors_total", + "Total ClickHouse query errors", + ["operation", "error_type"], +) + +# ClickHouse 连接状态(1=connected, 0=disconnected) +clickhouse_connection_status = Gauge( + "data_ana_clickhouse_connection_status", + "ClickHouse connection status (1=connected, 0=disconnected)", +) + +# 慢查询计数(超过阈值 1s) +clickhouse_slow_queries_total = Counter( + "data_ana_clickhouse_slow_queries_total", + "Total ClickHouse slow queries (>1s)", + ["operation"], +) + +# ===== ExamCache 指标 ===== + +# 缓存大小 +exam_cache_size = Gauge( + "data_ana_exam_cache_size", + "ExamCache current size (memory LRU)", +) + +# 缓存命中/未命中 +exam_cache_hits_total = Counter( + "data_ana_exam_cache_hits_total", + "ExamCache cache hits", +) + +exam_cache_misses_total = Counter( + "data_ana_exam_cache_misses_total", + "ExamCache cache misses", +) + +# Redis 连接状态 +redis_connection_status = Gauge( + "data_ana_redis_connection_status", + "Redis connection status (1=connected, 0=disconnected)", +) + +# ===== DataScope 缓存指标 ===== + +datascope_cache_hits_total = Counter( + "data_ana_datascope_cache_hits_total", + "DataScope cache hits", +) + +datascope_cache_misses_total = Counter( + "data_ana_datascope_cache_misses_total", + "DataScope cache misses", +) + +datascope_fallback_total = Counter( + "data_ana_datascope_fallback_total", + "DataScope fallback (iam gRPC unavailable, using role-based fallback)", +) + +# ===== gRPC 指标 ===== + +grpc_requests_total = Counter( + "data_ana_grpc_requests_total", + "Total gRPC requests", + ["method", "status"], # status: success / failed / degraded +) + +grpc_request_duration_seconds = Histogram( + "data_ana_grpc_request_duration_seconds", + "gRPC request duration", + ["method"], + buckets=(0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 3.0, 5.0), +) + +# iam gRPC 连接状态 +iam_grpc_connection_status = Gauge( + "data_ana_iam_grpc_connection_status", + "iam gRPC connection status (1=connected, 0=disconnected)", +) + +# ===== 业务指标 ===== + +# 掌握度计算次数 +mastery_calculations_total = Counter( + "data_ana_mastery_calculations_total", + "Total mastery level calculations", + ["method"], # method: weighted_moving_avg / forgetting_curve +) + +# 预警触发次数 +warnings_triggered_total = Counter( + "data_ana_warnings_triggered_total", + "Total warnings triggered", + ["warning_type", "severity"], +) + +# HTTP 请求指标(FastAPI 自动 instrumentation 已覆盖,这里补充业务层) +http_requests_degraded_total = Counter( + "data_ana_http_requests_degraded_total", + "Total HTTP requests in degraded mode", + ["endpoint"], +) + + +def record_clickhouse_query(operation: str, duration_s: float, success: bool) -> None: + """记录 ClickHouse 查询指标(供 repository 调用).""" + clickhouse_query_duration_seconds.labels(operation=operation).observe(duration_s) + if not success: + clickhouse_query_errors_total.labels(operation=operation, error_type="query_failed").inc() + elif duration_s > 1.0: + clickhouse_slow_queries_total.labels(operation=operation).inc() + + +def record_grpc_request(method: str, duration_s: float, status: str) -> None: + """记录 gRPC 请求指标(供 grpc_server 调用).""" + grpc_requests_total.labels(method=method, status=status).inc() + grpc_request_duration_seconds.labels(method=method).observe(duration_s) + + +def record_exam_cache_hit() -> None: + """记录 ExamCache 命中.""" + exam_cache_hits_total.inc() + + +def record_exam_cache_miss() -> None: + """记录 ExamCache 未命中.""" + exam_cache_misses_total.inc() + + +def record_datascope_cache_hit() -> None: + """记录 DataScope 缓存命中.""" + datascope_cache_hits_total.inc() + + +def record_datascope_cache_miss() -> None: + """记录 DataScope 缓存未命中.""" + datascope_cache_misses_total.inc() + + +def record_datascope_fallback() -> None: + """记录 DataScope 降级兜底.""" + datascope_fallback_total.inc() diff --git a/services/data-ana/src/data_ana/repository/clickhouse_repository.py b/services/data-ana/src/data_ana/repository/clickhouse_repository.py index 7dbc3ae..f2cbb29 100644 --- a/services/data-ana/src/data_ana/repository/clickhouse_repository.py +++ b/services/data-ana/src/data_ana/repository/clickhouse_repository.py @@ -5,7 +5,7 @@ P0 整改:ReplacingMergeTree 查询必须加 FINAL 或用 argMax 聚合确保 """ import asyncio -from datetime import datetime +from datetime import UTC, datetime, timedelta from typing import Any import structlog @@ -793,3 +793,213 @@ async def query_student_scores_by_kp( return None return [{"score": float(row[0] or 0), "timestamp": row[1], "exam_id": row[2]} for row in rows] + + +# ===== P6+ v2 扩展查询方法 ===== + + +async def query_assignment_analysis( + class_id: str, + assignment_id: str, + subject_id: str = "", +) -> dict | None: + """查询作业/考试分析(单次作业/考试维度统计).""" + client = get_client() + if client is None: + return None + + where_parts = ["class_id = {cid:String}", "exam_id = {aid:String}"] + params: dict[str, Any] = {"cid": class_id, "aid": assignment_id} + if subject_id: + where_parts.append("subject_id = {sid:String}") + params["sid"] = subject_id + + where_clause = " AND ".join(where_parts) + try: + result = await asyncio.to_thread( + client.query, + f"SELECT " + f" avg(score) AS avg_score, " + f" max(score) AS max_score, " + f" min(score) AS min_score, " + f" count() AS total, " + f" countIf(score >= 60) / count() AS pass_rate, " + f" countIf(score >= 90) AS r90, " + f" countIf(score >= 80 AND score < 90) AS r80, " + f" countIf(score >= 70 AND score < 80) AS r70, " + f" countIf(score >= 60 AND score < 70) AS r60, " + f" countIf(score < 60) AS r_below " + f"FROM student_dashboard_view FINAL " + f"WHERE {where_clause}", + parameters=params, + ) + rows = result.result_rows + except Exception as exc: # noqa: BLE001 + logger.warning( + "query_assignment_analysis_failed", + error=str(exc), + class_id=class_id, + assignment_id=assignment_id, + ) + return None + + if not rows: + return None + + ( + avg_score, + max_score, + min_score, + total, + pass_rate, + r90, + r80, + r70, + r60, + r_below, + ) = rows[0] + total_int = int(total or 0) + + def _pct(count: Any) -> float: + return (int(count or 0) / total_int) if total_int else 0.0 + + ranges = [ + {"label": "90-100", "count": int(r90 or 0), "percentage": _pct(r90)}, + {"label": "80-89", "count": int(r80 or 0), "percentage": _pct(r80)}, + {"label": "70-79", "count": int(r70 or 0), "percentage": _pct(r70)}, + {"label": "60-69", "count": int(r60 or 0), "percentage": _pct(r60)}, + {"label": "<60", "count": int(r_below or 0), "percentage": _pct(r_below)}, + ] + return { + "assignmentId": assignment_id, + "classId": class_id, + "subjectId": subject_id, + "averageScore": float(avg_score or 0), + "highestScore": float(max_score or 0), + "lowestScore": float(min_score or 0), + "totalStudents": total_int, + "submittedCount": total_int, + "passRate": float(pass_rate or 0), + "ranges": ranges, + } + + +async def query_error_book_stats(student_id: str, subject_id: str = "") -> dict | None: + """查询错题本统计(按知识点聚合).""" + client = get_client() + if client is None: + return None + + where_parts = ["student_id = {sid:String}"] + params: dict[str, Any] = {"sid": student_id} + # subject_id 过滤需要 join 知识点表,简化为按 knowledge_point_id 前缀匹配 + + where_clause = " AND ".join(where_parts) + try: + result = await asyncio.to_thread( + client.query, + f"SELECT " + f" knowledge_point_id, " + f" sum(error_count) AS total_errors, " + f" count(DISTINCT question_id) AS question_count, " + f" max(last_error_time) AS last_error " + f"FROM student_errors FINAL " + f"WHERE {where_clause} " + f"GROUP BY knowledge_point_id " + f"ORDER BY total_errors DESC " + f"LIMIT 50", + parameters=params, + ) + rows = result.result_rows + except Exception as exc: # noqa: BLE001 + logger.warning("query_error_book_stats_failed", error=str(exc), student_id=student_id) + return None + + by_kp = [] + total_errors = 0 + total_questions = 0 + for row in rows: + kp_id, err_count, q_count, _last = row + err_count_int = int(err_count or 0) + q_count_int = int(q_count or 0) + total_errors += err_count_int + total_questions += q_count_int + by_kp.append( + { + "knowledge_point_id": kp_id, + "title": kp_id, # content CDC 同步后补充 + "error_count": err_count_int, + "question_count": q_count_int, + "error_rate": (err_count_int / q_count_int) if q_count_int else 0.0, + } + ) + + # 查询最近 7 天错误次数 + try: + seven_days_ago = datetime.now(UTC) - timedelta(days=7) + result = await asyncio.to_thread( + client.query, + "SELECT sum(error_count) " + "FROM student_errors FINAL " + "WHERE student_id = {sid:String} AND last_error_time >= {sd:DateTime64(3)}", + parameters={"sid": student_id, "sd": seven_days_ago}, + ) + recent_rows = result.result_rows + recent_7d = int(recent_rows[0][0] or 0) if recent_rows else 0 + except Exception: # noqa: BLE001 + recent_7d = 0 + + return { + "studentId": student_id, + "totalErrorQuestions": len(by_kp), + "totalErrorCount": total_errors, + "byKnowledgePoint": by_kp, + "recent7dErrors": recent_7d, + } + + +async def query_mastery_trend( + student_id: str, + start_date: int = 0, + end_date: int = 0, +) -> list[dict] | None: + """查询掌握度变化趋势(按时间排序的快照).""" + client = get_client() + if client is None: + return None + + where_parts = ["student_id = {sid:String}"] + params: dict[str, Any] = {"sid": student_id} + if start_date: + where_parts.append("calculated_at >= {sd:DateTime64(3)}") + params["sd"] = datetime.fromtimestamp(start_date) + if end_date: + where_parts.append("calculated_at <= {ed:DateTime64(3)}") + params["ed"] = datetime.fromtimestamp(end_date) + + where_clause = " AND ".join(where_parts) + try: + result = await asyncio.to_thread( + client.query, + f"SELECT " + f" calculated_at, " + f" avg(mastery_level) AS overall_mastery " + f"FROM mastery_snapshot " + f"WHERE {where_clause} " + f"GROUP BY calculated_at " + f"ORDER BY calculated_at ASC " + f"LIMIT 100", + parameters=params, + ) + rows = result.result_rows + except Exception as exc: # noqa: BLE001 + logger.warning("query_mastery_trend_failed", error=str(exc), student_id=student_id) + return None + + return [ + { + "calculated_at": int(row[0].timestamp()) if row[0] else 0, + "overall_mastery": float(row[1] or 0), + } + for row in rows + ] diff --git a/services/data-ana/src/generated_proto/analytics_pb2.py b/services/data-ana/src/generated_proto/analytics_pb2.py index eff35a1..0123ad7 100644 --- a/services/data-ana/src/generated_proto/analytics_pb2.py +++ b/services/data-ana/src/generated_proto/analytics_pb2.py @@ -20,7 +20,7 @@ _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\x0f\x61nalytics.proto\x12\x1bnext_edu_cloud.analytics.v1"h\n\x1aGetClassPerformanceRequest\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x12\n\nsubject_id\x18\x02 \x01(\t\x12\x12\n\nstart_date\x18\x03 \x01(\x03\x12\x10\n\x08\x65nd_date\x18\x04 \x01(\x03"C\n\x19GetStudentWeaknessRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x12\n\nsubject_id\x18\x02 \x01(\t"g\n\x17GetLearningTrendRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x12\n\nstart_date\x18\x02 \x01(\x03\x12\x10\n\x08\x65nd_date\x18\x03 \x01(\x03\x12\x12\n\nsubject_id\x18\x04 \x01(\t"?\n\x1aGetTeacherDashboardRequest\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x10\n\x08\x63lass_id\x18\x02 \x01(\t"-\n\x1aGetStudentDashboardRequest\x12\x0f\n\x07user_id\x18\x01 \x01(\t"@\n\x19GetParentDashboardRequest\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x12\n\nstudent_id\x18\x02 \x01(\t"L\n\x18GetAdminDashboardRequest\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\r\n\x05scope\x18\x02 \x01(\t\x12\x10\n\x08scope_id\x18\x03 \x01(\t"G\n\x12GetWarningsRequest\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x10\n\x08severity\x18\x02 \x01(\t\x12\r\n\x05since\x18\x03 \x01(\x03"R\n\x15TriggerWarningRequest\x12\x11\n\ttarget_id\x18\x01 \x01(\t\x12\x14\n\x0cwarning_type\x18\x02 \x01(\t\x12\x10\n\x08severity\x18\x03 \x01(\t"a\n\x1dGetMasteryDistributionRequest\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x12\n\nsubject_id\x18\x02 \x01(\t\x12\x1a\n\x12knowledge_point_id\x18\x03 \x01(\t"B\n\x18GetStudentMasteryRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x12\n\nsubject_id\x18\x02 \x01(\t"E\n\x1dSubscribeMasteryUpdateRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x10\n\x08\x63lass_id\x18\x02 \x01(\t"\xa1\x01\n\x10\x43lassPerformance\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x15\n\raverage_score\x18\x02 \x01(\x01\x12\x11\n\tpass_rate\x18\x03 \x01(\x01\x12\x16\n\x0etotal_students\x18\x04 \x01(\x05\x12\x39\n\x06scores\x18\x05 \x03(\x0b\x32).next_edu_cloud.analytics.v1.StudentScore"@\n\x0cStudentScore\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\r\n\x05score\x18\x02 \x01(\x01\x12\r\n\x05grade\x18\x03 \x01(\t"b\n\x0fStudentWeakness\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12;\n\x0bweak_points\x18\x02 \x03(\x0b\x32&.next_edu_cloud.analytics.v1.WeakPoint"\\\n\tWeakPoint\x12\x1a\n\x12knowledge_point_id\x18\x01 \x01(\t\x12\r\n\x05title\x18\x02 \x01(\t\x12\x0f\n\x07mastery\x18\x03 \x01(\x01\x12\x13\n\x0b\x65rror_count\x18\x04 \x01(\x05"\\\n\rLearningTrend\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x37\n\x06points\x18\x02 \x03(\x0b\x32\'.next_edu_cloud.analytics.v1.TrendPoint")\n\nTrendPoint\x12\x0c\n\x04\x64\x61te\x18\x01 \x01(\x03\x12\r\n\x05score\x18\x02 \x01(\x01"\xcd\x02\n\x10TeacherDashboard\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x15\n\rtotal_classes\x18\x02 \x01(\x05\x12\x16\n\x0etotal_students\x18\x03 \x01(\x05\x12\x17\n\x0f\x63lass_avg_score\x18\x04 \x01(\x01\x12\x1e\n\x16pending_homework_count\x18\x05 \x01(\x05\x12:\n\x07\x63lasses\x18\x06 \x03(\x0b\x32).next_edu_cloud.analytics.v1.ClassSummary\x12\x41\n\x0ctop_students\x18\x07 \x03(\x0b\x32+.next_edu_cloud.analytics.v1.StudentSummary\x12\x41\n\x0frecent_warnings\x18\x08 \x03(\x0b\x32(.next_edu_cloud.analytics.v1.WarningInfo"b\n\x0c\x43lassSummary\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x12\n\nclass_name\x18\x02 \x01(\t\x12\x15\n\rstudent_count\x18\x03 \x01(\x05\x12\x15\n\raverage_score\x18\x04 \x01(\x01"`\n\x0eStudentSummary\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x14\n\x0cstudent_name\x18\x02 \x01(\t\x12\r\n\x05score\x18\x03 \x01(\x01\x12\x15\n\rrank_in_class\x18\x04 \x01(\x05"\xf9\x01\n\x10StudentDashboard\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x11\n\tavg_score\x18\x02 \x01(\x01\x12\x12\n\nclass_rank\x18\x03 \x01(\x05\x12\x16\n\x0etotal_students\x18\x04 \x01(\x05\x12;\n\x0bweak_points\x18\x05 \x03(\x0b\x32&.next_edu_cloud.analytics.v1.WeakPoint\x12>\n\rrecent_trends\x18\x06 \x03(\x0b\x32\'.next_edu_cloud.analytics.v1.TrendPoint\x12\x18\n\x10pending_homework\x18\x07 \x01(\x05"\x8c\x02\n\x0fParentDashboard\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x12\n\nstudent_id\x18\x02 \x01(\t\x12\x17\n\x0f\x63hild_avg_score\x18\x03 \x01(\x01\x12\x18\n\x10\x63hild_class_rank\x18\x04 \x01(\x05\x12\x1c\n\x14total_class_students\x18\x05 \x01(\x05\x12\x41\n\x11\x63hild_weak_points\x18\x06 \x03(\x0b\x32&.next_edu_cloud.analytics.v1.WeakPoint\x12@\n\x0e\x63hild_warnings\x18\x07 \x03(\x0b\x32(.next_edu_cloud.analytics.v1.WarningInfo"\x84\x02\n\x0e\x41\x64minDashboard\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x16\n\x0etotal_teachers\x18\x02 \x01(\x05\x12\x16\n\x0etotal_students\x18\x03 \x01(\x05\x12\x15\n\rtotal_classes\x18\x04 \x01(\x05\x12\x18\n\x10school_avg_score\x18\x05 \x01(\x01\x12\x41\n\x0frecent_warnings\x18\x06 \x03(\x0b\x32(.next_edu_cloud.analytics.v1.WarningInfo\x12=\n\x08\x61i_usage\x18\x07 \x01(\x0b\x32+.next_edu_cloud.analytics.v1.AIUsageSummary"\x9d\x01\n\x0e\x41IUsageSummary\x12\x16\n\x0etotal_requests\x18\x01 \x01(\x03\x12\x14\n\x0ctotal_tokens\x18\x02 \x01(\x03\x12\x18\n\x10total_cost_cents\x18\x03 \x01(\x03\x12\x43\n\x0b\x62y_provider\x18\x04 \x03(\x0b\x32..next_edu_cloud.analytics.v1.AIUsageByProvider"f\n\x11\x41IUsageByProvider\x12\x10\n\x08provider\x18\x01 \x01(\t\x12\x15\n\rrequest_count\x18\x02 \x01(\x03\x12\x14\n\x0ctotal_tokens\x18\x03 \x01(\x03\x12\x12\n\ncost_cents\x18\x04 \x01(\x03"X\n\x0bWarningList\x12:\n\x08warnings\x18\x01 \x03(\x0b\x32(.next_edu_cloud.analytics.v1.WarningInfo\x12\r\n\x05total\x18\x02 \x01(\x05"\xb0\x01\n\x0bWarningInfo\x12\x12\n\nwarning_id\x18\x01 \x01(\t\x12\x14\n\x0cwarning_type\x18\x02 \x01(\t\x12\x11\n\ttarget_id\x18\x03 \x01(\t\x12\x13\n\x0btarget_name\x18\x04 \x01(\t\x12\x11\n\tthreshold\x18\x05 \x01(\x01\x12\x15\n\rcurrent_value\x18\x06 \x01(\x01\x12\x10\n\x08severity\x18\x07 \x01(\t\x12\x13\n\x0boccurred_at\x18\x08 \x01(\x03"?\n\x16TriggerWarningResponse\x12\x12\n\nwarning_id\x18\x01 \x01(\t\x12\x11\n\ttriggered\x18\x02 \x01(\x08"\x86\x01\n\x13MasteryDistribution\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x16\n\x0emastered_count\x18\x02 \x01(\x05\x12\x19\n\x11progressing_count\x18\x03 \x01(\x05\x12\x12\n\nweak_count\x18\x04 \x01(\x05\x12\x16\n\x0etotal_students\x18\x05 \x01(\x05"\x8b\x01\n\x0eStudentMastery\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12L\n\x10knowledge_points\x18\x02 \x03(\x0b\x32\x32.next_edu_cloud.analytics.v1.KnowledgePointMastery\x12\x17\n\x0foverall_mastery\x18\x03 \x01(\x01"\x9b\x01\n\x15KnowledgePointMastery\x12\x1a\n\x12knowledge_point_id\x18\x01 \x01(\t\x12\r\n\x05title\x18\x02 \x01(\t\x12\x12\n\nsubject_id\x18\x03 \x01(\t\x12\x15\n\rmastery_level\x18\x04 \x01(\x01\x12\x15\n\rmastery_label\x18\x05 \x01(\t\x12\x15\n\rcalculated_at\x18\x06 \x01(\x03"\x9c\x01\n\x12MasteryUpdateEvent\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\t\x12\x12\n\nstudent_id\x18\x02 \x01(\t\x12\x1a\n\x12knowledge_point_id\x18\x03 \x01(\t\x12\x15\n\rmastery_level\x18\x04 \x01(\x01\x12\x16\n\x0eprevious_level\x18\x05 \x01(\x01\x12\x15\n\rcalculated_at\x18\x06 \x01(\x03\x32\xe7\x0b\n\x10\x41nalyticsService\x12}\n\x13GetClassPerformance\x12\x37.next_edu_cloud.analytics.v1.GetClassPerformanceRequest\x1a-.next_edu_cloud.analytics.v1.ClassPerformance\x12z\n\x12GetStudentWeakness\x12\x36.next_edu_cloud.analytics.v1.GetStudentWeaknessRequest\x1a,.next_edu_cloud.analytics.v1.StudentWeakness\x12t\n\x10GetLearningTrend\x12\x34.next_edu_cloud.analytics.v1.GetLearningTrendRequest\x1a*.next_edu_cloud.analytics.v1.LearningTrend\x12}\n\x13GetTeacherDashboard\x12\x37.next_edu_cloud.analytics.v1.GetTeacherDashboardRequest\x1a-.next_edu_cloud.analytics.v1.TeacherDashboard\x12}\n\x13GetStudentDashboard\x12\x37.next_edu_cloud.analytics.v1.GetStudentDashboardRequest\x1a-.next_edu_cloud.analytics.v1.StudentDashboard\x12z\n\x12GetParentDashboard\x12\x36.next_edu_cloud.analytics.v1.GetParentDashboardRequest\x1a,.next_edu_cloud.analytics.v1.ParentDashboard\x12w\n\x11GetAdminDashboard\x12\x35.next_edu_cloud.analytics.v1.GetAdminDashboardRequest\x1a+.next_edu_cloud.analytics.v1.AdminDashboard\x12h\n\x0bGetWarnings\x12/.next_edu_cloud.analytics.v1.GetWarningsRequest\x1a(.next_edu_cloud.analytics.v1.WarningList\x12y\n\x0eTriggerWarning\x12\x32.next_edu_cloud.analytics.v1.TriggerWarningRequest\x1a\x33.next_edu_cloud.analytics.v1.TriggerWarningResponse\x12\x86\x01\n\x16GetMasteryDistribution\x12:.next_edu_cloud.analytics.v1.GetMasteryDistributionRequest\x1a\x30.next_edu_cloud.analytics.v1.MasteryDistribution\x12w\n\x11GetStudentMastery\x12\x35.next_edu_cloud.analytics.v1.GetStudentMasteryRequest\x1a+.next_edu_cloud.analytics.v1.StudentMastery\x12\x87\x01\n\x16SubscribeMasteryUpdate\x12:.next_edu_cloud.analytics.v1.SubscribeMasteryUpdateRequest\x1a/.next_edu_cloud.analytics.v1.MasteryUpdateEvent0\x01\x62\x06proto3' + b'\n\x0f\x61nalytics.proto\x12\x1bnext_edu_cloud.analytics.v1"h\n\x1aGetClassPerformanceRequest\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x12\n\nsubject_id\x18\x02 \x01(\t\x12\x12\n\nstart_date\x18\x03 \x01(\x03\x12\x10\n\x08\x65nd_date\x18\x04 \x01(\x03"C\n\x19GetStudentWeaknessRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x12\n\nsubject_id\x18\x02 \x01(\t"g\n\x17GetLearningTrendRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x12\n\nstart_date\x18\x02 \x01(\x03\x12\x10\n\x08\x65nd_date\x18\x03 \x01(\x03\x12\x12\n\nsubject_id\x18\x04 \x01(\t"?\n\x1aGetTeacherDashboardRequest\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x10\n\x08\x63lass_id\x18\x02 \x01(\t"-\n\x1aGetStudentDashboardRequest\x12\x0f\n\x07user_id\x18\x01 \x01(\t"@\n\x19GetParentDashboardRequest\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x12\n\nstudent_id\x18\x02 \x01(\t"L\n\x18GetAdminDashboardRequest\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\r\n\x05scope\x18\x02 \x01(\t\x12\x10\n\x08scope_id\x18\x03 \x01(\t"G\n\x12GetWarningsRequest\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x10\n\x08severity\x18\x02 \x01(\t\x12\r\n\x05since\x18\x03 \x01(\x03"R\n\x15TriggerWarningRequest\x12\x11\n\ttarget_id\x18\x01 \x01(\t\x12\x14\n\x0cwarning_type\x18\x02 \x01(\t\x12\x10\n\x08severity\x18\x03 \x01(\t"a\n\x1dGetMasteryDistributionRequest\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x12\n\nsubject_id\x18\x02 \x01(\t\x12\x1a\n\x12knowledge_point_id\x18\x03 \x01(\t"B\n\x18GetStudentMasteryRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x12\n\nsubject_id\x18\x02 \x01(\t"E\n\x1dSubscribeMasteryUpdateRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x10\n\x08\x63lass_id\x18\x02 \x01(\t"g\n\x17GetStudentGrowthRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x12\n\nstart_date\x18\x02 \x01(\x03\x12\x10\n\x08\x65nd_date\x18\x03 \x01(\x03\x12\x12\n\nsubject_id\x18\x04 \x01(\t"[\n\x1cGetAssignmentAnalysisRequest\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x15\n\rassignment_id\x18\x02 \x01(\t\x12\x12\n\nsubject_id\x18\x03 \x01(\t"B\n\x18GetMasterySummaryRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x12\n\nsubject_id\x18\x02 \x01(\t"P\n\x1cListDiagnosticReportsRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\r\n\x05since\x18\x02 \x01(\x03\x12\r\n\x05limit\x18\x03 \x01(\x05"b\n\x19ListErrorBookItemsRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x12\n\nsubject_id\x18\x02 \x01(\t\x12\r\n\x05limit\x18\x03 \x01(\x05\x12\x0e\n\x06offset\x18\x04 \x01(\x05"B\n\x18GetErrorBookStatsRequest\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x12\n\nsubject_id\x18\x02 \x01(\t"\xa1\x01\n\x10\x43lassPerformance\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x15\n\raverage_score\x18\x02 \x01(\x01\x12\x11\n\tpass_rate\x18\x03 \x01(\x01\x12\x16\n\x0etotal_students\x18\x04 \x01(\x05\x12\x39\n\x06scores\x18\x05 \x03(\x0b\x32).next_edu_cloud.analytics.v1.StudentScore"@\n\x0cStudentScore\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\r\n\x05score\x18\x02 \x01(\x01\x12\r\n\x05grade\x18\x03 \x01(\t"b\n\x0fStudentWeakness\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12;\n\x0bweak_points\x18\x02 \x03(\x0b\x32&.next_edu_cloud.analytics.v1.WeakPoint"\\\n\tWeakPoint\x12\x1a\n\x12knowledge_point_id\x18\x01 \x01(\t\x12\r\n\x05title\x18\x02 \x01(\t\x12\x0f\n\x07mastery\x18\x03 \x01(\x01\x12\x13\n\x0b\x65rror_count\x18\x04 \x01(\x05"\\\n\rLearningTrend\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x37\n\x06points\x18\x02 \x03(\x0b\x32\'.next_edu_cloud.analytics.v1.TrendPoint")\n\nTrendPoint\x12\x0c\n\x04\x64\x61te\x18\x01 \x01(\x03\x12\r\n\x05score\x18\x02 \x01(\x01"\xcd\x02\n\x10TeacherDashboard\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x15\n\rtotal_classes\x18\x02 \x01(\x05\x12\x16\n\x0etotal_students\x18\x03 \x01(\x05\x12\x17\n\x0f\x63lass_avg_score\x18\x04 \x01(\x01\x12\x1e\n\x16pending_homework_count\x18\x05 \x01(\x05\x12:\n\x07\x63lasses\x18\x06 \x03(\x0b\x32).next_edu_cloud.analytics.v1.ClassSummary\x12\x41\n\x0ctop_students\x18\x07 \x03(\x0b\x32+.next_edu_cloud.analytics.v1.StudentSummary\x12\x41\n\x0frecent_warnings\x18\x08 \x03(\x0b\x32(.next_edu_cloud.analytics.v1.WarningInfo"b\n\x0c\x43lassSummary\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x12\n\nclass_name\x18\x02 \x01(\t\x12\x15\n\rstudent_count\x18\x03 \x01(\x05\x12\x15\n\raverage_score\x18\x04 \x01(\x01"`\n\x0eStudentSummary\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x14\n\x0cstudent_name\x18\x02 \x01(\t\x12\r\n\x05score\x18\x03 \x01(\x01\x12\x15\n\rrank_in_class\x18\x04 \x01(\x05"\xf9\x01\n\x10StudentDashboard\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x11\n\tavg_score\x18\x02 \x01(\x01\x12\x12\n\nclass_rank\x18\x03 \x01(\x05\x12\x16\n\x0etotal_students\x18\x04 \x01(\x05\x12;\n\x0bweak_points\x18\x05 \x03(\x0b\x32&.next_edu_cloud.analytics.v1.WeakPoint\x12>\n\rrecent_trends\x18\x06 \x03(\x0b\x32\'.next_edu_cloud.analytics.v1.TrendPoint\x12\x18\n\x10pending_homework\x18\x07 \x01(\x05"\x8c\x02\n\x0fParentDashboard\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x12\n\nstudent_id\x18\x02 \x01(\t\x12\x17\n\x0f\x63hild_avg_score\x18\x03 \x01(\x01\x12\x18\n\x10\x63hild_class_rank\x18\x04 \x01(\x05\x12\x1c\n\x14total_class_students\x18\x05 \x01(\x05\x12\x41\n\x11\x63hild_weak_points\x18\x06 \x03(\x0b\x32&.next_edu_cloud.analytics.v1.WeakPoint\x12@\n\x0e\x63hild_warnings\x18\x07 \x03(\x0b\x32(.next_edu_cloud.analytics.v1.WarningInfo"\x84\x02\n\x0e\x41\x64minDashboard\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x16\n\x0etotal_teachers\x18\x02 \x01(\x05\x12\x16\n\x0etotal_students\x18\x03 \x01(\x05\x12\x15\n\rtotal_classes\x18\x04 \x01(\x05\x12\x18\n\x10school_avg_score\x18\x05 \x01(\x01\x12\x41\n\x0frecent_warnings\x18\x06 \x03(\x0b\x32(.next_edu_cloud.analytics.v1.WarningInfo\x12=\n\x08\x61i_usage\x18\x07 \x01(\x0b\x32+.next_edu_cloud.analytics.v1.AIUsageSummary"\x9d\x01\n\x0e\x41IUsageSummary\x12\x16\n\x0etotal_requests\x18\x01 \x01(\x03\x12\x14\n\x0ctotal_tokens\x18\x02 \x01(\x03\x12\x18\n\x10total_cost_cents\x18\x03 \x01(\x03\x12\x43\n\x0b\x62y_provider\x18\x04 \x03(\x0b\x32..next_edu_cloud.analytics.v1.AIUsageByProvider"f\n\x11\x41IUsageByProvider\x12\x10\n\x08provider\x18\x01 \x01(\t\x12\x15\n\rrequest_count\x18\x02 \x01(\x03\x12\x14\n\x0ctotal_tokens\x18\x03 \x01(\x03\x12\x12\n\ncost_cents\x18\x04 \x01(\x03"X\n\x0bWarningList\x12:\n\x08warnings\x18\x01 \x03(\x0b\x32(.next_edu_cloud.analytics.v1.WarningInfo\x12\r\n\x05total\x18\x02 \x01(\x05"\xb0\x01\n\x0bWarningInfo\x12\x12\n\nwarning_id\x18\x01 \x01(\t\x12\x14\n\x0cwarning_type\x18\x02 \x01(\t\x12\x11\n\ttarget_id\x18\x03 \x01(\t\x12\x13\n\x0btarget_name\x18\x04 \x01(\t\x12\x11\n\tthreshold\x18\x05 \x01(\x01\x12\x15\n\rcurrent_value\x18\x06 \x01(\x01\x12\x10\n\x08severity\x18\x07 \x01(\t\x12\x13\n\x0boccurred_at\x18\x08 \x01(\x03"?\n\x16TriggerWarningResponse\x12\x12\n\nwarning_id\x18\x01 \x01(\t\x12\x11\n\ttriggered\x18\x02 \x01(\x08"\x86\x01\n\x13MasteryDistribution\x12\x10\n\x08\x63lass_id\x18\x01 \x01(\t\x12\x16\n\x0emastered_count\x18\x02 \x01(\x05\x12\x19\n\x11progressing_count\x18\x03 \x01(\x05\x12\x12\n\nweak_count\x18\x04 \x01(\x05\x12\x16\n\x0etotal_students\x18\x05 \x01(\x05"\x8b\x01\n\x0eStudentMastery\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12L\n\x10knowledge_points\x18\x02 \x03(\x0b\x32\x32.next_edu_cloud.analytics.v1.KnowledgePointMastery\x12\x17\n\x0foverall_mastery\x18\x03 \x01(\x01"\x9b\x01\n\x15KnowledgePointMastery\x12\x1a\n\x12knowledge_point_id\x18\x01 \x01(\t\x12\r\n\x05title\x18\x02 \x01(\t\x12\x12\n\nsubject_id\x18\x03 \x01(\t\x12\x15\n\rmastery_level\x18\x04 \x01(\x01\x12\x15\n\rmastery_label\x18\x05 \x01(\t\x12\x15\n\rcalculated_at\x18\x06 \x01(\x03"\x9c\x01\n\x12MasteryUpdateEvent\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\t\x12\x12\n\nstudent_id\x18\x02 \x01(\t\x12\x1a\n\x12knowledge_point_id\x18\x03 \x01(\t\x12\x15\n\rmastery_level\x18\x04 \x01(\x01\x12\x16\n\x0eprevious_level\x18\x05 \x01(\x01\x12\x15\n\rcalculated_at\x18\x06 \x01(\x03"\x98\x02\n\rStudentGrowth\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12<\n\x0bscore_trend\x18\x02 \x03(\x0b\x32\'.next_edu_cloud.analytics.v1.TrendPoint\x12\x45\n\rmastery_trend\x18\x03 \x03(\x0b\x32..next_edu_cloud.analytics.v1.MasteryTrendPoint\x12\x42\n\nattendance\x18\x04 \x01(\x0b\x32..next_edu_cloud.analytics.v1.AttendanceSummary\x12\x14\n\x0cgrowth_score\x18\x05 \x01(\x01\x12\x14\n\x0cgrowth_level\x18\x06 \x01(\t"C\n\x11MasteryTrendPoint\x12\x15\n\rcalculated_at\x18\x01 \x01(\x03\x12\x17\n\x0foverall_mastery\x18\x02 \x01(\x01"~\n\x11\x41ttendanceSummary\x12\x12\n\ntotal_days\x18\x01 \x01(\x05\x12\x14\n\x0cpresent_days\x18\x02 \x01(\x05\x12\x13\n\x0b\x61\x62sent_days\x18\x03 \x01(\x05\x12\x11\n\tlate_days\x18\x04 \x01(\x05\x12\x17\n\x0f\x61ttendance_rate\x18\x05 \x01(\x01"\x92\x02\n\x12\x41ssignmentAnalysis\x12\x15\n\rassignment_id\x18\x01 \x01(\t\x12\x10\n\x08\x63lass_id\x18\x02 \x01(\t\x12\x12\n\nsubject_id\x18\x03 \x01(\t\x12\x15\n\raverage_score\x18\x04 \x01(\x01\x12\x15\n\rhighest_score\x18\x05 \x01(\x01\x12\x14\n\x0clowest_score\x18\x06 \x01(\x01\x12\x16\n\x0etotal_students\x18\x07 \x01(\x05\x12\x17\n\x0fsubmitted_count\x18\x08 \x01(\x05\x12\x11\n\tpass_rate\x18\t \x01(\x01\x12\x37\n\x06ranges\x18\n \x03(\x0b\x32\'.next_edu_cloud.analytics.v1.ScoreRange">\n\nScoreRange\x12\r\n\x05label\x18\x01 \x01(\t\x12\r\n\x05\x63ount\x18\x02 \x01(\x05\x12\x12\n\npercentage\x18\x03 \x01(\x01"\xbb\x01\n\x0eMasterySummary\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x17\n\x0foverall_mastery\x18\x02 \x01(\x01\x12\x16\n\x0emastered_count\x18\x03 \x01(\x05\x12\x19\n\x11progressing_count\x18\x04 \x01(\x05\x12\x12\n\nweak_count\x18\x05 \x01(\x05\x12\x1e\n\x16total_knowledge_points\x18\x06 \x01(\x05\x12\x15\n\rmastery_level\x18\x07 \x01(\t"y\n\x14\x44iagnosticReportList\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12>\n\x07reports\x18\x02 \x03(\x0b\x32-.next_edu_cloud.analytics.v1.DiagnosticReport\x12\r\n\x05total\x18\x03 \x01(\x05"\x94\x01\n\x10\x44iagnosticReport\x12\x11\n\treport_id\x18\x01 \x01(\t\x12\x12\n\nstudent_id\x18\x02 \x01(\t\x12\x13\n\x0breport_type\x18\x03 \x01(\t\x12\r\n\x05title\x18\x04 \x01(\t\x12\x0f\n\x07summary\x18\x05 \x01(\t\x12\x14\n\x0cgenerated_at\x18\x06 \x01(\x03\x12\x0e\n\x06status\x18\x07 \x01(\t"m\n\rErrorBookList\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x39\n\x05items\x18\x02 \x03(\x0b\x32*.next_edu_cloud.analytics.v1.ErrorBookItem\x12\r\n\x05total\x18\x03 \x01(\x05"\x9e\x01\n\rErrorBookItem\x12\x13\n\x0bquestion_id\x18\x01 \x01(\t\x12\x1a\n\x12knowledge_point_id\x18\x02 \x01(\t\x12\x1d\n\x15knowledge_point_title\x18\x03 \x01(\t\x12\x13\n\x0b\x65rror_count\x18\x04 \x01(\x05\x12\x17\n\x0flast_error_time\x18\x05 \x01(\x03\x12\x0f\n\x07\x63ontent\x18\x06 \x01(\t"\xcb\x01\n\x0e\x45rrorBookStats\x12\x12\n\nstudent_id\x18\x01 \x01(\t\x12\x1d\n\x15total_error_questions\x18\x02 \x01(\x05\x12\x19\n\x11total_error_count\x18\x03 \x01(\x05\x12Q\n\x12\x62y_knowledge_point\x18\x04 \x03(\x0b\x32\x35.next_edu_cloud.analytics.v1.KnowledgePointErrorStats\x12\x18\n\x10recent_7d_errors\x18\x05 \x01(\x05"\x86\x01\n\x18KnowledgePointErrorStats\x12\x1a\n\x12knowledge_point_id\x18\x01 \x01(\t\x12\r\n\x05title\x18\x02 \x01(\t\x12\x13\n\x0b\x65rror_count\x18\x03 \x01(\x05\x12\x16\n\x0equestion_count\x18\x04 \x01(\x05\x12\x12\n\nerror_rate\x18\x05 \x01(\x01\x32\xd7\x11\n\x10\x41nalyticsService\x12}\n\x13GetClassPerformance\x12\x37.next_edu_cloud.analytics.v1.GetClassPerformanceRequest\x1a-.next_edu_cloud.analytics.v1.ClassPerformance\x12z\n\x12GetStudentWeakness\x12\x36.next_edu_cloud.analytics.v1.GetStudentWeaknessRequest\x1a,.next_edu_cloud.analytics.v1.StudentWeakness\x12t\n\x10GetLearningTrend\x12\x34.next_edu_cloud.analytics.v1.GetLearningTrendRequest\x1a*.next_edu_cloud.analytics.v1.LearningTrend\x12}\n\x13GetTeacherDashboard\x12\x37.next_edu_cloud.analytics.v1.GetTeacherDashboardRequest\x1a-.next_edu_cloud.analytics.v1.TeacherDashboard\x12}\n\x13GetStudentDashboard\x12\x37.next_edu_cloud.analytics.v1.GetStudentDashboardRequest\x1a-.next_edu_cloud.analytics.v1.StudentDashboard\x12z\n\x12GetParentDashboard\x12\x36.next_edu_cloud.analytics.v1.GetParentDashboardRequest\x1a,.next_edu_cloud.analytics.v1.ParentDashboard\x12w\n\x11GetAdminDashboard\x12\x35.next_edu_cloud.analytics.v1.GetAdminDashboardRequest\x1a+.next_edu_cloud.analytics.v1.AdminDashboard\x12h\n\x0bGetWarnings\x12/.next_edu_cloud.analytics.v1.GetWarningsRequest\x1a(.next_edu_cloud.analytics.v1.WarningList\x12y\n\x0eTriggerWarning\x12\x32.next_edu_cloud.analytics.v1.TriggerWarningRequest\x1a\x33.next_edu_cloud.analytics.v1.TriggerWarningResponse\x12\x86\x01\n\x16GetMasteryDistribution\x12:.next_edu_cloud.analytics.v1.GetMasteryDistributionRequest\x1a\x30.next_edu_cloud.analytics.v1.MasteryDistribution\x12w\n\x11GetStudentMastery\x12\x35.next_edu_cloud.analytics.v1.GetStudentMasteryRequest\x1a+.next_edu_cloud.analytics.v1.StudentMastery\x12\x87\x01\n\x16SubscribeMasteryUpdate\x12:.next_edu_cloud.analytics.v1.SubscribeMasteryUpdateRequest\x1a/.next_edu_cloud.analytics.v1.MasteryUpdateEvent0\x01\x12t\n\x10GetStudentGrowth\x12\x34.next_edu_cloud.analytics.v1.GetStudentGrowthRequest\x1a*.next_edu_cloud.analytics.v1.StudentGrowth\x12\x83\x01\n\x15GetAssignmentAnalysis\x12\x39.next_edu_cloud.analytics.v1.GetAssignmentAnalysisRequest\x1a/.next_edu_cloud.analytics.v1.AssignmentAnalysis\x12w\n\x11GetMasterySummary\x12\x35.next_edu_cloud.analytics.v1.GetMasterySummaryRequest\x1a+.next_edu_cloud.analytics.v1.MasterySummary\x12\x85\x01\n\x15ListDiagnosticReports\x12\x39.next_edu_cloud.analytics.v1.ListDiagnosticReportsRequest\x1a\x31.next_edu_cloud.analytics.v1.DiagnosticReportList\x12x\n\x12ListErrorBookItems\x12\x36.next_edu_cloud.analytics.v1.ListErrorBookItemsRequest\x1a*.next_edu_cloud.analytics.v1.ErrorBookList\x12w\n\x11GetErrorBookStats\x12\x35.next_edu_cloud.analytics.v1.GetErrorBookStatsRequest\x1a+.next_edu_cloud.analytics.v1.ErrorBookStatsb\x06proto3' ) _globals = globals() @@ -52,48 +52,84 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals["_GETSTUDENTMASTERYREQUEST"]._serialized_end = 906 _globals["_SUBSCRIBEMASTERYUPDATEREQUEST"]._serialized_start = 908 _globals["_SUBSCRIBEMASTERYUPDATEREQUEST"]._serialized_end = 977 - _globals["_CLASSPERFORMANCE"]._serialized_start = 980 - _globals["_CLASSPERFORMANCE"]._serialized_end = 1141 - _globals["_STUDENTSCORE"]._serialized_start = 1143 - _globals["_STUDENTSCORE"]._serialized_end = 1207 - _globals["_STUDENTWEAKNESS"]._serialized_start = 1209 - _globals["_STUDENTWEAKNESS"]._serialized_end = 1307 - _globals["_WEAKPOINT"]._serialized_start = 1309 - _globals["_WEAKPOINT"]._serialized_end = 1401 - _globals["_LEARNINGTREND"]._serialized_start = 1403 - _globals["_LEARNINGTREND"]._serialized_end = 1495 - _globals["_TRENDPOINT"]._serialized_start = 1497 - _globals["_TRENDPOINT"]._serialized_end = 1538 - _globals["_TEACHERDASHBOARD"]._serialized_start = 1541 - _globals["_TEACHERDASHBOARD"]._serialized_end = 1874 - _globals["_CLASSSUMMARY"]._serialized_start = 1876 - _globals["_CLASSSUMMARY"]._serialized_end = 1974 - _globals["_STUDENTSUMMARY"]._serialized_start = 1976 - _globals["_STUDENTSUMMARY"]._serialized_end = 2072 - _globals["_STUDENTDASHBOARD"]._serialized_start = 2075 - _globals["_STUDENTDASHBOARD"]._serialized_end = 2324 - _globals["_PARENTDASHBOARD"]._serialized_start = 2327 - _globals["_PARENTDASHBOARD"]._serialized_end = 2595 - _globals["_ADMINDASHBOARD"]._serialized_start = 2598 - _globals["_ADMINDASHBOARD"]._serialized_end = 2858 - _globals["_AIUSAGESUMMARY"]._serialized_start = 2861 - _globals["_AIUSAGESUMMARY"]._serialized_end = 3018 - _globals["_AIUSAGEBYPROVIDER"]._serialized_start = 3020 - _globals["_AIUSAGEBYPROVIDER"]._serialized_end = 3122 - _globals["_WARNINGLIST"]._serialized_start = 3124 - _globals["_WARNINGLIST"]._serialized_end = 3212 - _globals["_WARNINGINFO"]._serialized_start = 3215 - _globals["_WARNINGINFO"]._serialized_end = 3391 - _globals["_TRIGGERWARNINGRESPONSE"]._serialized_start = 3393 - _globals["_TRIGGERWARNINGRESPONSE"]._serialized_end = 3456 - _globals["_MASTERYDISTRIBUTION"]._serialized_start = 3459 - _globals["_MASTERYDISTRIBUTION"]._serialized_end = 3593 - _globals["_STUDENTMASTERY"]._serialized_start = 3596 - _globals["_STUDENTMASTERY"]._serialized_end = 3735 - _globals["_KNOWLEDGEPOINTMASTERY"]._serialized_start = 3738 - _globals["_KNOWLEDGEPOINTMASTERY"]._serialized_end = 3893 - _globals["_MASTERYUPDATEEVENT"]._serialized_start = 3896 - _globals["_MASTERYUPDATEEVENT"]._serialized_end = 4052 - _globals["_ANALYTICSSERVICE"]._serialized_start = 4055 - _globals["_ANALYTICSSERVICE"]._serialized_end = 5566 + _globals["_GETSTUDENTGROWTHREQUEST"]._serialized_start = 979 + _globals["_GETSTUDENTGROWTHREQUEST"]._serialized_end = 1082 + _globals["_GETASSIGNMENTANALYSISREQUEST"]._serialized_start = 1084 + _globals["_GETASSIGNMENTANALYSISREQUEST"]._serialized_end = 1175 + _globals["_GETMASTERYSUMMARYREQUEST"]._serialized_start = 1177 + _globals["_GETMASTERYSUMMARYREQUEST"]._serialized_end = 1243 + _globals["_LISTDIAGNOSTICREPORTSREQUEST"]._serialized_start = 1245 + _globals["_LISTDIAGNOSTICREPORTSREQUEST"]._serialized_end = 1325 + _globals["_LISTERRORBOOKITEMSREQUEST"]._serialized_start = 1327 + _globals["_LISTERRORBOOKITEMSREQUEST"]._serialized_end = 1425 + _globals["_GETERRORBOOKSTATSREQUEST"]._serialized_start = 1427 + _globals["_GETERRORBOOKSTATSREQUEST"]._serialized_end = 1493 + _globals["_CLASSPERFORMANCE"]._serialized_start = 1496 + _globals["_CLASSPERFORMANCE"]._serialized_end = 1657 + _globals["_STUDENTSCORE"]._serialized_start = 1659 + _globals["_STUDENTSCORE"]._serialized_end = 1723 + _globals["_STUDENTWEAKNESS"]._serialized_start = 1725 + _globals["_STUDENTWEAKNESS"]._serialized_end = 1823 + _globals["_WEAKPOINT"]._serialized_start = 1825 + _globals["_WEAKPOINT"]._serialized_end = 1917 + _globals["_LEARNINGTREND"]._serialized_start = 1919 + _globals["_LEARNINGTREND"]._serialized_end = 2011 + _globals["_TRENDPOINT"]._serialized_start = 2013 + _globals["_TRENDPOINT"]._serialized_end = 2054 + _globals["_TEACHERDASHBOARD"]._serialized_start = 2057 + _globals["_TEACHERDASHBOARD"]._serialized_end = 2390 + _globals["_CLASSSUMMARY"]._serialized_start = 2392 + _globals["_CLASSSUMMARY"]._serialized_end = 2490 + _globals["_STUDENTSUMMARY"]._serialized_start = 2492 + _globals["_STUDENTSUMMARY"]._serialized_end = 2588 + _globals["_STUDENTDASHBOARD"]._serialized_start = 2591 + _globals["_STUDENTDASHBOARD"]._serialized_end = 2840 + _globals["_PARENTDASHBOARD"]._serialized_start = 2843 + _globals["_PARENTDASHBOARD"]._serialized_end = 3111 + _globals["_ADMINDASHBOARD"]._serialized_start = 3114 + _globals["_ADMINDASHBOARD"]._serialized_end = 3374 + _globals["_AIUSAGESUMMARY"]._serialized_start = 3377 + _globals["_AIUSAGESUMMARY"]._serialized_end = 3534 + _globals["_AIUSAGEBYPROVIDER"]._serialized_start = 3536 + _globals["_AIUSAGEBYPROVIDER"]._serialized_end = 3638 + _globals["_WARNINGLIST"]._serialized_start = 3640 + _globals["_WARNINGLIST"]._serialized_end = 3728 + _globals["_WARNINGINFO"]._serialized_start = 3731 + _globals["_WARNINGINFO"]._serialized_end = 3907 + _globals["_TRIGGERWARNINGRESPONSE"]._serialized_start = 3909 + _globals["_TRIGGERWARNINGRESPONSE"]._serialized_end = 3972 + _globals["_MASTERYDISTRIBUTION"]._serialized_start = 3975 + _globals["_MASTERYDISTRIBUTION"]._serialized_end = 4109 + _globals["_STUDENTMASTERY"]._serialized_start = 4112 + _globals["_STUDENTMASTERY"]._serialized_end = 4251 + _globals["_KNOWLEDGEPOINTMASTERY"]._serialized_start = 4254 + _globals["_KNOWLEDGEPOINTMASTERY"]._serialized_end = 4409 + _globals["_MASTERYUPDATEEVENT"]._serialized_start = 4412 + _globals["_MASTERYUPDATEEVENT"]._serialized_end = 4568 + _globals["_STUDENTGROWTH"]._serialized_start = 4571 + _globals["_STUDENTGROWTH"]._serialized_end = 4851 + _globals["_MASTERYTRENDPOINT"]._serialized_start = 4853 + _globals["_MASTERYTRENDPOINT"]._serialized_end = 4920 + _globals["_ATTENDANCESUMMARY"]._serialized_start = 4922 + _globals["_ATTENDANCESUMMARY"]._serialized_end = 5048 + _globals["_ASSIGNMENTANALYSIS"]._serialized_start = 5051 + _globals["_ASSIGNMENTANALYSIS"]._serialized_end = 5325 + _globals["_SCORERANGE"]._serialized_start = 5327 + _globals["_SCORERANGE"]._serialized_end = 5389 + _globals["_MASTERYSUMMARY"]._serialized_start = 5392 + _globals["_MASTERYSUMMARY"]._serialized_end = 5579 + _globals["_DIAGNOSTICREPORTLIST"]._serialized_start = 5581 + _globals["_DIAGNOSTICREPORTLIST"]._serialized_end = 5702 + _globals["_DIAGNOSTICREPORT"]._serialized_start = 5705 + _globals["_DIAGNOSTICREPORT"]._serialized_end = 5853 + _globals["_ERRORBOOKLIST"]._serialized_start = 5855 + _globals["_ERRORBOOKLIST"]._serialized_end = 5964 + _globals["_ERRORBOOKITEM"]._serialized_start = 5967 + _globals["_ERRORBOOKITEM"]._serialized_end = 6125 + _globals["_ERRORBOOKSTATS"]._serialized_start = 6128 + _globals["_ERRORBOOKSTATS"]._serialized_end = 6331 + _globals["_KNOWLEDGEPOINTERRORSTATS"]._serialized_start = 6334 + _globals["_KNOWLEDGEPOINTERRORSTATS"]._serialized_end = 6468 + _globals["_ANALYTICSSERVICE"]._serialized_start = 6471 + _globals["_ANALYTICSSERVICE"]._serialized_end = 8734 # @@protoc_insertion_point(module_scope) diff --git a/services/data-ana/src/generated_proto/analytics_pb2_grpc.py b/services/data-ana/src/generated_proto/analytics_pb2_grpc.py index 832dcb4..158a17b 100644 --- a/services/data-ana/src/generated_proto/analytics_pb2_grpc.py +++ b/services/data-ana/src/generated_proto/analytics_pb2_grpc.py @@ -111,6 +111,42 @@ class AnalyticsServiceStub: response_deserializer=analytics__pb2.MasteryUpdateEvent.FromString, _registered_method=True, ) + self.GetStudentGrowth = channel.unary_unary( + "/next_edu_cloud.analytics.v1.AnalyticsService/GetStudentGrowth", + request_serializer=analytics__pb2.GetStudentGrowthRequest.SerializeToString, + response_deserializer=analytics__pb2.StudentGrowth.FromString, + _registered_method=True, + ) + self.GetAssignmentAnalysis = channel.unary_unary( + "/next_edu_cloud.analytics.v1.AnalyticsService/GetAssignmentAnalysis", + request_serializer=analytics__pb2.GetAssignmentAnalysisRequest.SerializeToString, + response_deserializer=analytics__pb2.AssignmentAnalysis.FromString, + _registered_method=True, + ) + self.GetMasterySummary = channel.unary_unary( + "/next_edu_cloud.analytics.v1.AnalyticsService/GetMasterySummary", + request_serializer=analytics__pb2.GetMasterySummaryRequest.SerializeToString, + response_deserializer=analytics__pb2.MasterySummary.FromString, + _registered_method=True, + ) + self.ListDiagnosticReports = channel.unary_unary( + "/next_edu_cloud.analytics.v1.AnalyticsService/ListDiagnosticReports", + request_serializer=analytics__pb2.ListDiagnosticReportsRequest.SerializeToString, + response_deserializer=analytics__pb2.DiagnosticReportList.FromString, + _registered_method=True, + ) + self.ListErrorBookItems = channel.unary_unary( + "/next_edu_cloud.analytics.v1.AnalyticsService/ListErrorBookItems", + request_serializer=analytics__pb2.ListErrorBookItemsRequest.SerializeToString, + response_deserializer=analytics__pb2.ErrorBookList.FromString, + _registered_method=True, + ) + self.GetErrorBookStats = channel.unary_unary( + "/next_edu_cloud.analytics.v1.AnalyticsService/GetErrorBookStats", + request_serializer=analytics__pb2.GetErrorBookStatsRequest.SerializeToString, + response_deserializer=analytics__pb2.ErrorBookStats.FromString, + _registered_method=True, + ) class AnalyticsServiceServicer: @@ -191,6 +227,44 @@ class AnalyticsServiceServicer: context.set_details("Method not implemented!") raise NotImplementedError("Method not implemented!") + def GetStudentGrowth(self, request, context): + """===== P6+ v2 扩展 RPC(响应上游 parent-bff / student-bff v2 请求) ===== + 学生成长档案(综合成绩趋势 + 掌握度变化 + 考勤统计,供 parent-bff GrowthArchiveService). + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def GetAssignmentAnalysis(self, request, context): + """作业/考试分析(单次作业/考试维度统计,供 student-bff / parent-bff).""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def GetMasterySummary(self, request, context): + """学生掌握度汇总(轻量级,仅返回总体掌握度 + 三档分布,供 parent-bff MasteryService.GetSummary).""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def ListDiagnosticReports(self, request, context): + """诊断报告列表(占位实现,真实数据需 ai 服务集成,供 parent-bff / student-bff).""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def ListErrorBookItems(self, request, context): + """错题本列表(gRPC 版本,供 parent-bff ErrorBookService.List).""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def GetErrorBookStats(self, request, context): + """错题本统计(按知识点聚合,供 parent-bff ErrorBookService.GetStats).""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + def add_AnalyticsServiceServicer_to_server(servicer, server): rpc_method_handlers = { @@ -254,6 +328,36 @@ def add_AnalyticsServiceServicer_to_server(servicer, server): request_deserializer=analytics__pb2.SubscribeMasteryUpdateRequest.FromString, response_serializer=analytics__pb2.MasteryUpdateEvent.SerializeToString, ), + "GetStudentGrowth": grpc.unary_unary_rpc_method_handler( + servicer.GetStudentGrowth, + request_deserializer=analytics__pb2.GetStudentGrowthRequest.FromString, + response_serializer=analytics__pb2.StudentGrowth.SerializeToString, + ), + "GetAssignmentAnalysis": grpc.unary_unary_rpc_method_handler( + servicer.GetAssignmentAnalysis, + request_deserializer=analytics__pb2.GetAssignmentAnalysisRequest.FromString, + response_serializer=analytics__pb2.AssignmentAnalysis.SerializeToString, + ), + "GetMasterySummary": grpc.unary_unary_rpc_method_handler( + servicer.GetMasterySummary, + request_deserializer=analytics__pb2.GetMasterySummaryRequest.FromString, + response_serializer=analytics__pb2.MasterySummary.SerializeToString, + ), + "ListDiagnosticReports": grpc.unary_unary_rpc_method_handler( + servicer.ListDiagnosticReports, + request_deserializer=analytics__pb2.ListDiagnosticReportsRequest.FromString, + response_serializer=analytics__pb2.DiagnosticReportList.SerializeToString, + ), + "ListErrorBookItems": grpc.unary_unary_rpc_method_handler( + servicer.ListErrorBookItems, + request_deserializer=analytics__pb2.ListErrorBookItemsRequest.FromString, + response_serializer=analytics__pb2.ErrorBookList.SerializeToString, + ), + "GetErrorBookStats": grpc.unary_unary_rpc_method_handler( + servicer.GetErrorBookStats, + request_deserializer=analytics__pb2.GetErrorBookStatsRequest.FromString, + response_serializer=analytics__pb2.ErrorBookStats.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( "next_edu_cloud.analytics.v1.AnalyticsService", rpc_method_handlers @@ -630,3 +734,183 @@ class AnalyticsService: metadata, _registered_method=True, ) + + @staticmethod + def GetStudentGrowth( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/next_edu_cloud.analytics.v1.AnalyticsService/GetStudentGrowth", + analytics__pb2.GetStudentGrowthRequest.SerializeToString, + analytics__pb2.StudentGrowth.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def GetAssignmentAnalysis( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/next_edu_cloud.analytics.v1.AnalyticsService/GetAssignmentAnalysis", + analytics__pb2.GetAssignmentAnalysisRequest.SerializeToString, + analytics__pb2.AssignmentAnalysis.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def GetMasterySummary( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/next_edu_cloud.analytics.v1.AnalyticsService/GetMasterySummary", + analytics__pb2.GetMasterySummaryRequest.SerializeToString, + analytics__pb2.MasterySummary.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def ListDiagnosticReports( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/next_edu_cloud.analytics.v1.AnalyticsService/ListDiagnosticReports", + analytics__pb2.ListDiagnosticReportsRequest.SerializeToString, + analytics__pb2.DiagnosticReportList.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def ListErrorBookItems( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/next_edu_cloud.analytics.v1.AnalyticsService/ListErrorBookItems", + analytics__pb2.ListErrorBookItemsRequest.SerializeToString, + analytics__pb2.ErrorBookList.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def GetErrorBookStats( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/next_edu_cloud.analytics.v1.AnalyticsService/GetErrorBookStats", + analytics__pb2.GetErrorBookStatsRequest.SerializeToString, + analytics__pb2.ErrorBookStats.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + )