From 253c414e8f63e70c4561023692bf2fc491275f54 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 20 Jun 2026 20:02:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AF=BE=E7=A8=8B=E5=B0=8F?= =?UTF-8?q?=E7=BB=93=E8=87=AA=E5=8A=A8=E7=BB=91=E5=AE=9A=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/data.py | 237 +++++++++++++++++++++++++++++++++++++- app/app/static/admin.html | 8 +- app/app/static/admin.js | 116 ++++++++++++++++++- 3 files changed, 351 insertions(+), 10 deletions(-) diff --git a/app/app/data.py b/app/app/data.py index 93b92bb..053f4a3 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -1439,6 +1439,29 @@ def duration_minutes_from_time_range(time_range: str) -> int | None: return end - start if end > start else None +def time_range_bounds_minutes(time_range: str) -> tuple[int, int] | None: + normalized = normalize_time_range_text(time_range) + if not normalized: + return None + match = TIME_RANGE_RE.fullmatch(normalized) + if not match: + return None + start = int(match.group("sh")) * 60 + int(match.group("sm")) + end = int(match.group("eh")) * 60 + int(match.group("em")) + return (start, end) if end > start else None + + +def time_range_close_enough(left: str, right: str, tolerance_minutes: int = 20) -> bool: + left_bounds = time_range_bounds_minutes(left) + right_bounds = time_range_bounds_minutes(right) + if left_bounds is None or right_bounds is None: + return False + return ( + abs(left_bounds[0] - right_bounds[0]) <= tolerance_minutes + and abs(left_bounds[1] - right_bounds[1]) <= tolerance_minutes + ) + + def duration_minutes_from_summary(summary: dict) -> int | None: raw_duration = summary.get("duration") or summary.get("duration_text") or "" if raw_duration: @@ -2229,6 +2252,24 @@ def course_summary_record_key(student: str, teacher: str, subject: str, date_iso ) +def course_summary_identity_key(item: dict) -> tuple[str, str, str, str]: + return ( + canonical_name(str(item.get("student") or "").strip()), + canonical_teacher_name(str(item.get("teacher") or "").strip()), + normalize_subject(str(item.get("subject") or "").strip()), + str(item.get("date_iso") or "").strip(), + ) + + +def class_record_identity_key(record: ClassRecord) -> tuple[str, str, str, str]: + return ( + record.student, + record.teacher, + normalize_subject(record.subject), + record.date.replace(".", "-"), + ) + + def course_summary_duplicate_key(item: dict) -> tuple[str, str, str, str, str]: return course_summary_record_key( str(item.get("student") or ""), @@ -2456,8 +2497,14 @@ def course_summary_to_public(item: dict, teachers: list[Teacher]) -> dict: } -def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, str, str], list[dict]]: +def course_summary_index_for_records( + root: Path, + records: list[ClassRecord] | None = None, +) -> dict[tuple[str, str, str, str, str], list[dict]]: index: dict[tuple[str, str, str, str, str], list[dict]] = defaultdict(list) + records_by_identity: defaultdict[tuple[str, str, str, str], list[ClassRecord]] = defaultdict(list) + for record in records or []: + records_by_identity[class_record_identity_key(record)].append(record) for item in iter_course_summary_markdown(root): time_range = str(item.get("time_range") or "") if not time_range: @@ -2469,6 +2516,12 @@ def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, st str(item.get("date_iso") or ""), time_range, ) + close_record = close_time_binding_record( + item, + records_by_identity.get(course_summary_identity_key(item), []), + ) + if close_record is not None: + key = class_record_binding_key(close_record) index[key].append(item) for key, values in list(index.items()): values.sort(key=lambda item: (str(item.get("title") or ""), str(item.get("id") or ""))) @@ -2487,6 +2540,38 @@ def class_record_binding_key(record: ClassRecord) -> tuple[str, str, str, str, s ) +def close_time_binding_record(item: dict, records: list[ClassRecord]) -> ClassRecord | None: + item_time = str(item.get("time_range") or "") + if not item_time: + return None + item_bounds = time_range_bounds_minutes(item_time) + if item_bounds is None: + return None + item_identity = course_summary_identity_key(item) + if not all(item_identity): + return None + candidates = [ + record + for record in records + if class_record_identity_key(record) == item_identity + and record.time != normalize_time_range_text(item_time) + and time_range_close_enough(item_time, record.time) + ] + if not candidates: + return None + def distance(record: ClassRecord) -> tuple[int, str]: + record_bounds = time_range_bounds_minutes(record.time) + if record_bounds is None: + return (9999, record.time) + return ( + abs(item_bounds[0] - record_bounds[0]) + abs(item_bounds[1] - record_bounds[1]), + record.time, + ) + + candidates.sort(key=distance) + return candidates[0] + + def course_summary_record_snapshot(record: ClassRecord) -> dict: return { "record_id": record_identity(record), @@ -2571,6 +2656,21 @@ def course_summary_binding_status(item: dict, record_keys: dict[tuple[str, str, "differences": [], } + close_record = close_time_binding_record(item, records) + if close_record is not None: + return { + "status": "matched", + "label": "已绑定", + "record": course_summary_record_snapshot(close_record), + "candidates": [], + "reason": "只有时间段不一致,起止时间差均在20分钟内,已自动绑定", + "differences": ["时间不一致"], + "auto_bound": True, + "auto_bind_reason": "time_range_within_20_minutes", + "summary_time_range": str(item.get("time_range") or ""), + "record_time_range": close_record.time, + } + item_student = canonical_name(str(item.get("student") or "")) item_date = str(item.get("date_iso") or "") same_student_date = [ @@ -3184,6 +3284,83 @@ def course_summary_to_class_record_line(summary: dict) -> str: ) +def close_time_existing_class_record(summary: dict, classnotes_path: Path) -> ClassRecord | None: + if not classnotes_path.exists() or not summary.get("time_range"): + return None + try: + records = read_classnotes(classnotes_path) + except ValueError: + return None + return close_time_binding_record(summary, records) + + +def auto_bound_class_record_context(summary: dict, classnotes_path: Path) -> dict | None: + record = close_time_existing_class_record(summary, classnotes_path) + if record is None: + return None + summary_time = normalize_time_range_text(summary.get("time_range")) + return { + "record": record, + "record_line": class_record_to_line(record), + "summary_time_range": summary_time, + "record_time_range": record.time, + "status": "自动绑定", + "reason": "只有时间段不一致,起止时间差均在20分钟内,已自动绑定", + } + + +def auto_bound_course_summary_item( + operation_logs_path: Path, + operation: str, + source_id: str, + normalized: dict, + saved: dict, + bound_context: dict, + *, + batch_id: str = "", + duplicate_tasks: dict | None = None, +) -> tuple[str, list[str], dict]: + log_id = append_operation_log( + operation_logs_path, + operation, + "自动绑定", + batch_id=batch_id, + source_id=source_id, + student=normalized["student"], + teacher=normalized.get("teacher", ""), + subject=normalized.get("subject", ""), + proposed_line=str(bound_context.get("record_line") or ""), + saved_path=str(saved.get("path") or ""), + summary_time_range=str(bound_context.get("summary_time_range") or ""), + record_time_range=str(bound_context.get("record_time_range") or ""), + reasons=[str(bound_context.get("reason") or "")], + ) + operation_log_ids = [log_id] + if duplicate_tasks and duplicate_tasks.get("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"], + ) + operation_log_ids.append(scan_log_id) + return ( + "自动绑定", + operation_log_ids, + { + "source_id": source_id, + "status": "自动绑定", + "task_id": None, + "backup_id": "", + "reasons": [str(bound_context.get("reason") or "")], + "record_line": str(bound_context.get("record_line") or ""), + }, + ) + + def auto_register_reasons(summary: dict, classnotes_path: Path, accounts_path: Path) -> tuple[list[str], str]: reasons: list[str] = [] confidence = str(summary.get("confidence") or "").lower() @@ -3723,6 +3900,44 @@ def register_course_summary_texts( summaries_root, target_key=course_summary_duplicate_key(normalized), ) + bound_context = auto_bound_class_record_context(normalized, classnotes_path) + if bound_context is not None: + 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) + _status_value, log_ids, item = auto_bound_course_summary_item( + operation_logs_path, + "课程小结登记", + source_id, + normalized, + saved, + bound_context, + duplicate_tasks=duplicate_tasks, + ) + result["saved"] += 1 if saved.get("added") else 0 + result["auto_registered"] += 1 + result["operation_log_ids"].extend(log_ids) + result["items"].append(item) + continue 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) @@ -3916,6 +4131,24 @@ def ingest_course_summaries( reason = f"脚本追问:{question}" if reason not in reasons: reasons.append(reason) + bound_context = auto_bound_class_record_context(normalized, classnotes_path) + if bound_context is not None and not reasons: + status_value, log_ids, item = auto_bound_course_summary_item( + operation_logs_path, + "课程小结接收", + source_id, + normalized, + saved, + bound_context, + batch_id=batch_id, + duplicate_tasks=duplicate_tasks, + ) + seen_source_ids.add(source_id) + seen_semantic_keys.add(semantic_key) + result["auto_registered"] += 1 + result["operation_log_ids"].extend(log_ids) + result["items"].append(item) + continue if reasons: task = create_course_summary_review_task( tasks_path, @@ -4472,7 +4705,7 @@ def query_public_records( matched = filter_records(records, spec) if has_filter_condition(spec) else [] shown = matched[:limit] if limit > 0 else matched display_names = teacher_alias_map(teachers) - summary_index = course_summary_index_for_records(summaries_root) if summaries_root is not None else {} + summary_index = course_summary_index_for_records(summaries_root, records) if summaries_root is not None else {} return { "query": { "raw_query": spec.raw_query, diff --git a/app/app/static/admin.html b/app/app/static/admin.html index 82bd0ab..00f05da 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -24,7 +24,7 @@ - + @@ -293,8 +293,9 @@ - + diff --git a/app/app/static/admin.js b/app/app/static/admin.js index 34698c1..eacf178 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -84,6 +84,7 @@ const summarySearchBindingStatus = document.querySelector("#summarySearchBinding const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate"); const summarySearchMeta = document.querySelector("#summarySearchMeta"); const summarySearchRows = document.querySelector("#summarySearchRows"); +const quickMismatchBtn = document.querySelector("#quickMismatchBtn"); const logOperation = document.querySelector("#logOperation"); const logStatus = document.querySelector("#logStatus"); const logFilterForm = document.querySelector("#logFilterForm"); @@ -116,6 +117,8 @@ let currentSummarySearchItems = []; let expandedSummarySearchId = ""; let editingSummarySearchId = ""; let editingSummaryIdentityId = ""; +let quickSummaryMismatchMode = false; +let quickSummaryProcessing = false; let currentOperationLogs = []; let expandedOperationLogId = ""; const registerPreviewState = { @@ -161,7 +164,7 @@ function statusClass(status) { } function taskStatusClass(status) { - if (["approved", "auto_registered", "已批准", "自动入账", "完成", "已更新", "已删除"].includes(status)) return "normal"; + if (["approved", "auto_registered", "已批准", "自动入账", "自动绑定", "完成", "已更新", "已删除"].includes(status)) return "normal"; if (["rejected", "duplicate", "已驳回", "重复"].includes(status)) return "closed"; if (status === "conflict" || status === "冲突") return "debt"; return "warning"; @@ -999,6 +1002,44 @@ function summarizeSummaryBindings(items) { ); } +function mismatchItemsWithCandidates() { + return currentSummarySearchItems.filter((item) => { + const binding = summaryBinding(item); + return binding.status === "mismatch" && Array.isArray(binding.candidates) && binding.candidates.length; + }); +} + +function firstMismatchWithCandidate() { + return mismatchItemsWithCandidates()[0] || null; +} + +function currentQuickMismatchItem() { + const items = mismatchItemsWithCandidates(); + return items.find((item) => String(item.id) === String(expandedSummarySearchId)) || items[0] || null; +} + +function updateQuickMismatchButton() { + if (!quickMismatchBtn) return; + const remaining = mismatchItemsWithCandidates().length; + quickMismatchBtn.disabled = quickSummaryProcessing || (quickSummaryMismatchMode && remaining === 0); + if (quickSummaryProcessing) { + quickMismatchBtn.textContent = "正在处理"; + } else if (!quickSummaryMismatchMode) { + quickMismatchBtn.textContent = "快速处理字段不一致"; + } else if (remaining) { + quickMismatchBtn.textContent = `按首个候选修正并下一条(剩 ${remaining})`; + } else { + quickMismatchBtn.textContent = "字段不一致已处理完"; + } +} + +function selectFirstQuickMismatchItem() { + const item = firstMismatchWithCandidate(); + expandedSummarySearchId = item ? String(item.id) : ""; + editingSummarySearchId = ""; + editingSummaryIdentityId = ""; +} + function renderSummaryBindingDetail(item) { const binding = summaryBinding(item); if (binding.status === "matched" && binding.record) { @@ -1085,6 +1126,12 @@ async function loadSummarySearch() { if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = ""; if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = ""; if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = ""; + if ( + quickSummaryMismatchMode + && !mismatchItemsWithCandidates().some((item) => String(item.id) === String(expandedSummarySearchId)) + ) { + selectFirstQuickMismatchItem(); + } const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems); summarySearchMeta.innerHTML = [ metric("命中小结", `${data.count} 条`), @@ -1094,9 +1141,11 @@ async function loadSummarySearch() { metric("有候选", `${bindingSummary.withCandidate} 条`), ].join(""); renderCurrentSummarySearch(); + updateQuickMismatchButton(); } catch (error) { currentSummarySearchItems = []; summarySearchRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; + updateQuickMismatchButton(); } } @@ -1150,7 +1199,7 @@ function findCurrentSummary(summaryId) { return currentSummarySearchItems.find((item) => String(item.id) === String(summaryId)); } -async function applySummaryBindingCandidate(summaryId, record) { +async function applySummaryBindingCandidate(summaryId, record, options = {}) { const item = findCurrentSummary(summaryId); if (!item) throw new Error("未找到当前课程小结"); let activeSummaryId = summaryId; @@ -1180,11 +1229,53 @@ async function applySummaryBindingCandidate(summaryId, record) { } editingSummaryIdentityId = ""; editingSummarySearchId = ""; - expandedSummarySearchId = activeSummaryId; + expandedSummarySearchId = options.next ? "" : activeSummaryId; await loadSummarySearch(); await loadOperationLogs(); } +async function startQuickMismatchMode() { + quickSummaryMismatchMode = true; + quickSummaryProcessing = false; + summarySearchBindingStatus.value = "mismatch"; + summarySearchHasCandidate.value = "true"; + await loadSummarySearch(); + selectFirstQuickMismatchItem(); + renderCurrentSummarySearch(); + updateQuickMismatchButton(); +} + +async function processQuickMismatchItem() { + if (!quickSummaryMismatchMode) { + await startQuickMismatchMode(); + return; + } + const item = currentQuickMismatchItem(); + const binding = item ? summaryBinding(item) : null; + const candidate = binding && Array.isArray(binding.candidates) ? binding.candidates[0] : null; + if (!item || !candidate) { + selectFirstQuickMismatchItem(); + renderCurrentSummarySearch(); + updateQuickMismatchButton(); + return; + } + quickSummaryProcessing = true; + updateQuickMismatchButton(); + try { + await applySummaryBindingCandidate(item.id, { + student: candidate.student, + teacher: candidate.teacher, + time: candidate.time, + subject: candidate.subject, + }, { next: true }); + selectFirstQuickMismatchItem(); + renderCurrentSummarySearch(); + } finally { + quickSummaryProcessing = false; + updateQuickMismatchButton(); + } +} + async function saveSummaryReviewEdits() { if (!activeSummaryReview) return; try { @@ -1770,10 +1861,24 @@ document.addEventListener("keydown", (event) => { }); summarySearchForm.addEventListener("submit", (event) => { event.preventDefault(); + quickSummaryMismatchMode = false; loadSummarySearch(); }); -summarySearchBindingStatus.addEventListener("change", loadSummarySearch); -summarySearchHasCandidate.addEventListener("change", loadSummarySearch); +summarySearchBindingStatus.addEventListener("change", () => { + quickSummaryMismatchMode = false; + loadSummarySearch(); +}); +summarySearchHasCandidate.addEventListener("change", () => { + quickSummaryMismatchMode = false; + loadSummarySearch(); +}); +quickMismatchBtn.addEventListener("click", () => { + processQuickMismatchItem().catch((error) => { + quickSummaryProcessing = false; + updateQuickMismatchButton(); + alert(error.message); + }); +}); summarySearchRows.addEventListener("click", (event) => { const saveTime = event.target.closest(".summary-time-save"); const deleteSummary = event.target.closest(".summary-delete"); @@ -1928,4 +2033,5 @@ refreshBtn.addEventListener("click", () => { }); loadAdminHealth(); +updateQuickMismatchButton(); loadAccounts();