优化课程小结自动绑定处理

This commit is contained in:
Codex
2026-06-20 20:02:18 +08:00
parent 6d410efb5c
commit 253c414e8f
3 changed files with 351 additions and 10 deletions
+235 -2
View File
@@ -1439,6 +1439,29 @@ def duration_minutes_from_time_range(time_range: str) -> int | None:
return end - start if end > start else 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: def duration_minutes_from_summary(summary: dict) -> int | None:
raw_duration = summary.get("duration") or summary.get("duration_text") or "" raw_duration = summary.get("duration") or summary.get("duration_text") or ""
if raw_duration: 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]: def course_summary_duplicate_key(item: dict) -> tuple[str, str, str, str, str]:
return course_summary_record_key( return course_summary_record_key(
str(item.get("student") or ""), 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) 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): for item in iter_course_summary_markdown(root):
time_range = str(item.get("time_range") or "") time_range = str(item.get("time_range") or "")
if not time_range: 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 ""), str(item.get("date_iso") or ""),
time_range, 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) index[key].append(item)
for key, values in list(index.items()): for key, values in list(index.items()):
values.sort(key=lambda item: (str(item.get("title") or ""), str(item.get("id") or ""))) 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: def course_summary_record_snapshot(record: ClassRecord) -> dict:
return { return {
"record_id": record_identity(record), "record_id": record_identity(record),
@@ -2571,6 +2656,21 @@ def course_summary_binding_status(item: dict, record_keys: dict[tuple[str, str,
"differences": [], "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_student = canonical_name(str(item.get("student") or ""))
item_date = str(item.get("date_iso") or "") item_date = str(item.get("date_iso") or "")
same_student_date = [ 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]: def auto_register_reasons(summary: dict, classnotes_path: Path, accounts_path: Path) -> tuple[list[str], str]:
reasons: list[str] = [] reasons: list[str] = []
confidence = str(summary.get("confidence") or "").lower() confidence = str(summary.get("confidence") or "").lower()
@@ -3723,6 +3900,44 @@ def register_course_summary_texts(
summaries_root, summaries_root,
target_key=course_summary_duplicate_key(normalized), 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) register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
seen_source_ids.add(source_id) seen_source_ids.add(source_id)
seen_semantic_keys.add(semantic_key) seen_semantic_keys.add(semantic_key)
@@ -3916,6 +4131,24 @@ def ingest_course_summaries(
reason = f"脚本追问:{question}" reason = f"脚本追问:{question}"
if reason not in reasons: if reason not in reasons:
reasons.append(reason) 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: if reasons:
task = create_course_summary_review_task( task = create_course_summary_review_task(
tasks_path, tasks_path,
@@ -4472,7 +4705,7 @@ def query_public_records(
matched = filter_records(records, spec) if has_filter_condition(spec) else [] matched = filter_records(records, spec) if has_filter_condition(spec) else []
shown = matched[:limit] if limit > 0 else matched shown = matched[:limit] if limit > 0 else matched
display_names = teacher_alias_map(teachers) 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 { return {
"query": { "query": {
"raw_query": spec.raw_query, "raw_query": spec.raw_query,
+5 -3
View File
@@ -24,7 +24,7 @@
<button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button> <button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
<button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button> <button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button>
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button> <button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结查询</button> <button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结</button>
<button class="admin-tab" data-admin-tab="logs" type="button">操作记录</button> <button class="admin-tab" data-admin-tab="logs" type="button">操作记录</button>
<button class="admin-tab" data-admin-tab="register" type="button">登记</button> <button class="admin-tab" data-admin-tab="register" type="button">登记</button>
</nav> </nav>
@@ -293,8 +293,9 @@
<section id="summarySearchPanel" class="panel admin-panel" hidden> <section id="summarySearchPanel" class="panel admin-panel" hidden>
<div class="section-head"> <div class="section-head">
<h2>课程小结查询</h2> <h2>课程小结</h2>
<div class="quick-actions"> <div class="quick-actions">
<button id="quickMismatchBtn" class="chip" type="button">快速处理字段不一致</button>
<button class="chip duplicate-summary-scan" type="button">扫描重复小结</button> <button class="chip duplicate-summary-scan" type="button">扫描重复小结</button>
</div> </div>
</div> </div>
@@ -396,6 +397,7 @@
<option value="">全部结果</option> <option value="">全部结果</option>
<option value="完成">完成</option> <option value="完成">完成</option>
<option value="自动入账">自动入账</option> <option value="自动入账">自动入账</option>
<option value="自动绑定">自动绑定</option>
<option value="待审核">待审核</option> <option value="待审核">待审核</option>
<option value="重复">重复</option> <option value="重复">重复</option>
<option value="已驳回">失败/驳回</option> <option value="已驳回">失败/驳回</option>
@@ -467,6 +469,6 @@
</section> </section>
</main> </main>
<script src="/static/admin.js?v=20260620-admin-health-summary"></script> <script src="/static/admin.js?v=20260620-course-summary-auto-bind"></script>
</body> </body>
</html> </html>
+111 -5
View File
@@ -84,6 +84,7 @@ const summarySearchBindingStatus = document.querySelector("#summarySearchBinding
const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate"); const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate");
const summarySearchMeta = document.querySelector("#summarySearchMeta"); const summarySearchMeta = document.querySelector("#summarySearchMeta");
const summarySearchRows = document.querySelector("#summarySearchRows"); const summarySearchRows = document.querySelector("#summarySearchRows");
const quickMismatchBtn = document.querySelector("#quickMismatchBtn");
const logOperation = document.querySelector("#logOperation"); const logOperation = document.querySelector("#logOperation");
const logStatus = document.querySelector("#logStatus"); const logStatus = document.querySelector("#logStatus");
const logFilterForm = document.querySelector("#logFilterForm"); const logFilterForm = document.querySelector("#logFilterForm");
@@ -116,6 +117,8 @@ let currentSummarySearchItems = [];
let expandedSummarySearchId = ""; let expandedSummarySearchId = "";
let editingSummarySearchId = ""; let editingSummarySearchId = "";
let editingSummaryIdentityId = ""; let editingSummaryIdentityId = "";
let quickSummaryMismatchMode = false;
let quickSummaryProcessing = false;
let currentOperationLogs = []; let currentOperationLogs = [];
let expandedOperationLogId = ""; let expandedOperationLogId = "";
const registerPreviewState = { const registerPreviewState = {
@@ -161,7 +164,7 @@ function statusClass(status) {
} }
function taskStatusClass(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 (["rejected", "duplicate", "已驳回", "重复"].includes(status)) return "closed";
if (status === "conflict" || status === "冲突") return "debt"; if (status === "conflict" || status === "冲突") return "debt";
return "warning"; 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) { function renderSummaryBindingDetail(item) {
const binding = summaryBinding(item); const binding = summaryBinding(item);
if (binding.status === "matched" && binding.record) { 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) === expandedSummarySearchId)) expandedSummarySearchId = "";
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = ""; if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = "";
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = ""; if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = "";
if (
quickSummaryMismatchMode
&& !mismatchItemsWithCandidates().some((item) => String(item.id) === String(expandedSummarySearchId))
) {
selectFirstQuickMismatchItem();
}
const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems); const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems);
summarySearchMeta.innerHTML = [ summarySearchMeta.innerHTML = [
metric("命中小结", `${data.count}`), metric("命中小结", `${data.count}`),
@@ -1094,9 +1141,11 @@ async function loadSummarySearch() {
metric("有候选", `${bindingSummary.withCandidate}`), metric("有候选", `${bindingSummary.withCandidate}`),
].join(""); ].join("");
renderCurrentSummarySearch(); renderCurrentSummarySearch();
updateQuickMismatchButton();
} catch (error) { } catch (error) {
currentSummarySearchItems = []; currentSummarySearchItems = [];
summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`; summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
updateQuickMismatchButton();
} }
} }
@@ -1150,7 +1199,7 @@ function findCurrentSummary(summaryId) {
return currentSummarySearchItems.find((item) => String(item.id) === String(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); const item = findCurrentSummary(summaryId);
if (!item) throw new Error("未找到当前课程小结"); if (!item) throw new Error("未找到当前课程小结");
let activeSummaryId = summaryId; let activeSummaryId = summaryId;
@@ -1180,11 +1229,53 @@ async function applySummaryBindingCandidate(summaryId, record) {
} }
editingSummaryIdentityId = ""; editingSummaryIdentityId = "";
editingSummarySearchId = ""; editingSummarySearchId = "";
expandedSummarySearchId = activeSummaryId; expandedSummarySearchId = options.next ? "" : activeSummaryId;
await loadSummarySearch(); await loadSummarySearch();
await loadOperationLogs(); 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() { async function saveSummaryReviewEdits() {
if (!activeSummaryReview) return; if (!activeSummaryReview) return;
try { try {
@@ -1770,10 +1861,24 @@ document.addEventListener("keydown", (event) => {
}); });
summarySearchForm.addEventListener("submit", (event) => { summarySearchForm.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
quickSummaryMismatchMode = false;
loadSummarySearch(); loadSummarySearch();
}); });
summarySearchBindingStatus.addEventListener("change", loadSummarySearch); summarySearchBindingStatus.addEventListener("change", () => {
summarySearchHasCandidate.addEventListener("change", loadSummarySearch); 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) => { summarySearchRows.addEventListener("click", (event) => {
const saveTime = event.target.closest(".summary-time-save"); const saveTime = event.target.closest(".summary-time-save");
const deleteSummary = event.target.closest(".summary-delete"); const deleteSummary = event.target.closest(".summary-delete");
@@ -1928,4 +2033,5 @@ refreshBtn.addEventListener("click", () => {
}); });
loadAdminHealth(); loadAdminHealth();
updateQuickMismatchButton();
loadAccounts(); loadAccounts();