diff --git a/app/data.py b/app/data.py index ebdf295..ccef0c9 100644 --- a/app/data.py +++ b/app/data.py @@ -975,34 +975,6 @@ def extract_course_summary_from_text(text: str, index: int = 0, known_students: } -def manual_review_summary(raw: dict, error: Exception) -> dict: - body = str(raw.get("body") or raw.get("content") or "").strip() - source_id = str(raw.get("source_id") or "").strip() or f"manual:{sha1_text(body, 24)}" - return { - "source_id": source_id, - "student": canonical_name(str(raw.get("student") or "").strip()) or "待核对学生", - "date_iso": str(raw.get("date_iso") or raw.get("date") or "").strip(), - "time_range": str(raw.get("time_range") or raw.get("time") or "").strip(), - "duration_minutes": raw.get("duration_minutes"), - "duration": str(raw.get("duration") or "").strip(), - "teacher": canonical_name(str(raw.get("teacher") or "").strip()), - "subject": parse_subject_code(str(raw.get("subject") or "").strip()), - "group": str(raw.get("group") or "").strip(), - "sender": str(raw.get("sender") or "管理后台").strip(), - "sender_id": str(raw.get("sender_id") or "").strip(), - "message_time": str(raw.get("message_time") or "").strip(), - "message_date": str(raw.get("message_date") or "").strip(), - "db": str(raw.get("db") or "").strip(), - "local_id": str(raw.get("local_id") or "").strip(), - "title": str(raw.get("title") or "手工登记课程小结").strip(), - "body": body or str(error), - "recognition_source": "manual_admin", - "confidence": "manual_review", - "teacher_trusted": True, - "remark": f"手工登记待审核:{error}", - } - - def normalize_course_summary(raw: dict) -> dict: student = canonical_name(str(raw.get("student") or "").strip()) teacher = canonical_name(str(raw.get("teacher") or "").strip()) @@ -1510,8 +1482,6 @@ def register_course_summary_texts( now = datetime.now().isoformat(timespec="seconds") accounts = read_accounts(accounts_path) known_students = [account.student for account in accounts] - summaries: list[dict] = [] - manual_review_items: list[dict] = [] result = { "received": len(texts), "saved": 0, @@ -1523,60 +1493,99 @@ def register_course_summary_texts( "items": [], } for index, text in enumerate(texts): - raw = extract_course_summary_from_text(text, index, known_students=known_students) + raw: dict | None = None + normalized: dict | None = None try: - normalize_course_summary(raw) - summaries.append(raw) - except ValueError as exc: - summary = manual_review_summary(raw, exc) - saved = save_course_summary_markdown(summaries_root, summary) - task = create_course_summary_review_task( - tasks_path, - summary, - "", - [str(exc), "手工登记课程小结需人工补全"], - saved_path=str(saved.get("path") or ""), + raw = extract_course_summary_from_text(text, index, known_students=known_students) + normalized = normalize_course_summary(raw) + source_id = normalized["source_id"] + semantic_key = course_summary_semantic_key(normalized) + state = read_course_summary_state(state_path) + seen_source_ids = set(str(item) for item in state.get("seen_source_ids", [])) + seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", [])) + if source_id in seen_source_ids or semantic_key in seen_semantic_keys: + result["duplicates"] += 1 + log_id = append_operation_log( + operation_logs_path, + "course_summary_manual_register", + "duplicate", + source_id=source_id, + student=normalized["student"], + ) + result["operation_log_ids"].append(log_id) + result["items"].append({"source_id": source_id, "status": "duplicate"}) + continue + + reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path) + if reasons: + raise ValueError(";".join(reasons)) + + saved = save_course_summary_markdown(summaries_root, normalized) + register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line) + seen_source_ids.add(source_id) + seen_semantic_keys.add(semantic_key) + state["seen_source_ids"] = sorted(seen_source_ids) + state["seen_semantic_keys"] = sorted(seen_semantic_keys) + state.setdefault("batches", []).append( + { + "batch_id": f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text(source_id, 8)}", + "received_at": now, + "window": {"source": "admin_register", "submitted_at": now}, + "students": [normalized["student"]], + "result": { + "received": 1, + "saved": 1 if saved.get("added") else 0, + "auto_registered": 1, + "review_pending": 0, + "duplicates": 0, + "rejected": 0, + }, + } ) + state["batches"] = state["batches"][-200:] + write_course_summary_state(state_path, state) log_id = append_operation_log( operation_logs_path, - "course_summary_ingest", - "review", - batch_id=f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}", - source_id=summary["source_id"], - student=summary["student"], - reasons=[str(exc), "手工登记课程小结需人工补全"], - task_id=task.get("id"), + "course_summary_manual_register", + "auto_registered", + source_id=source_id, + student=normalized["student"], + teacher=normalized.get("teacher", ""), + subject=normalized.get("subject", ""), + proposed_line=proposed_line, + backup_id=str(register_result.get("backup_id") or ""), saved_path=str(saved.get("path") or ""), ) result["saved"] += 1 if saved.get("added") else 0 - result["review_pending"] += 1 + result["auto_registered"] += 1 result["operation_log_ids"].append(log_id) result["items"].append( { - "source_id": summary["source_id"], - "status": "review", - "task_id": task.get("id"), - "reasons": [str(exc), "手工登记课程小结需人工补全"], + "source_id": source_id, + "status": "auto_registered", + "backup_id": str(register_result.get("backup_id") or ""), + } + ) + except ValueError as exc: + source_id = str((normalized or raw or {}).get("source_id") or f"manual:{sha1_text(text, 24)}") + student = str((normalized or raw or {}).get("student") or "") + log_id = append_operation_log( + operation_logs_path, + "course_summary_manual_register", + "rejected", + source_id=source_id, + student=student, + error=str(exc), + ) + result["rejected"] += 1 + result["operation_log_ids"].append(log_id) + result["items"].append( + { + "source_id": source_id, + "status": "rejected", + "error": str(exc), } ) - if not summaries: - return result - ingest_result = ingest_course_summaries( - classnotes_path=classnotes_path, - accounts_path=accounts_path, - tasks_path=tasks_path, - summaries_root=summaries_root, - state_path=state_path, - operation_logs_path=operation_logs_path, - batch_id=f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text('|'.join(texts), 8)}", - window={"source": "admin_register", "submitted_at": now}, - students=[], - summaries=summaries, - ) - for key in ("saved", "auto_registered", "review_pending", "duplicates", "rejected"): - result[key] += int(ingest_result.get(key) or 0) - result["operation_log_ids"].extend(ingest_result.get("operation_log_ids") or []) - result["items"].extend(ingest_result.get("items") or []) return result diff --git a/app/static/admin.html b/app/static/admin.html index 7b60324..1fdd797 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -319,6 +319,6 @@ - + diff --git a/app/static/admin.js b/app/static/admin.js index 757dd0d..a0d3e3a 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -75,6 +75,14 @@ let currentSummaryReviews = []; let activeSummaryReview = null; let summaryRegisterItemSeq = 0; +const SUMMARY_REQUIRED_FIELDS = [ + ["student", "学生"], + ["date", "日期"], + ["time", "时间"], + ["teacher", "老师"], + ["subject", "科目"], +]; + function fmtHours(value) { return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); } @@ -596,20 +604,73 @@ function addSummaryRegisterItem(value = "") { const item = document.createElement("div"); item.className = "summary-register-item"; item.innerHTML = ` - + +
+ + + + + +
+ `; summaryRegisterItems.appendChild(item); } +function inferSummaryField(body, field) { + const patterns = { + student: /(?:学生|学员)[::]\s*([^\n;;,,]+)/, + date: /(?:日期|上课日期)[::]\s*(\d{4}[./-]\d{1,2}[./-]\d{1,2})|(\d{4}[./-]\d{1,2}[./-]\d{1,2})/, + time: /(?:时间|上课时间)[::]\s*(\d{1,2}:\d{2}-\d{1,2}:\d{2})|(\d{1,2}:\d{2}-\d{1,2}:\d{2})/, + teacher: /(?:老师|教师)[::]\s*([^\n;;,,]+)|([\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)/, + subject: /(?:科目|课程)[::]\s*([^\n;;,,]+)|(数学|语文|英语|物理|化学|生物|历史|地理|政治|道法)/, + }; + const match = String(body || "").match(patterns[field]); + if (!match) return ""; + return (match[1] || match[2] || "").trim(); +} + +function summaryRegisterItemPayload(item) { + const body = item.querySelector("[data-summary-field='body']").value.trim(); + const values = { body }; + SUMMARY_REQUIRED_FIELDS.forEach(([field]) => { + const input = item.querySelector(`[data-summary-field='${field}']`); + values[field] = input.value.trim() || inferSummaryField(body, field); + if (!input.value.trim() && values[field]) input.value = values[field]; + }); + const missing = SUMMARY_REQUIRED_FIELDS.filter(([field]) => !values[field]).map(([, label]) => label); + const missingNode = item.querySelector(".summary-register-missing"); + missingNode.hidden = missing.length === 0; + missingNode.textContent = missing.length ? `请补齐:${missing.join("、")};无法补齐请删除本框,本条不会提交。` : ""; + item.classList.toggle("has-missing", missing.length > 0); + if (!body || missing.length) return null; + return [ + `学生:${values.student}`, + `日期:${values.date}`, + `时间:${values.time}`, + `老师:${values.teacher}`, + `科目:${values.subject}`, + "小结:", + body, + ].join("\n"); +} + function summaryRegisterLines() { - return Array.from(summaryRegisterItems.querySelectorAll("textarea")) - .map((textarea) => textarea.value.trim()) - .filter(Boolean); + const items = Array.from(summaryRegisterItems.querySelectorAll(".summary-register-item")); + const lines = items.map(summaryRegisterItemPayload).filter(Boolean); + return { + lines, + blocked: items.some((item) => item.classList.contains("has-missing")), + }; } async function submitSummaryRegister(event) { event.preventDefault(); - const lines = summaryRegisterLines(); + const { lines, blocked } = summaryRegisterLines(); + if (blocked) { + summaryRegisterStatus.textContent = "还有课程小结缺字段,请补齐后再提交;无法补齐的请删除本框。"; + return; + } if (!lines.length) { summaryRegisterStatus.textContent = "请至少填写一条课程小结"; return; @@ -621,7 +682,7 @@ async function submitSummaryRegister(event) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lines }), }); - summaryRegisterStatus.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;待审核 ${data.review_pending} 条;重复 ${data.duplicates} 条;失败 ${data.rejected} 条`; + summaryRegisterStatus.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;重复 ${data.duplicates} 条;舍弃 ${data.rejected} 条`; summaryRegisterItems.innerHTML = ""; addSummaryRegisterItem(); await loadAdminHealth(); diff --git a/app/static/styles.css b/app/static/styles.css index ddb73ae..3d9056c 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -383,12 +383,33 @@ textarea:focus { gap: 7px; } +.summary-register-item.has-missing { + padding: 10px; + border: 1px solid #fecdca; + border-radius: 6px; + background: #fff7f6; +} + .summary-register-item label { color: #344054; font-size: 13px; font-weight: 700; } +.summary-register-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.summary-register-missing { + margin: 0; + color: var(--danger); + font-size: 13px; + font-weight: 700; + line-height: 1.45; +} + .summary-register-remove { justify-self: start; } @@ -1003,6 +1024,10 @@ td { grid-template-columns: 1fr; } + .summary-register-fields { + grid-template-columns: 1fr; + } + .admin-tabs { overflow-x: auto; }