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 @@
-
课程小结查询
+
课程小结
+
@@ -396,6 +397,7 @@
+
@@ -467,6 +469,6 @@
-
+