From 5f6bf376b260529a84a995f49204f0f9d371159f Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 16 Jun 2026 17:02:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=87=8D=E5=A4=8D=E8=AF=BE?= =?UTF-8?q?=E7=A8=8B=E5=B0=8F=E7=BB=93=E5=AE=A1=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/data.py | 295 +++++++++++++++++++++++++++++++++++++- app/app/routers/admin.py | 46 ++++++ app/app/static/admin.html | 19 ++- app/app/static/admin.js | 103 +++++++++++-- 4 files changed, 446 insertions(+), 17 deletions(-) diff --git a/app/app/data.py b/app/app/data.py index 4a90813..9d1f143 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -1021,6 +1021,88 @@ def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = } +def duplicate_review_task_from_group(task_id: int, group: dict, now: str) -> dict: + key = group.get("key") or {} + return { + "id": task_id, + "type": "course_summary_duplicate_review", + "status": "pending", + "created_at": now, + "updated_at": now, + "duplicate_group_id": str(group.get("duplicate_group_id") or ""), + "student": str(key.get("student") or ""), + "teacher": str(key.get("teacher") or ""), + "subject": str(key.get("subject") or ""), + "date_iso": str(key.get("date_iso") or ""), + "time_range": str(key.get("time_range") or ""), + "summary": { + "student": str(key.get("student") or ""), + "teacher": str(key.get("teacher") or ""), + "subject": str(key.get("subject") or ""), + "date_iso": str(key.get("date_iso") or ""), + "time_range": str(key.get("time_range") or ""), + "group": "", + "body": "", + }, + "duplicate_candidates": group.get("candidates") or [], + "reasons": ["同一节课存在多条课程小结,请选择删除其中一条"], + } + + +def create_course_summary_duplicate_review_tasks( + tasks_path: Path, + summaries_root: Path, + target_key: tuple[str, str, str, str, str] | None = None, +) -> dict: + tasks = read_admin_tasks(tasks_path) + active_tasks = { + str(task.get("duplicate_group_id") or ""): task + for task in tasks.get("items", []) + if task.get("type") == "course_summary_duplicate_review" and task.get("status") in {"pending", "conflict"} + } + now = datetime.now().isoformat(timespec="seconds") + created: list[dict] = [] + groups = find_course_summary_duplicate_groups(summaries_root) + if target_key is not None: + groups = [ + group + for group in groups + if course_summary_record_key( + str((group.get("key") or {}).get("student") or ""), + str((group.get("key") or {}).get("teacher") or ""), + str((group.get("key") or {}).get("subject") or ""), + str((group.get("key") or {}).get("date_iso") or ""), + str((group.get("key") or {}).get("time_range") or ""), + ) + == target_key + ] + changed = False + refreshed = 0 + for group in groups: + group_id = str(group.get("duplicate_group_id") or "") + if not group_id: + continue + if group_id in active_tasks: + active_tasks[group_id]["duplicate_candidates"] = group.get("candidates") or [] + active_tasks[group_id]["updated_at"] = now + changed = True + refreshed += 1 + continue + task = duplicate_review_task_from_group(int(tasks["next_id"]), group, now) + tasks["next_id"] = int(tasks["next_id"]) + 1 + tasks["items"].append(task) + active_tasks[group_id] = task + created.append(task) + if created or changed: + write_admin_tasks(tasks_path, tasks) + return { + "scanned": len(groups), + "created": len(created), + "refreshed": refreshed, + "items": [task_to_dict(task) for task in created], + } + + def replace_class_record_line(original_text: str, original_line: str, corrected_line: str) -> str: lines = original_text.splitlines() matched = [index for index, line in enumerate(lines) if line.strip() == original_line] @@ -1501,6 +1583,8 @@ OPERATION_LABELS = { "admin_task_reject": "审核驳回", "admin_course_summary_update_time": "课程小结补齐时间", "admin_course_summary_delete": "课程小结删除", + "admin_course_summary_duplicate_scan": "重复小结扫描", + "admin_course_summary_duplicate_delete": "重复小结删除", "register-class-records": "登记上课记录", "register-payments": "登记缴费记录", "admin-create-account": "新增课时账户", @@ -1532,6 +1616,7 @@ TYPE_LABELS = { "class_record_correction": "上课记录纠错", "class_record_deletion": "上课记录删除", "course_summary_review": "课程小结审核", + "course_summary_duplicate_review": "重复小结审核", } @@ -1891,6 +1976,9 @@ def iter_course_summary_markdown(root: Path) -> Iterable[dict]: body = text[start:end].strip() body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip() body_text = body_without_meta or body + source_id_match = re.search(r"来源ID:`([^`]+)`", body) + sent_at_match = re.search(r"发送时间:`([^`]+)`", body) + sender_match = re.search(r"发送者:`([^`]+)`", body) item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20) yield { **identity, @@ -1900,6 +1988,10 @@ def iter_course_summary_markdown(root: Path) -> Iterable[dict]: "time_range": parse_course_summary_time_text(f"{title}\n{body_text}"), "group": group, "body": body_text, + "raw_body": body, + "source_id": source_id_match.group(1) if source_id_match else "", + "message_time": sent_at_match.group(1) if sent_at_match else "", + "sender": sender_match.group(1) if sender_match else "", "body_preview": body_text[:260] + ("..." if len(body_text) > 260 else ""), } @@ -1950,6 +2042,108 @@ def course_summary_record_key(student: str, teacher: str, subject: str, date_iso ) +def course_summary_duplicate_key(item: dict) -> tuple[str, str, str, str, str]: + return course_summary_record_key( + str(item.get("student") or ""), + str(item.get("teacher") or ""), + str(item.get("subject") or ""), + str(item.get("date_iso") or ""), + str(item.get("time_range") or ""), + ) + + +def course_summary_source_meta(item: dict) -> dict: + body = str(item.get("raw_body") or item.get("body") or "") + source_id_match = re.search(r"来源ID:`([^`]+)`", body) + sent_at_match = re.search(r"发送时间:`([^`]+)`", body) + sender_match = re.search(r"发送者:`([^`]+)`", body) + return { + "source_id": source_id_match.group(1) if source_id_match else "", + "message_time": sent_at_match.group(1) if sent_at_match else "", + "sender": sender_match.group(1) if sender_match else "", + } + + +def course_summary_duplicate_candidate(item: dict) -> dict: + meta = course_summary_source_meta(item) + body = str(item.get("body") or "") + return { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or ""), + "student": str(item.get("student") or ""), + "teacher": str(item.get("teacher") or ""), + "subject": str(item.get("subject") or ""), + "date_iso": str(item.get("date_iso") or ""), + "time_range": str(item.get("time_range") or ""), + "group": str(item.get("group") or ""), + "source_path": str(item.get("source_path") or ""), + "relative_path": str(item.get("relative_path") or ""), + "source_id": str(item.get("source_id") or meta["source_id"]), + "message_time": str(item.get("message_time") or meta["message_time"]), + "sender": str(item.get("sender") or meta["sender"]), + "body": body, + "body_preview": body[:260] + ("..." if len(body) > 260 else ""), + } + + +def course_summary_quality_score(item: dict) -> tuple[int, int, int, str]: + title = str(item.get("title") or "") + body = str(item.get("body") or "") + raw_body = str(item.get("raw_body") or body) + has_standard_time = 1 if re.search(r"\d{1,2}:\d{2}-\d{1,2}:\d{2}", title) else 0 + has_source_meta = 1 if "来源ID:`" in raw_body or "发送时间:`" in raw_body or "发送者:`" in raw_body else 0 + return (has_standard_time, has_source_meta, len(body), str(item.get("id") or "")) + + +def course_summary_duplicate_group_id(key: tuple[str, str, str, str, str], candidate_ids: list[str]) -> str: + return sha1_text("|".join([*key, *sorted(candidate_ids)]), 20) + + +def find_course_summary_duplicate_groups(root: Path) -> list[dict]: + grouped: dict[tuple[str, str, str, str, str], list[dict]] = defaultdict(list) + for item in iter_course_summary_markdown(root): + if not item.get("date_iso") or not item.get("time_range"): + continue + key = course_summary_duplicate_key(item) + if not all(key): + continue + grouped[key].append(item) + + groups: list[dict] = [] + for key, items in grouped.items(): + if len(items) <= 1: + continue + candidates = [course_summary_duplicate_candidate(item) for item in sorted(items, key=lambda item: str(item.get("id") or ""))] + groups.append( + { + "duplicate_group_id": course_summary_duplicate_group_id(key, [candidate["id"] for candidate in candidates]), + "key": { + "student": key[0], + "teacher": key[1], + "subject": key[2], + "date_iso": key[3], + "time_range": key[4], + }, + "candidates": candidates, + } + ) + groups.sort(key=lambda group: ( + str(group["key"].get("date_iso") or ""), + str(group["key"].get("student") or ""), + str(group["key"].get("teacher") or ""), + str(group["key"].get("subject") or ""), + str(group["key"].get("time_range") or ""), + )) + return groups + + +def refresh_course_summary_duplicate_group(root: Path, duplicate_group_id: str) -> dict | None: + for group in find_course_summary_duplicate_groups(root): + if str(group.get("duplicate_group_id") or "") == duplicate_group_id: + return group + return None + + def course_summary_to_public(item: dict, teachers: list[Teacher]) -> dict: display_names = teacher_alias_map(teachers) teacher = str(item.get("teacher") or "") @@ -1984,8 +2178,10 @@ def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, st time_range, ) index[key].append(item) - for values in index.values(): + for key, values in list(index.items()): values.sort(key=lambda item: (str(item.get("title") or ""), str(item.get("id") or ""))) + if len(values) > 1: + index[key] = [max(values, key=course_summary_quality_score)] return index @@ -2114,6 +2310,72 @@ def delete_course_summary(root: Path, summary_id: str) -> dict: return result +def resolve_duplicate_course_summary_task( + tasks_path: Path, + summaries_root: Path, + task_id: int, + delete_summary_id: str, +) -> dict: + target_id = delete_summary_id.strip() + if not target_id: + raise ValueError("请选择要删除的课程小结") + tasks = read_admin_tasks(tasks_path) + task = find_admin_task(tasks, task_id) + if task.get("type") != "course_summary_duplicate_review": + raise ValueError("该任务不是重复小结审核") + if task.get("status") not in {"pending", "conflict"}: + raise ValueError("该任务已处理,不能重复批准") + + group_id = str(task.get("duplicate_group_id") or "") + current_group = refresh_course_summary_duplicate_group(summaries_root, group_id) + if current_group is None: + task["status"] = "conflict" + task["updated_at"] = datetime.now().isoformat(timespec="seconds") + task["message"] = "重复小结已不存在或已被处理" + write_admin_tasks(tasks_path, tasks) + raise ValueError(task["message"]) + + candidates = current_group.get("candidates") or [] + candidate_ids = {str(candidate.get("id") or "") for candidate in candidates} + if target_id not in candidate_ids: + task["status"] = "conflict" + task["updated_at"] = datetime.now().isoformat(timespec="seconds") + task["duplicate_candidates"] = candidates + task["message"] = "选择的小结已不存在,请重新选择" + write_admin_tasks(tasks_path, tasks) + raise ValueError(task["message"]) + if len(candidates) <= 1: + task["status"] = "conflict" + task["updated_at"] = datetime.now().isoformat(timespec="seconds") + task["duplicate_candidates"] = candidates + task["message"] = "当前重复组已不足两条,无需删除" + write_admin_tasks(tasks_path, tasks) + raise ValueError(task["message"]) + + original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n" + delete_result = delete_course_summary(summaries_root, target_id) + now = datetime.now().isoformat(timespec="seconds") + task["status"] = "approved" + task["updated_at"] = now + task["reviewed_at"] = now + task["deleted_summary_id"] = target_id + task["deleted_summary_title"] = str(delete_result.get("title") or "") + task["backup_id"] = str(delete_result.get("backup_id") or "") + task["duplicate_candidates"] = candidates + new_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n" + try: + atomic_write_text(tasks_path, new_tasks) + except Exception: + atomic_write_text(tasks_path, original_tasks) + raise + return { + "task": task_to_dict(task), + "deleted_summary_id": target_id, + "backup_id": str(delete_result.get("backup_id") or ""), + "deleted": delete_result, + } + + def course_summary_path(root: Path, summary: dict) -> Path: student = safe_filename_part(summary["student"]) teacher = safe_filename_part(summary["teacher"] or "待核对老师") @@ -2397,6 +2659,11 @@ def register_course_summary_texts( raise ValueError(";".join(reasons)) saved = save_course_summary_markdown(summaries_root, normalized) + duplicate_tasks = create_course_summary_duplicate_review_tasks( + tasks_path, + summaries_root, + target_key=course_summary_duplicate_key(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) @@ -2435,6 +2702,16 @@ def register_course_summary_texts( result["saved"] += 1 if saved.get("added") else 0 result["auto_registered"] += 1 result["operation_log_ids"].append(log_id) + if duplicate_tasks["created"]: + scan_log_id = append_operation_log( + operation_logs_path, + "重复小结扫描", + "待审核", + source_id=source_id, + student=normalized["student"], + created_tasks=duplicate_tasks["created"], + ) + result["operation_log_ids"].append(scan_log_id) result["items"].append( { "source_id": source_id, @@ -2519,6 +2796,11 @@ def ingest_course_summaries( continue saved = save_course_summary_markdown(summaries_root, normalized) + duplicate_tasks = create_course_summary_duplicate_review_tasks( + tasks_path, + summaries_root, + target_key=course_summary_duplicate_key(normalized), + ) result["saved"] += 1 if saved.get("added") else 0 reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path) @@ -2564,6 +2846,17 @@ def ingest_course_summaries( saved_path=str(saved.get("path") or ""), ) result["operation_log_ids"].append(log_id) + if duplicate_tasks["created"]: + scan_log_id = append_operation_log( + operation_logs_path, + "重复小结扫描", + "待审核", + batch_id=batch_id, + source_id=source_id, + student=normalized["student"], + created_tasks=duplicate_tasks["created"], + ) + result["operation_log_ids"].append(scan_log_id) result["items"].append( { "source_id": source_id, diff --git a/app/app/routers/admin.py b/app/app/routers/admin.py index 7a2a752..c4c78e4 100644 --- a/app/app/routers/admin.py +++ b/app/app/routers/admin.py @@ -16,12 +16,14 @@ from ..config import ( from ..data import ( append_operation_log, approve_admin_task, + create_course_summary_duplicate_review_tasks, delete_course_summary, list_admin_tasks, list_operation_logs, migrate_operation_log_labels, query_course_summaries, reject_admin_task, + resolve_duplicate_course_summary_task, rollback_operation_log, update_course_summary_time, ) @@ -139,6 +141,33 @@ def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)): return {"ok": True, **result} +@router.post("/api/admin/tasks/{task_id}/resolve-duplicate-summary") +def admin_resolve_duplicate_summary(task_id: int, payload: dict, _user: str = Depends(verify_admin_auth)): + delete_summary_id = str(payload.get("delete_summary_id") or "") + try: + with write_lock: + result = resolve_duplicate_course_summary_task( + ADMIN_TASKS_PATH, + COURSE_SUMMARIES_ROOT, + task_id, + delete_summary_id, + ) + task = result.get("task", {}) + append_operation_log( + OPERATION_LOGS_PATH, + "重复小结删除", + "已删除", + task_id=task_id, + task_type=str(task.get("type") or ""), + student=str(task.get("student") or ""), + summary_id=str(result.get("deleted_summary_id") or ""), + backup_id=str(result.get("backup_id") or ""), + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return {"ok": True, **result} + + @router.post("/api/admin/tasks/{task_id}/reject") def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)): try: @@ -158,6 +187,23 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)): return {"ok": True, "task": task} +@router.post("/api/admin/course-summaries/duplicate-scan") +def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = create_course_summary_duplicate_review_tasks(ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT) + append_operation_log( + OPERATION_LOGS_PATH, + "重复小结扫描", + "待审核" if result.get("created") else "完成", + scanned_groups=int(result.get("scanned") or 0), + created_tasks=int(result.get("created") or 0), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + @router.post("/api/admin/course-summaries/{summary_id}/time") def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)): try: diff --git a/app/app/static/admin.html b/app/app/static/admin.html index 5c38e53..fe425ab 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -219,13 +219,16 @@