Files
xsk-education-management/app/app/data.py
T
2026-06-30 11:35:25 +08:00

5061 lines
202 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from collections import defaultdict
from dataclasses import replace
from datetime import date, datetime, timedelta
import hashlib
import json
import re
from pathlib import Path
from typing import Iterable
from .domain import (
ACCOUNT_STATUSES,
AUTO_RECOGNITION_SOURCES,
Account,
ClassRecord,
DuplicateRecordError,
FALLBACK_ALIASES,
HIGH_CONFIDENCE_VALUES,
Payment,
QuerySpec,
ROLE_WORDS,
SUBJECTS,
SUBJECT_ALIASES,
TEACHER_STATUSES,
Teacher,
UNKNOWN_SUBJECTS,
UNKNOWN_TEACHERS,
WEEKDAYS,
)
from .storage import BACKUP_DIR_RE, atomic_write_text, create_data_backup, prune_data_backups
CLASSNOTE_RE = re.compile(
r"^(?P<date>\d{4}\.\d{2}\.\d{2})-"
r"(?P<weekday>星期[一二三四五六日])-"
r"(?P<time>\d{1,2}:\d{2}-\d{1,2}:\d{2})-"
r"(?P<student>[^-]+)-"
r"(?P<duration>\d+小时\d+分)-"
r"(?P<teacher>[^-]+)-"
r"(?P<subject>.+)$"
)
PAYMENT_LINE_RE = re.compile(r"^(?P<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P<hours>\d+(?:\.\d+)?)$")
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P<title>.+)$", re.M)
COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})")
SUMMARY_FIELD_RE = re.compile(r"^(?P<label>学生|学员|日期|上课日期|时间|上课时间|老师|教师|科目|课程|班级|分组|正文|内容|小结)[::]\s*(?P<value>.*)$")
TIME_RANGE_SEPARATOR = r"[--–—~到至‐‑‒]"
DATE_RANGE_SEPARATOR = r"(?:到|至||-|~|—||||)"
CHINESE_DATE_RANGE_RE = re.compile(
rf"(?:(?P<sy>\d{{4}})\s*年\s*)?"
rf"(?P<sm>\d{{1,2}})\s*月\s*(?P<sd>\d{{1,2}})\s*[日号]?\s*"
rf"{DATE_RANGE_SEPARATOR}\s*"
rf"(?:(?P<ey>\d{{4}})\s*年\s*)?"
rf"(?:(?P<em>\d{{1,2}})\s*月\s*)?"
rf"(?P<ed>\d{{1,2}})\s*[日号]?"
)
NUMERIC_DATE_RANGE_RE = re.compile(
rf"(?<!\d)"
rf"(?:(?P<sy>\d{{4}})[./-])?"
rf"(?P<sm>\d{{1,2}})[./-](?P<sd>\d{{1,2}})\s*"
rf"{DATE_RANGE_SEPARATOR}\s*"
rf"(?:(?P<ey>\d{{4}})[./-])?"
rf"(?:(?P<em>\d{{1,2}})[./-])?"
rf"(?P<ed>\d{{1,2}})"
rf"(?!\d)"
)
def canonical_name(name: str) -> str:
text = name.strip()
return FALLBACK_ALIASES.get(text, text)
def canonical_teacher_name(name: str) -> str:
text = canonical_name(name).strip()
return re.sub(r"(?:老师|教师)$", "", text).strip()
def parse_hours_text(text: str) -> float:
match = re.fullmatch(r"(\d+)小时(\d+)分", text.strip())
if not match:
raise ValueError(f"无法解析时长: {text}")
hours, minutes = map(int, match.groups())
return hours + minutes / 60.0
def format_number(value: float) -> str:
if float(value).is_integer():
return str(int(value))
return f"{value:.2f}".rstrip("0").rstrip(".")
def format_payment(payment: Payment) -> str:
return f"{payment.date}:{format_number(payment.hours)}"
def format_account_row(account: Account) -> str:
payments = ",".join(format_payment(payment) for payment in account.payments)
primary_entry_year = str(account.primary_entry_year or "")
return (
f"| {account.student_id} | {account.student} | {primary_entry_year} | {payments} | "
f"{format_number(account.remaining)} | {account.account_status} | {account.note} |"
)
def format_teacher_row(teacher: Teacher) -> str:
subjects = "".join(teacher.subjects)
return f"| {teacher.teacher_id} | {teacher.name} | {teacher.alias} | {subjects} | {teacher.status} | {teacher.note} |"
def paginate_items(items: list, offset: int = 0, limit: int = 100) -> tuple[list, int, bool]:
normalized_offset = max(int(offset or 0), 0)
normalized_limit = max(int(limit or 0), 0)
if normalized_limit <= 0:
return items[normalized_offset:], normalized_offset, False
end = normalized_offset + normalized_limit
return items[normalized_offset:end], normalized_offset, end < len(items)
def parse_payments(text: str) -> list[Payment]:
payments: list[Payment] = []
if not text.strip():
return payments
for item in text.split(","):
item = item.strip()
if not item or ":" not in item:
continue
paid_date, hours_text = item.split(":", 1)
payments.append(Payment(date=paid_date.strip(), hours=float(hours_text.strip())))
return payments
def validate_payment(payment: Payment) -> Payment:
try:
datetime.strptime(payment.date, "%Y-%m-%d")
except ValueError as exc:
raise ValueError(f"缴费日期不存在: {payment.date}") from exc
return Payment(date=payment.date, hours=round(float(payment.hours), 2))
def validate_account(account: Account) -> Account:
student_id = account.student_id.strip()
student = canonical_name(account.student)
primary_entry_year = account.primary_entry_year
if not re.fullmatch(r"XS\d{3}", student_id):
raise ValueError("学生ID格式应为 XS001 这样的三位编号")
if not student:
raise ValueError("学生姓名不能为空")
if account.account_status not in ACCOUNT_STATUSES:
raise ValueError("档案状态必须是 正常、预警、欠费、结课、退费")
if primary_entry_year is not None and not 1900 <= int(primary_entry_year) <= 2100:
raise ValueError("入学年份必须是 1900 到 2100 之间的年份")
return Account(
student_id=student_id,
student=student,
payments=[validate_payment(payment) for payment in account.payments],
remaining=round(float(account.remaining), 2),
account_status=account.account_status,
primary_entry_year=int(primary_entry_year) if primary_entry_year is not None else None,
note=account.note.strip(),
)
def validate_teacher(teacher: Teacher) -> Teacher:
teacher_id = teacher.teacher_id.strip()
name = canonical_name(teacher.name)
alias = teacher.alias.strip()
subjects = [normalize_subject(subject) for subject in teacher.subjects if normalize_subject(subject)]
deduped_subjects = list(dict.fromkeys(subjects))
status = teacher.status.strip()
note = teacher.note.strip()
if not re.fullmatch(r"T\d{3}", teacher_id):
raise ValueError("教师ID格式应为 T001 这样的三位编号")
if not name:
raise ValueError("教师姓名不能为空")
if "|" in name or "|" in alias or "|" in note:
raise ValueError("教师姓名、别名和备注不能包含 |")
if not deduped_subjects:
raise ValueError("任教学科不能为空")
if status not in TEACHER_STATUSES:
raise ValueError("教师状态必须是 在岗 或 离职")
return Teacher(
teacher_id=teacher_id,
name=name,
alias=alias,
subjects=deduped_subjects,
status=status,
note=note,
)
def read_classnotes(path: Path) -> list[ClassRecord]:
records: list[ClassRecord] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, raw_line in enumerate(handle, start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
match = CLASSNOTE_RE.fullmatch(line)
if not match:
raise ValueError(f"{path}:{line_number} 无法解析课程记录行: {line}")
true_minutes = parse_time_range_minutes(match.group("time"))
records.append(
ClassRecord(
date=match.group("date"),
weekday=match.group("weekday"),
time=match.group("time"),
student=canonical_name(match.group("student")),
duration=duration_text_from_minutes(true_minutes),
duration_hours=round(true_minutes / 60.0, 2),
teacher=canonical_teacher_name(match.group("teacher")),
subject=match.group("subject").strip(),
)
)
return records
def read_accounts(path: Path) -> list[Account]:
accounts: list[Account] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, raw_line in enumerate(handle, start=1):
line = raw_line.strip()
compact = line.strip("|").replace(" ", "").replace("|", "")
if not line.startswith("|") or set(compact) <= {"-"} or "学生ID" in line:
continue
parts = [part.strip() for part in line.strip("|").split("|")]
if len(parts) < 5:
continue
if len(parts) >= 7:
student_id, student, primary_entry_year_text, payments_text, remaining_text, status_text, note_text = parts[:7]
else:
student_id, student, payments_text, remaining_text, status_text = parts[:5]
primary_entry_year_text = ""
note_text = parts[5] if len(parts) > 5 else ""
try:
remaining = float(remaining_text)
except ValueError as exc:
raise ValueError(f"{path}:{line_number} 无法解析剩余课时: {remaining_text}") from exc
try:
primary_entry_year = int(primary_entry_year_text) if primary_entry_year_text else None
except ValueError as exc:
raise ValueError(f"{path}:{line_number} 无法解析入学年份: {primary_entry_year_text}") from exc
accounts.append(
Account(
student_id=student_id,
student=canonical_name(student),
payments=parse_payments(payments_text),
remaining=remaining,
account_status=status_text,
primary_entry_year=primary_entry_year,
note=note_text,
)
)
return accounts
def parse_teacher_subjects(text: str) -> list[str]:
subjects: list[str] = []
for item in re.split(r"[、,\s]+", text.strip()):
subject = normalize_subject(item)
if subject and subject not in subjects:
subjects.append(subject)
return subjects
def read_teachers(path: Path) -> list[Teacher]:
if not path.exists():
return []
teachers: list[Teacher] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, raw_line in enumerate(handle, start=1):
line = raw_line.strip()
compact = line.strip("|").replace(" ", "").replace("|", "")
if not line.startswith("|") or set(compact) <= {"-"} or "教师ID" in line:
continue
parts = [part.strip() for part in line.strip("|").split("|")]
if len(parts) == 5:
teacher_id, name, subjects, status, note = parts
alias = ""
elif len(parts) >= 6:
teacher_id, name, alias, subjects, status, note = parts[:6]
else:
continue
try:
teachers.append(
validate_teacher(
Teacher(
teacher_id=teacher_id,
name=canonical_teacher_name(name),
alias=alias.strip(),
subjects=parse_teacher_subjects(subjects),
status=status,
note=note,
)
)
)
except ValueError as exc:
raise ValueError(f"{path}:{line_number} 教师档案错误: {exc}") from exc
return teachers
def parse_class_record_line(line: str) -> ClassRecord:
raw = line.strip()
match = CLASSNOTE_RE.fullmatch(raw)
if not match:
raise ValueError(f"上课记录格式错误: {line}")
record_date = parse_record_date(match.group("date"))
expected_weekday = WEEKDAYS[record_date.weekday()]
if match.group("weekday") != expected_weekday:
raise ValueError(f"上课记录星期错误: {line},应为 {expected_weekday}")
duration = match.group("duration")
duration_hours = parse_hours_text(duration)
time_hours = parse_time_range_hours(match.group("time"))
if abs(duration_hours - time_hours) > 0.01:
raise ValueError(f"上课记录时长与时间段不一致: {line}")
student = canonical_name(match.group("student"))
teacher = canonical_teacher_name(match.group("teacher"))
subject = match.group("subject").strip()
if not student or not teacher or not subject:
raise ValueError(f"上课记录学生、老师、科目不能为空: {line}")
return ClassRecord(
date=match.group("date"),
weekday=match.group("weekday"),
time=match.group("time"),
student=student,
duration=duration,
duration_hours=duration_hours,
teacher=teacher,
subject=subject,
)
def parse_time_range_minutes(text: str) -> int:
match = TIME_RANGE_RE.fullmatch(text.strip())
if not match:
raise ValueError(f"无法解析时间段: {text}")
start_hour = int(match.group("sh"))
start_minute = int(match.group("sm"))
end_hour = int(match.group("eh"))
end_minute = int(match.group("em"))
if start_hour > 23 or end_hour > 23:
raise ValueError(f"时间段小时超出范围: {text}")
if start_minute > 59 or end_minute > 59:
raise ValueError(f"时间段分钟超出范围: {text}")
start_total = start_hour * 60 + start_minute
end_total = end_hour * 60 + end_minute
if end_total <= start_total:
raise ValueError(f"结束时间必须晚于开始时间: {text}")
return end_total - start_total
def parse_time_range_hours(text: str) -> float:
return parse_time_range_minutes(text) / 60.0
def class_record_to_line(record: ClassRecord) -> str:
return (
f"{record.date}-{record.weekday}-{record.time}-{record.student}-"
f"{record.duration}-{record.teacher}-{record.subject}"
)
def parse_payment_line(line: str) -> tuple[str, Payment]:
raw = line.strip()
match = PAYMENT_LINE_RE.fullmatch(raw)
if not match:
raise ValueError(f"缴费记录格式错误: {line}")
student = canonical_name(match.group("student"))
payment_date = match.group("date")
try:
datetime.strptime(payment_date, "%Y-%m-%d")
except ValueError as exc:
raise ValueError(f"缴费日期不存在: {payment_date}") from exc
hours = float(match.group("hours"))
if hours <= 0:
raise ValueError(f"缴费课时必须大于 0: {line}")
return student, Payment(date=payment_date, hours=hours)
def recalc_account_status(account: Account) -> str:
if account.account_status in {"结课", "退费"}:
return account.account_status
if account.remaining < 0:
return "欠费"
if account.remaining < 10:
return "预警"
return "正常"
def find_account_index(accounts: list[Account], student: str) -> int:
canonical_student = canonical_name(student)
for index, account in enumerate(accounts):
if account.student == canonical_student or account.student_id == canonical_student:
return index
raise ValueError(f"未找到学生档案: {student}")
def update_account_remaining(account: Account, delta_hours: float) -> Account:
updated = replace(account, remaining=round(account.remaining + delta_hours, 2))
return replace(updated, account_status=recalc_account_status(updated))
def append_account_payment(account: Account, payment: Payment) -> Account:
updated = replace(
account,
payments=[*account.payments, payment],
remaining=round(account.remaining + payment.hours, 2),
)
return replace(updated, account_status=recalc_account_status(updated))
def used_hours_for_student(records: list[ClassRecord], student: str) -> float:
canonical_student = canonical_name(student)
return round(sum(record.duration_hours for record in records if record.student == canonical_student), 2)
def derive_account_remaining(account: Account, records: list[ClassRecord]) -> Account:
paid = round(sum(payment.hours for payment in account.payments), 2)
used = used_hours_for_student(records, account.student)
updated = replace(account, remaining=round(paid - used, 2))
return replace(updated, account_status=recalc_account_status(updated))
def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account]) -> str:
lines = original_text.splitlines()
output: list[str] = []
for line in lines:
stripped = line.strip()
compact = stripped.strip("|").replace(" ", "").replace("|", "")
if stripped.startswith("|") and not set(compact) <= {"-"} and "学生ID" not in stripped:
parts = [part.strip() for part in stripped.strip("|").split("|")]
if parts and parts[0] in accounts_by_id:
output.append(format_account_row(accounts_by_id[parts[0]]))
continue
output.append(line)
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(output) + trailing_newline
def append_account_line(original_text: str, account: Account) -> str:
lines = original_text.splitlines()
insert_at = len(lines)
for index, line in enumerate(lines):
stripped = line.strip()
compact = stripped.strip("|").replace(" ", "").replace("|", "")
if stripped.startswith("|") and not set(compact) <= {"-"} and "学生ID" not in stripped:
insert_at = index + 1
lines.insert(insert_at, format_account_row(account))
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(lines) + trailing_newline
def replace_single_account_line(original_text: str, old_student_id: str, account: Account) -> str:
lines = original_text.splitlines()
replaced = False
output: list[str] = []
for line in lines:
stripped = line.strip()
compact = stripped.strip("|").replace(" ", "").replace("|", "")
if stripped.startswith("|") and not set(compact) <= {"-"} and "学生ID" not in stripped:
parts = [part.strip() for part in stripped.strip("|").split("|")]
if parts and parts[0] == old_student_id:
output.append(format_account_row(account))
replaced = True
continue
output.append(line)
if not replaced:
raise ValueError(f"未找到学生档案: {old_student_id}")
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(output) + trailing_newline
def next_student_id(accounts: list[Account]) -> str:
values = []
for account in accounts:
match = re.fullmatch(r"XS(\d{3})", account.student_id)
if match:
values.append(int(match.group(1)))
return f"XS{(max(values) if values else 0) + 1:03d}"
def write_accounts(path: Path, accounts: list[Account]) -> None:
original_text = path.read_text(encoding="utf-8")
accounts_by_id = {account.student_id: account for account in accounts}
atomic_write_text(path, replace_account_lines(original_text, accounts_by_id))
def next_teacher_id(teachers: list[Teacher]) -> str:
values = []
for teacher in teachers:
match = re.fullmatch(r"T(\d{3})", teacher.teacher_id)
if match:
values.append(int(match.group(1)))
return f"T{(max(values) if values else 0) + 1:03d}"
def replace_teacher_lines(original_text: str, teachers_by_id: dict[str, Teacher]) -> str:
lines = original_text.splitlines()
output: list[str] = []
for line in lines:
stripped = line.strip()
compact = stripped.strip("|").replace(" ", "").replace("|", "")
if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped:
parts = [part.strip() for part in stripped.strip("|").split("|")]
if parts and parts[0] in teachers_by_id:
output.append(format_teacher_row(teachers_by_id[parts[0]]))
continue
output.append(line)
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(output) + trailing_newline
def append_teacher_line(original_text: str, teacher: Teacher) -> str:
if not original_text.strip():
original_text = (
"| 教师ID | 教师姓名 | 别名 | 任教学科 | 状态 | 备注 |\n"
"| ------ | -------- | ---- | -------- | ------ | ---- |\n"
)
lines = original_text.splitlines()
insert_at = len(lines)
for index, line in enumerate(lines):
stripped = line.strip()
compact = stripped.strip("|").replace(" ", "").replace("|", "")
if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped:
insert_at = index + 1
lines.insert(insert_at, format_teacher_row(teacher))
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(lines) + trailing_newline
def replace_single_teacher_line(original_text: str, old_teacher_id: str, teacher: Teacher) -> str:
lines = original_text.splitlines()
replaced = False
output: list[str] = []
for line in lines:
stripped = line.strip()
compact = stripped.strip("|").replace(" ", "").replace("|", "")
if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped:
parts = [part.strip() for part in stripped.strip("|").split("|")]
if parts and parts[0] == old_teacher_id:
output.append(format_teacher_row(teacher))
replaced = True
continue
output.append(line)
if not replaced:
raise ValueError(f"未找到教师档案: {old_teacher_id}")
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(output) + trailing_newline
def write_teachers(path: Path, teachers: list[Teacher]) -> None:
original_text = path.read_text(encoding="utf-8") if path.exists() else ""
teachers_by_id = {teacher.teacher_id: teacher for teacher in teachers}
atomic_write_text(path, replace_teacher_lines(original_text, teachers_by_id))
def create_teacher(path: Path, teacher: Teacher) -> dict:
teachers = read_teachers(path)
teacher = validate_teacher(replace(teacher, teacher_id=next_teacher_id(teachers)))
if any(item.teacher_id == teacher.teacher_id for item in teachers):
raise ValueError(f"教师ID已存在: {teacher.teacher_id}")
original_text = path.read_text(encoding="utf-8") if path.exists() else ""
backup_dir = create_data_backup("admin-create-teacher", {path: original_text}, [format_teacher_row(teacher)])
atomic_write_text(path, append_teacher_line(original_text, teacher))
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name, "operation": "新增老师档案"}
def update_teacher(path: Path, old_teacher_id: str, teacher: Teacher) -> dict:
old_teacher_id = old_teacher_id.strip()
teachers = read_teachers(path)
teacher = validate_teacher(teacher)
if not any(item.teacher_id == old_teacher_id for item in teachers):
raise ValueError(f"未找到教师档案: {old_teacher_id}")
if teacher.teacher_id != old_teacher_id and any(item.teacher_id == teacher.teacher_id for item in teachers):
raise ValueError(f"教师ID已存在: {teacher.teacher_id}")
original_text = path.read_text(encoding="utf-8") if path.exists() else ""
backup_dir = create_data_backup("admin-update-teacher", {path: original_text}, [old_teacher_id, format_teacher_row(teacher)])
atomic_write_text(path, replace_single_teacher_line(original_text, old_teacher_id, teacher))
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name, "operation": "修改老师档案"}
def teacher_to_dict(teacher: Teacher) -> dict:
return {
"teacher_id": teacher.teacher_id,
"name": teacher.name,
"alias": teacher.alias,
"display_name": teacher.alias or teacher.name,
"subjects": teacher.subjects,
"status": teacher.status,
"note": teacher.note,
}
def teacher_alias_map(teachers: list[Teacher]) -> dict[str, str]:
mapping: dict[str, str] = {}
for teacher in teachers:
display = teacher.alias or teacher.name
mapping[teacher.name] = display
if teacher.alias:
mapping[teacher.alias] = display
mapping[teacher.teacher_id] = display
return mapping
def teacher_name_map(teachers: list[Teacher]) -> dict[str, Teacher]:
mapping: dict[str, Teacher] = {}
for teacher in teachers:
mapping[teacher.name] = teacher
if teacher.alias:
mapping[teacher.alias] = teacher
mapping[teacher.teacher_id] = teacher
return mapping
def create_account(path: Path, account: Account, classnotes_path: Path | None = None) -> dict:
accounts = read_accounts(path)
records = read_classnotes(classnotes_path) if classnotes_path and classnotes_path.exists() else []
account = derive_account_remaining(
validate_account(replace(account, student_id=next_student_id(accounts))),
records,
)
if any(item.student_id == account.student_id for item in accounts):
raise ValueError(f"学生ID已存在: {account.student_id}")
original_accounts = path.read_text(encoding="utf-8")
backup_dir = create_data_backup("admin-create-account", {path: original_accounts}, [format_account_row(account)])
atomic_write_text(path, append_account_line(original_accounts, account))
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "新增学生档案"}
def update_account(path: Path, old_student_id: str, account: Account, classnotes_path: Path | None = None) -> dict:
old_student_id = old_student_id.strip()
accounts = read_accounts(path)
records = read_classnotes(classnotes_path) if classnotes_path and classnotes_path.exists() else []
account = derive_account_remaining(validate_account(account), records)
if not any(item.student_id == old_student_id for item in accounts):
raise ValueError(f"未找到学生档案: {old_student_id}")
if account.student_id != old_student_id and any(item.student_id == account.student_id for item in accounts):
raise ValueError(f"学生ID已存在: {account.student_id}")
original_accounts = path.read_text(encoding="utf-8")
backup_dir = create_data_backup("admin-update-account", {path: original_accounts}, [old_student_id, format_account_row(account)])
atomic_write_text(path, replace_single_account_line(original_accounts, old_student_id, account))
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "修改学生档案"}
def render_accounts_text(path: Path, accounts: list[Account]) -> str:
original_text = path.read_text(encoding="utf-8")
accounts_by_id = {account.student_id: account for account in accounts}
return replace_account_lines(original_text, accounts_by_id)
def render_accounts_text_from_text(original_text: str, accounts: list[Account]) -> str:
accounts_by_id = {account.student_id: account for account in accounts}
return replace_account_lines(original_text, accounts_by_id)
def normalize_lines(lines: list[str] | None = None, line: str | None = None) -> list[str]:
values: list[str] = []
if line is not None:
values.append(line)
if lines is not None:
values.extend(lines)
result = [item.strip() for item in values if item and item.strip()]
if not result:
raise ValueError("登记内容不能为空")
return result
def register_class_record_lines(
classnotes_path: Path,
accounts_path: Path,
lines: list[str] | None = None,
line: str | None = None,
) -> dict:
input_lines = normalize_lines(lines=lines, line=line)
records = [parse_class_record_line(item) for item in input_lines]
record_lines = [class_record_to_line(record) for record in records]
if len(record_lines) != len(set(record_lines)):
raise DuplicateRecordError("本次提交包含重复上课记录")
existing_lines = {raw.strip() for raw in classnotes_path.read_text(encoding="utf-8").splitlines()}
duplicates = [item for item in record_lines if item in existing_lines]
if duplicates:
raise DuplicateRecordError(f"上课记录已存在: {duplicates[0]}")
accounts = read_accounts(accounts_path)
updated_accounts = list(accounts)
updated_account_ids: set[str] = set()
for record in records:
account_index = find_account_index(updated_accounts, record.student)
updated_accounts[account_index] = update_account_remaining(
updated_accounts[account_index],
-record.duration_hours,
)
updated_account_ids.add(updated_accounts[account_index].student_id)
original_classnotes = classnotes_path.read_text(encoding="utf-8")
original_accounts = accounts_path.read_text(encoding="utf-8")
separator = "" if not original_classnotes or original_classnotes.endswith("\n") else "\n"
new_classnotes = f"{original_classnotes}{separator}" + "".join(f"{item}\n" for item in record_lines)
updated_accounts_by_id = {
account.student_id: account
for account in updated_accounts
if account.student_id in updated_account_ids
}
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
backup_dir = create_data_backup(
"register-class-records",
{
accounts_path: original_accounts,
classnotes_path: original_classnotes,
},
record_lines,
)
try:
atomic_write_text(accounts_path, new_accounts)
atomic_write_text(classnotes_path, new_classnotes)
except Exception:
atomic_write_text(accounts_path, original_accounts)
atomic_write_text(classnotes_path, original_classnotes)
raise
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"registered": len(record_lines), "lines": record_lines, "backup_id": backup_dir.name, "operation": "登记上课记录"}
def register_payment_lines(
accounts_path: Path,
lines: list[str] | None = None,
line: str | None = None,
) -> dict:
input_lines = normalize_lines(lines=lines, line=line)
payments = [parse_payment_line(item) for item in input_lines]
accounts = read_accounts(accounts_path)
updated_accounts = list(accounts)
updated_account_ids: set[str] = set()
registered: list[str] = []
for student, payment in payments:
account_index = find_account_index(updated_accounts, student)
updated_accounts[account_index] = append_account_payment(updated_accounts[account_index], payment)
updated_account_ids.add(updated_accounts[account_index].student_id)
registered.append(f"{student}-{format_payment(payment)}")
original_accounts = accounts_path.read_text(encoding="utf-8")
updated_accounts_by_id = {
account.student_id: account
for account in updated_accounts
if account.student_id in updated_account_ids
}
backup_dir = create_data_backup(
"register-payments",
{accounts_path: original_accounts},
registered,
)
atomic_write_text(accounts_path, replace_account_lines(original_accounts, updated_accounts_by_id))
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name, "operation": "登记缴费记录"}
def normalize_classnote_durations_and_accounts(
classnotes_path: Path,
accounts_path: Path,
operation_logs_path: Path | None = None,
) -> dict:
original_classnotes = classnotes_path.read_text(encoding="utf-8")
original_accounts = accounts_path.read_text(encoding="utf-8")
accounts = read_accounts(accounts_path)
updated_accounts = list(accounts)
account_deltas: defaultdict[str, float] = defaultdict(float)
changed_lines: list[str] = []
output_lines: list[str] = []
skipped_lines: list[str] = []
for line_number, raw_line in enumerate(original_classnotes.splitlines(), start=1):
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
output_lines.append(raw_line)
continue
match = CLASSNOTE_RE.fullmatch(stripped)
if not match:
output_lines.append(raw_line)
skipped_lines.append(f"{line_number}: {stripped}")
continue
old_minutes = int(round(parse_hours_text(match.group("duration")) * 60))
true_minutes = parse_time_range_minutes(match.group("time"))
true_duration = duration_text_from_minutes(true_minutes)
if true_minutes == old_minutes and match.group("duration") == true_duration:
output_lines.append(raw_line)
continue
student = canonical_name(match.group("student"))
account_index = find_account_index(updated_accounts, student)
delta_hours = round((old_minutes - true_minutes) / 60.0, 2)
if delta_hours:
updated_accounts[account_index] = update_account_remaining(updated_accounts[account_index], delta_hours)
account_deltas[updated_accounts[account_index].student_id] += delta_hours
new_line = (
f"{match.group('date')}-{match.group('weekday')}-{match.group('time')}-"
f"{student}-{true_duration}-{match.group('teacher').strip()}-{match.group('subject').strip()}"
)
output_lines.append(new_line)
changed_lines.append(f"{line_number}: {stripped} => {new_line}")
if not changed_lines:
return {
"updated_records": 0,
"updated_accounts": 0,
"account_deltas": {},
"skipped_lines": skipped_lines,
"backup_id": "",
}
trailing_newline = "\n" if original_classnotes.endswith("\n") else ""
new_classnotes = "\n".join(output_lines) + trailing_newline
changed_account_ids = {account_id for account_id, delta in account_deltas.items() if round(delta, 2)}
updated_accounts_by_id = {
account.student_id: account
for account in updated_accounts
if account.student_id in changed_account_ids
}
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
backup_dir = create_data_backup(
"normalize-classnote-durations",
{
accounts_path: original_accounts,
classnotes_path: original_classnotes,
},
changed_lines,
)
try:
atomic_write_text(classnotes_path, new_classnotes)
atomic_write_text(accounts_path, new_accounts)
except Exception:
atomic_write_text(classnotes_path, original_classnotes)
atomic_write_text(accounts_path, original_accounts)
raise
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
result = {
"updated_records": len(changed_lines),
"updated_accounts": len(updated_accounts_by_id),
"account_deltas": {
account_id: round(delta, 2)
for account_id, delta in sorted(account_deltas.items())
if round(delta, 2)
},
"skipped_lines": skipped_lines,
"backup_id": backup_dir.name,
}
if operation_logs_path is not None:
append_operation_log(
operation_logs_path,
"历史上课记录真实时长迁移",
"完成",
backup_id=backup_dir.name,
updated_records=result["updated_records"],
updated_accounts=result["updated_accounts"],
account_deltas=result["account_deltas"],
skipped_lines_count=len(skipped_lines),
)
return result
def default_admin_tasks() -> dict:
return {"version": 1, "next_id": 1, "items": []}
def read_admin_tasks(path: Path) -> dict:
if not path.exists():
return default_admin_tasks()
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"管理任务文件 JSON 格式错误: {path}") from exc
if not isinstance(payload, dict):
raise ValueError("管理任务文件必须是 JSON 对象")
payload.setdefault("version", 1)
payload.setdefault("next_id", 1)
payload.setdefault("items", [])
if not isinstance(payload["items"], list):
raise ValueError("管理任务 items 必须是数组")
return payload
def write_admin_tasks(path: Path, tasks: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text(path, json.dumps(tasks, ensure_ascii=False, indent=2) + "\n")
def task_to_dict(task: dict) -> dict:
item = dict(task)
if item.get("type") == "course_summary_review":
summary = item.get("summary") or {}
if isinstance(summary, dict) and not summary.get("time_range"):
suggested_time = parse_course_summary_time_text(
"\n".join(
str(value or "")
for value in (summary.get("title"), summary.get("body"))
if value
)
)
if suggested_time:
item["suggested_time_range"] = suggested_time
minutes = duration_minutes_from_time_range(suggested_time)
if minutes is not None:
item["suggested_duration"] = duration_text_from_minutes(minutes)
return item
def course_summary_duplicate_candidates_with_binding(
candidates: list[dict],
records: list[ClassRecord],
) -> list[dict]:
record_keys = {class_record_binding_key(record): record for record in records}
return [
{
**candidate,
"binding": course_summary_binding_status(candidate, record_keys, records),
}
for candidate in candidates
]
def task_to_dict_with_context(task: dict, records: list[ClassRecord] | None = None) -> dict:
item = task_to_dict(task)
if records is not None and item.get("type") == "course_summary_duplicate_review":
item["duplicate_candidates"] = course_summary_duplicate_candidates_with_binding(
item.get("duplicate_candidates") or [],
records,
)
return item
def find_admin_task(tasks: dict, task_id: int) -> dict:
for task in tasks["items"]:
if int(task.get("id", 0)) == task_id:
return task
raise ValueError(f"未找到管理任务: {task_id}")
def submit_correction_tasks(tasks_path: Path, items: list[dict]) -> dict:
if not items:
raise ValueError("提交审核的纠错记录不能为空")
tasks = read_admin_tasks(tasks_path)
now = datetime.now().isoformat(timespec="seconds")
created: list[dict] = []
for item in items:
original_line = str(item.get("original_line", "")).strip()
corrected_line = str(item.get("corrected_line", "")).strip()
if not original_line or not corrected_line:
raise ValueError("纠错审核记录缺少原记录或修改后记录")
original = parse_class_record_line(original_line)
corrected = parse_class_record_line(corrected_line)
if original_line == corrected_line:
raise ValueError("原记录和修改后记录相同,无需提交审核")
task = {
"id": int(tasks["next_id"]),
"type": "class_record_correction",
"status": "pending",
"created_at": now,
"updated_at": now,
"original_line": class_record_to_line(original),
"corrected_line": class_record_to_line(corrected),
"original": record_to_dict(original),
"corrected": record_to_dict(corrected),
}
tasks["next_id"] = int(tasks["next_id"]) + 1
tasks["items"].append(task)
created.append(task)
write_admin_tasks(tasks_path, tasks)
return {"submitted": len(created), "items": [task_to_dict(task) for task in created]}
def submit_deletion_tasks(tasks_path: Path, items: list[dict]) -> dict:
if not items:
raise ValueError("提交审核的删除记录不能为空")
tasks = read_admin_tasks(tasks_path)
now = datetime.now().isoformat(timespec="seconds")
created: list[dict] = []
for item in items:
original_line = str(item.get("original_line", "")).strip()
if not original_line:
raise ValueError("删除审核记录缺少原记录")
original = parse_class_record_line(original_line)
task = {
"id": int(tasks["next_id"]),
"type": "class_record_deletion",
"status": "pending",
"created_at": now,
"updated_at": now,
"original_line": class_record_to_line(original),
"original": record_to_dict(original),
"student": original.student,
"reasons": ["申请删除课程记录"],
}
tasks["next_id"] = int(tasks["next_id"]) + 1
tasks["items"].append(task)
created.append(task)
write_admin_tasks(tasks_path, tasks)
return {"submitted": len(created), "items": [task_to_dict(task) for task in created]}
def resolve_teacher_input(value: str, teachers: list[Teacher]) -> str:
text = value.strip()
if not text:
raise ValueError("老师不能为空")
matches = [
teacher
for teacher in teachers
if text in {teacher.teacher_id, teacher.name, teacher.alias}
]
if len(matches) == 1:
return matches[0].name
if len(matches) > 1:
raise ValueError(f"老师别名不唯一,请在后台修正别名: {text}")
return canonical_name(text)
def resolve_existing_teacher_input(value: str, teachers: list[Teacher]) -> str:
text = value.strip()
if not text:
raise ValueError("老师不能为空")
matches = [
teacher
for teacher in teachers
if text in {teacher.teacher_id, teacher.name, teacher.alias}
]
if len(matches) == 1:
return matches[0].name
if len(matches) > 1:
raise ValueError(f"老师别名不唯一,请在后台修正别名: {text}")
raise ValueError(f"老师不存在,请先在教师档案中维护: {text}")
def class_record_from_public_item(original: ClassRecord, item: dict, teachers: list[Teacher]) -> ClassRecord:
date_text = str(item.get("date") or original.date)
time_text = str(item.get("time") or original.time)
student = canonical_name(str(item.get("student") or original.student))
teacher = resolve_teacher_input(str(item.get("teacher") or original.teacher), teachers)
subject = normalize_subject(str(item.get("subject") or original.subject))
record_date = parse_record_date(date_text)
weekday = WEEKDAYS[record_date.weekday()]
duration_hours = parse_time_range_hours(time_text)
minutes = int(round(duration_hours * 60))
duration = duration_text_from_minutes(minutes)
return ClassRecord(
date=record_date.strftime("%Y.%m.%d"),
weekday=weekday,
time=normalize_time_range_text(time_text),
student=student,
duration=duration,
duration_hours=duration_hours,
teacher=teacher,
subject=subject,
)
def submit_public_correction_tasks(tasks_path: Path, records: list[ClassRecord], teachers: list[Teacher], items: list[dict]) -> dict:
if not items:
raise ValueError("提交审核的纠错记录不能为空")
internal_items: list[dict] = []
for item in items:
original = find_record_by_identity(records, str(item.get("record_id") or ""))
corrected = class_record_from_public_item(original, item, teachers)
internal_items.append(
{
"original_line": class_record_to_line(original),
"corrected_line": class_record_to_line(corrected),
}
)
return submit_correction_tasks(tasks_path, internal_items)
def submit_public_deletion_tasks(tasks_path: Path, records: list[ClassRecord], items: list[dict]) -> dict:
if not items:
raise ValueError("提交审核的删除记录不能为空")
internal_items: list[dict] = []
for item in items:
original = find_record_by_identity(records, str(item.get("record_id") or ""))
internal_items.append({"original_line": class_record_to_line(original)})
return submit_deletion_tasks(tasks_path, internal_items)
def list_admin_tasks(
tasks_path: Path,
status_filter: str = "",
task_type: str = "",
classnotes_path: Path | None = None,
offset: int = 0,
limit: int = 200,
) -> dict:
tasks = read_admin_tasks(tasks_path)
items = tasks["items"]
if status_filter:
items = [item for item in items if item.get("status") == status_filter]
task_types = [item.strip() for item in task_type.split(",") if item.strip()]
if task_types:
items = [item for item in items if str(item.get("type") or "") in task_types]
type_counts: dict[str, int] = defaultdict(int)
for item in items:
type_counts[str(item.get("type") or "")] += 1
sorted_items = sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True)
shown, normalized_offset, has_more = paginate_items(sorted_items, offset, limit)
records = read_classnotes(classnotes_path) if classnotes_path is not None and classnotes_path.exists() else None
return {
"version": tasks["version"],
"next_id": tasks["next_id"],
"count": len(items),
"returned": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
"type_counts": dict(type_counts),
"items": [
task_to_dict_with_context(item, records)
for item in shown
],
}
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,
classnotes_path: Path | None = None,
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)
records = read_classnotes(classnotes_path) if classnotes_path is not None and classnotes_path.exists() else None
return {
"scanned": len(groups),
"created": len(created),
"refreshed": refreshed,
"items": [task_to_dict_with_context(task, records) 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]
if not matched:
raise ValueError("原上课记录在正式文件中不存在,可能已被修改")
if original_line != corrected_line and any(line.strip() == corrected_line for line in lines):
raise ValueError("修改后的上课记录已存在,不能重复写入")
lines[matched[0]] = corrected_line
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(lines) + trailing_newline
def delete_class_record_line(original_text: str, original_line: str) -> str:
lines = original_text.splitlines()
matched = [index for index, line in enumerate(lines) if line.strip() == original_line]
if not matched:
raise ValueError("原上课记录在正式文件中不存在,可能已被修改")
del lines[matched[0]]
trailing_newline = "\n" if original_text.endswith("\n") and lines else ""
return "\n".join(lines) + trailing_newline
def mark_admin_task(tasks_path: Path, task_id: int, status: str, message: str = "") -> dict:
tasks = read_admin_tasks(tasks_path)
task = find_admin_task(tasks, task_id)
if task.get("status") not in {"pending", "conflict"}:
raise ValueError("该任务已处理,不能重复操作")
now = datetime.now().isoformat(timespec="seconds")
task["status"] = status
task["updated_at"] = now
task["reviewed_at"] = now
if message:
task["message"] = message
write_admin_tasks(tasks_path, tasks)
return task_to_dict(task)
def reject_admin_task(tasks_path: Path, task_id: int) -> dict:
return mark_admin_task(tasks_path, task_id, "rejected")
def approve_correction_task(tasks_path: Path, classnotes_path: Path, accounts_path: Path, task_id: int) -> dict:
tasks = read_admin_tasks(tasks_path)
task = find_admin_task(tasks, task_id)
if task.get("type") != "class_record_correction":
raise ValueError("该任务不是上课记录纠错")
if task.get("status") not in {"pending", "conflict"}:
raise ValueError("该任务已处理,不能重复批准")
original_line = str(task.get("original_line", "")).strip()
corrected_line = str(task.get("corrected_line", "")).strip()
original = parse_class_record_line(original_line)
corrected = parse_class_record_line(corrected_line)
corrected_line = class_record_to_line(corrected)
original_classnotes = classnotes_path.read_text(encoding="utf-8")
try:
new_classnotes = replace_class_record_line(original_classnotes, original_line, corrected_line)
accounts = read_accounts(accounts_path)
updated_accounts = list(accounts)
original_account_index = find_account_index(updated_accounts, original.student)
updated_accounts[original_account_index] = update_account_remaining(
updated_accounts[original_account_index],
original.duration_hours,
)
corrected_account_index = find_account_index(updated_accounts, corrected.student)
updated_accounts[corrected_account_index] = update_account_remaining(
updated_accounts[corrected_account_index],
-corrected.duration_hours,
)
except ValueError as exc:
task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["message"] = str(exc)
write_admin_tasks(tasks_path, tasks)
raise
original_accounts = accounts_path.read_text(encoding="utf-8")
original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
now = datetime.now().isoformat(timespec="seconds")
task["status"] = "approved"
task["updated_at"] = now
task["reviewed_at"] = now
task["original"] = record_to_dict(original)
task["corrected_line"] = corrected_line
task["corrected"] = record_to_dict(corrected)
changed_account_ids = {
updated_accounts[original_account_index].student_id,
updated_accounts[corrected_account_index].student_id,
}
updated_accounts_by_id = {
account.student_id: account
for account in updated_accounts
if account.student_id in changed_account_ids
}
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
new_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
backup_dir = create_data_backup(
"admin-approve-correction",
{
accounts_path: original_accounts,
classnotes_path: original_classnotes,
tasks_path: original_tasks,
},
[original_line, corrected_line],
)
try:
atomic_write_text(accounts_path, new_accounts)
atomic_write_text(classnotes_path, new_classnotes)
atomic_write_text(tasks_path, new_tasks)
except Exception:
atomic_write_text(accounts_path, original_accounts)
atomic_write_text(classnotes_path, original_classnotes)
atomic_write_text(tasks_path, original_tasks)
raise
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"task": task_to_dict(task), "backup_id": backup_dir.name}
def approve_deletion_task(
tasks_path: Path,
classnotes_path: Path,
accounts_path: Path,
task_id: int,
) -> dict:
tasks = read_admin_tasks(tasks_path)
task = find_admin_task(tasks, task_id)
if task.get("type") != "class_record_deletion":
raise ValueError("该任务不是上课记录删除")
if task.get("status") not in {"pending", "conflict"}:
raise ValueError("该任务已处理,不能重复批准")
original_line = str(task.get("original_line", "")).strip()
original = parse_class_record_line(original_line)
original_classnotes = classnotes_path.read_text(encoding="utf-8")
try:
new_classnotes = delete_class_record_line(original_classnotes, original_line)
accounts = read_accounts(accounts_path)
updated_accounts = list(accounts)
account_index = find_account_index(updated_accounts, original.student)
updated_accounts[account_index] = update_account_remaining(updated_accounts[account_index], original.duration_hours)
except ValueError as exc:
task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["message"] = str(exc)
write_admin_tasks(tasks_path, tasks)
raise
original_accounts = accounts_path.read_text(encoding="utf-8")
original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
now = datetime.now().isoformat(timespec="seconds")
task["status"] = "approved"
task["updated_at"] = now
task["reviewed_at"] = now
task["deleted_line"] = original_line
task["restored_hours"] = original.duration_hours
updated_accounts_by_id = {updated_accounts[account_index].student_id: updated_accounts[account_index]}
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
new_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
backup_dir = create_data_backup(
"admin-approve-deletion",
{
accounts_path: original_accounts,
classnotes_path: original_classnotes,
tasks_path: original_tasks,
},
[original_line],
)
try:
atomic_write_text(accounts_path, new_accounts)
atomic_write_text(classnotes_path, new_classnotes)
atomic_write_text(tasks_path, new_tasks)
except Exception:
atomic_write_text(accounts_path, original_accounts)
atomic_write_text(classnotes_path, original_classnotes)
atomic_write_text(tasks_path, original_tasks)
raise
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"task": task_to_dict(task), "backup_id": backup_dir.name}
def safe_filename_part(value: object) -> str:
text = str(value or "").strip()
text = re.sub(r"[\\/:*?\"<>|\s]+", "_", text)
return text.strip("._") or "未命名"
def sha1_text(value: str, length: int = 16) -> str:
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:length]
def snapshot_text_files(paths: Iterable[Path]) -> dict[Path, str | None]:
snapshots: dict[Path, str | None] = {}
for path in paths:
if path in snapshots:
continue
snapshots[path] = path.read_text(encoding="utf-8") if path.exists() else None
return snapshots
def restore_text_file_snapshots(snapshots: dict[Path, str | None]) -> None:
for path, original_text in snapshots.items():
if original_text is None:
if path.exists():
path.unlink()
continue
atomic_write_text(path, original_text)
def payload_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
text = str(value or "").strip().lower()
return text in {"1", "true", "yes", "y", "", "高置信", "可信"}
def normalize_summary_date(value: object) -> str:
text = str(value or "").strip().replace(".", "-")
if not text:
raise ValueError("课程小结缺少日期")
parsed = datetime.strptime(text, "%Y-%m-%d").date()
return parsed.isoformat()
def normalize_time_range_text(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
text = re.sub(r"[.]", ":", text)
text = re.sub(TIME_RANGE_SEPARATOR, "-", text)
text = re.sub(r"\s+", "", text)
match = TIME_RANGE_RE.fullmatch(text)
if not match:
raise ValueError(f"课程小结时间段格式错误: {text}")
start_hour = int(match.group("sh"))
start_minute = int(match.group("sm"))
end_hour = int(match.group("eh"))
end_minute = int(match.group("em"))
if start_hour > 23 or end_hour > 23 or start_minute > 59 or end_minute > 59:
raise ValueError(f"课程小结时间段超出范围: {text}")
if end_hour * 60 + end_minute <= start_hour * 60 + start_minute:
raise ValueError(f"课程小结结束时间必须晚于开始时间: {text}")
return f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}"
def duration_text_from_minutes(minutes: int) -> str:
return f"{minutes // 60}小时{minutes % 60}"
def duration_text_from_hours(hours: float) -> str:
total_minutes = int(round(float(hours or 0) * 60))
sign = "-" if total_minutes < 0 else ""
return f"{sign}{duration_text_from_minutes(abs(total_minutes))}"
def duration_text_map(values: dict[str, float]) -> dict[str, str]:
return {key: duration_text_from_hours(value) for key, value in values.items()}
def duration_minutes_from_time_range(time_range: str) -> int | None:
if not time_range:
return None
match = TIME_RANGE_RE.fullmatch(time_range)
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 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:
return int(round(parse_hours_text(str(raw_duration)) * 60))
for key in ("duration_minutes", "minutes"):
value = summary.get(key)
if value not in (None, ""):
return int(round(float(value)))
for key in ("duration_hours", "hours"):
value = summary.get(key)
if value not in (None, ""):
return int(round(float(value) * 60))
return duration_minutes_from_time_range(str(summary.get("time_range") or ""))
def normalize_date_text(value: str) -> str:
text = value.strip().replace(".", "-").replace("/", "-")
match = re.search(r"(?P<y>\d{4})-(?P<m>\d{1,2})-(?P<d>\d{1,2})", text)
if not match:
raise ValueError(f"无法识别日期: {value}")
return f"{int(match.group('y')):04d}-{int(match.group('m')):02d}-{int(match.group('d')):02d}"
def extract_course_summary_from_text(text: str, index: int = 0, known_students: list[str] | None = None) -> dict:
raw = text.strip()
if not raw:
raise ValueError("课程小结内容不能为空")
fields: dict[str, str] = {}
body_lines: list[str] = []
in_body = False
label_map = {
"学生": "student",
"学员": "student",
"日期": "date_iso",
"上课日期": "date_iso",
"时间": "time_range",
"上课时间": "time_range",
"老师": "teacher",
"教师": "teacher",
"科目": "subject",
"课程": "subject",
"班级": "group",
"分组": "group",
"正文": "body",
"内容": "body",
"小结": "body",
}
for line in raw.splitlines():
stripped = line.strip()
if not stripped:
if in_body:
body_lines.append("")
continue
match = SUMMARY_FIELD_RE.match(stripped)
if match:
key = label_map[match.group("label")]
value = match.group("value").strip()
if key == "body":
in_body = True
if value:
body_lines.append(value)
else:
fields[key] = value
in_body = False
continue
if in_body:
body_lines.append(line.rstrip())
else:
body_lines.append(line.rstrip())
first_line = raw.splitlines()[0].strip()
try:
record = parse_class_record_line(first_line)
except ValueError:
record = None
if record:
fields.setdefault("date_iso", record.date.replace(".", "-"))
fields.setdefault("time_range", record.time)
fields.setdefault("student", record.student)
fields.setdefault("duration_minutes", str(int(round(record.duration_hours * 60))))
fields.setdefault("teacher", record.teacher)
fields.setdefault("subject", record.subject)
if "date_iso" not in fields:
date_match = COURSE_SUMMARY_DATE_RE.search(raw)
if date_match:
fields["date_iso"] = date_match.group("date")
if "time_range" not in fields:
time_match = TIME_RANGE_RE.search(raw)
if time_match:
fields["time_range"] = time_match.group(0)
if "student" not in fields:
for student in known_students or []:
if student and student in raw:
fields["student"] = student
break
if "subject" not in fields:
for subject in SUBJECTS:
if subject in raw:
fields["subject"] = subject
break
if "teacher" not in fields:
teacher_match = re.search(r"(?P<teacher>[\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)", raw)
if teacher_match:
fields["teacher"] = teacher_match.group("teacher")
if "date_iso" in fields:
fields["date_iso"] = normalize_date_text(fields["date_iso"])
if "time_range" in fields:
time_match = TIME_RANGE_RE.search(fields["time_range"])
if time_match:
fields["time_range"] = time_match.group(0)
body = "\n".join(body_lines).strip() or raw
source_id = f"manual:{sha1_text(raw, 24)}"
return {
**fields,
"source_id": source_id,
"body": body,
"recognition_source": "manual_admin",
"confidence": "manual",
"teacher_trusted": True,
"sender": "管理后台",
"local_id": str(index + 1),
}
def normalize_course_summary(raw: dict) -> dict:
student = canonical_name(str(raw.get("student") or "").strip())
teacher = canonical_teacher_name(str(raw.get("teacher") or "").strip())
subject = parse_subject_code(str(raw.get("subject") or "").strip())
body = str(raw.get("body") or raw.get("content") or "").strip()
if not student:
raise ValueError("课程小结缺少学生")
if not body:
raise ValueError("课程小结缺少正文")
date_iso = normalize_summary_date(raw.get("date_iso") or raw.get("date") or raw.get("class_date"))
time_range = normalize_time_range_text(raw.get("time_range") or raw.get("raw_time") or raw.get("time") or "")
minutes = duration_minutes_from_summary({**raw, "time_range": time_range})
if time_range and minutes is not None:
time_minutes = duration_minutes_from_time_range(time_range)
if time_minutes is not None and abs(time_minutes - minutes) > 1:
raise ValueError(f"课程小结时间段和时长不一致: {time_range} / {duration_text_from_minutes(minutes)}")
source_id = str(raw.get("source_id") or "").strip()
if not source_id:
source_parts = [
str(raw.get("db") or ""),
str(raw.get("local_id") or ""),
student,
date_iso,
teacher,
subject,
time_range,
body[:200],
]
source_id = sha1_text("|".join(source_parts), 24)
summary = {
"source_id": source_id,
"student": student,
"date_iso": date_iso,
"time_range": time_range,
"duration_minutes": minutes,
"duration": duration_text_from_minutes(minutes) if minutes is not None else "",
"teacher": teacher,
"subject": subject,
"group": str(raw.get("group") or "").strip(),
"sender": str(raw.get("sender") or raw.get("sender_name") 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,
"recognition_source": str(raw.get("recognition_source") or raw.get("source") or "").strip(),
"confidence": str(raw.get("confidence") or "").strip(),
"teacher_trusted": payload_bool(raw.get("teacher_trusted") or raw.get("sender_teacher_trusted")),
"remark": str(raw.get("remark") or "").strip(),
}
if not summary["message_date"] and len(summary["message_time"]) >= 10:
summary["message_date"] = summary["message_time"][:10]
return summary
def course_summary_semantic_key(summary: dict) -> str:
body_digest = sha1_text(re.sub(r"\s+", "", str(summary.get("body") or "")), 12)
parts = [
summary.get("student", ""),
summary.get("date_iso", ""),
summary.get("teacher", ""),
normalize_subject(str(summary.get("subject") or "")),
summary.get("time_range", ""),
str(summary.get("duration_minutes") or ""),
body_digest,
]
return "|".join(str(part) for part in parts)
def default_course_summary_state() -> dict:
return {"version": 1, "seen_source_ids": [], "seen_semantic_keys": [], "batches": []}
def read_course_summary_state(path: Path) -> dict:
if not path.exists():
return default_course_summary_state()
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"课程小结状态文件 JSON 格式错误: {path}") from exc
if not isinstance(payload, dict):
raise ValueError("课程小结状态文件必须是 JSON 对象")
payload.setdefault("version", 1)
payload.setdefault("seen_source_ids", [])
payload.setdefault("seen_semantic_keys", [])
payload.setdefault("batches", [])
return payload
def write_course_summary_state(path: Path, state: dict) -> None:
atomic_write_text(path, json.dumps(state, ensure_ascii=False, indent=2) + "\n")
def append_operation_log(path: Path, operation: str, status: str, **fields: object) -> str:
path.parent.mkdir(parents=True, exist_ok=True)
now = datetime.now().isoformat(timespec="seconds")
log_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text(json.dumps(fields, ensure_ascii=False, sort_keys=True), 8)}"
row = localize_operation_log_item({
"id": log_id,
"created_at": now,
"operation": operation,
"status": status,
**fields,
})
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
return log_id
OPERATION_LABELS = {
"history_course_summary_import": "历史课程小结导入",
"course_summary_manual_register": "课程小结登记",
"course_summary_ingest": "课程小结接收",
"admin_task_approve": "审核批准",
"admin_task_reject": "审核驳回",
"admin_course_summary_update_time": "课程小结补齐时间",
"admin_course_summary_delete": "课程小结删除",
"admin_course_summary_duplicate_scan": "重复小结扫描",
"admin_course_summary_duplicate_delete": "重复小结删除",
"admin_course_summary_review_update": "课程小结审核修正",
"admin_course_summary_link_existing": "课程小结关联已有记录",
"register-class-records": "登记上课记录",
"register-payments": "登记缴费记录",
"admin-create-account": "新增学生档案",
"admin-update-account": "修改学生档案",
"admin-create-teacher": "新增老师档案",
"admin-update-teacher": "修改老师档案",
"admin-approve-correction": "审核批准上课记录纠错",
"admin-approve-deletion": "审核批准上课记录删除",
"admin-update-course-summary-time": "课程小结补齐时间",
"admin-update-course-summary-body": "课程小结修改正文",
"admin-update-course-summary-identity": "课程小结修改归属",
"admin-delete-course-summary": "课程小结删除",
"rollback-operation": "撤回操作",
}
STATUS_LABELS = {
"completed": "完成",
"approved": "已批准",
"rejected": "已驳回",
"updated": "已更新",
"deleted": "已删除",
"duplicate": "重复",
"auto_registered": "自动入账",
"review": "待审核",
"conflict": "冲突",
"pending": "待处理",
"rolled_back": "已撤回",
}
TYPE_LABELS = {
"class_record_correction": "上课记录纠错",
"class_record_deletion": "上课记录删除",
"course_summary_review": "课程小结审核",
"course_summary_duplicate_review": "重复小结审核",
}
def localize_operation_log_item(item: dict) -> dict:
row = dict(item)
operation = str(row.get("operation") or "")
status = str(row.get("status") or "")
task_type = str(row.get("task_type") or "")
row["operation"] = OPERATION_LABELS.get(operation, operation)
row["status"] = STATUS_LABELS.get(status, status)
if task_type:
row["task_type"] = TYPE_LABELS.get(task_type, task_type)
if row.get("message"):
row["message"] = str(row["message"])
return row
def migrate_operation_log_labels(path: Path) -> dict:
if not path.exists():
return {"updated": 0, "path": str(path)}
lines = path.read_text(encoding="utf-8").splitlines()
updated_lines: list[str] = []
changed = 0
for raw_line in lines:
if not raw_line.strip():
updated_lines.append(raw_line)
continue
try:
item = json.loads(raw_line)
except json.JSONDecodeError:
updated_lines.append(raw_line)
continue
localized = localize_operation_log_item(item)
if localized != item:
changed += 1
updated_lines.append(json.dumps(localized, ensure_ascii=False, sort_keys=True))
if changed:
atomic_write_text(path, "\n".join(updated_lines) + "\n")
return {"updated": changed, "path": str(path)}
def read_operation_log_rows(path: Path) -> list[dict]:
rows: list[dict] = []
if path.exists():
for raw_line in path.read_text(encoding="utf-8").splitlines():
if not raw_line.strip():
continue
try:
item = json.loads(raw_line)
except json.JSONDecodeError:
continue
rows.append(item)
return rows
def backup_search_dirs(paths: list[Path]) -> list[Path]:
result: list[Path] = []
seen: set[str] = set()
def add(path: Path) -> None:
key = str(path)
if key not in seen:
seen.add(key)
result.append(path)
for path in paths:
if path.name == "backups":
add(path)
elif path.suffix:
add(path.parent / "backups")
else:
add(path / "backups")
if path.exists():
for nested in path.rglob("backups"):
if nested.is_dir():
add(nested)
return result
def backup_context(search_paths: list[Path]) -> tuple[list[Path], list[tuple[Path, dict]]]:
backup_dirs = backup_search_dirs(search_paths)
metadata_items: list[tuple[Path, dict]] = []
for backup_root in backup_dirs:
if not backup_root.exists():
continue
for backup_dir in sorted(
path
for path in backup_root.iterdir()
if path.is_dir() and BACKUP_DIR_RE.match(path.name)
):
try:
metadata_items.append((backup_dir, read_backup_metadata(backup_dir)))
except ValueError:
continue
return backup_dirs, metadata_items
def find_backup_dir(backup_id: str, search_paths: list[Path] | None = None, backup_dirs: list[Path] | None = None) -> Path:
value = backup_id.strip()
if not BACKUP_DIR_RE.match(value) or "/" in value or "\\" in value:
raise ValueError("备份ID格式错误")
for backup_root in backup_dirs if backup_dirs is not None else backup_search_dirs(search_paths or []):
candidate = backup_root / value
if candidate.is_dir():
return candidate
raise ValueError(f"备份不存在或已清理: {backup_id}")
def read_backup_metadata(backup_dir: Path) -> dict:
metadata_path = backup_dir / "metadata.json"
if not metadata_path.exists():
raise ValueError(f"备份元数据不存在: {backup_dir.name}")
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"备份元数据 JSON 格式错误: {backup_dir.name}") from exc
if not isinstance(metadata, dict):
raise ValueError("备份元数据必须是 JSON 对象")
metadata.setdefault("backup_id", backup_dir.name)
metadata.setdefault("files", [])
return metadata
def backup_source_path(backup_dir: Path, file_meta: dict) -> Path:
raw_path = str(file_meta.get("source_path") or "").strip()
if raw_path:
path = Path(raw_path)
if path.is_absolute():
return path
return (backup_dir / path).resolve()
name = str(file_meta.get("name") or "").strip()
if not name:
raise ValueError("备份文件缺少 source_path/name")
return backup_dir.parent.parent / name
def backup_source_paths(backup_dir: Path, metadata: dict) -> set[str]:
paths: set[str] = set()
for file_meta in metadata.get("files") or []:
if isinstance(file_meta, dict):
paths.add(str(backup_source_path(backup_dir, file_meta)))
return paths
def operation_log_rollback_state(
item: dict,
rows: list[dict],
search_paths: list[Path],
backup_dirs: list[Path] | None = None,
metadata_items: list[tuple[Path, dict]] | None = None,
) -> dict:
backup_id = str(item.get("backup_id") or "").strip()
log_id = str(item.get("id") or "").strip()
operation = str(item.get("operation") or "")
if not backup_id:
return {"can_rollback": False, "rollback_block_reason": "没有备份"}
if operation == "撤回操作":
return {"can_rollback": False, "rollback_block_reason": "撤回记录不能再次撤回"}
for row in rows:
localized = localize_operation_log_item(row)
if localized.get("operation") == "撤回操作" and str(localized.get("target_log_id") or "") == log_id:
return {"can_rollback": False, "rollback_block_reason": "已撤回"}
try:
backup_dir = find_backup_dir(backup_id, search_paths, backup_dirs)
metadata = read_backup_metadata(backup_dir)
target_sources = backup_source_paths(backup_dir, metadata)
except ValueError as exc:
return {"can_rollback": False, "rollback_block_reason": str(exc)}
if not target_sources:
return {"can_rollback": False, "rollback_block_reason": "备份没有文件"}
if operation == "登记上课记录" and str(item.get("proposed_line") or "").strip():
return {"can_rollback": True, "rollback_block_reason": "", "rollback_mode": "precise_class_record"}
if metadata_items is None:
_backup_dirs, metadata_items = backup_context(search_paths)
for other_dir, other_metadata in metadata_items:
if other_dir.name <= backup_dir.name:
continue
if target_sources & backup_source_paths(other_dir, other_metadata):
return {
"can_rollback": False,
"rollback_block_reason": f"已有后续备份 {other_dir.name}",
}
return {"can_rollback": True, "rollback_block_reason": ""}
def remove_one_class_record_line(original_text: str, target_line: str) -> str:
target = target_line.strip()
lines = original_text.splitlines()
output: list[str] = []
removed = False
for line in lines:
if not removed and line.strip() == target:
removed = True
continue
output.append(line)
if not removed:
raise ValueError("要撤回的上课记录已不存在,不能精确撤回")
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(output) + trailing_newline
def rollback_class_record_registration(
target: dict,
classnotes_path: Path,
accounts_path: Path,
) -> dict:
proposed_line = str(target.get("proposed_line") or "").strip()
if not proposed_line:
raise ValueError("登记上课记录缺少 proposed_line,不能精确撤回")
record = parse_class_record_line(proposed_line)
original_classnotes = classnotes_path.read_text(encoding="utf-8")
original_accounts = accounts_path.read_text(encoding="utf-8")
new_classnotes = remove_one_class_record_line(original_classnotes, class_record_to_line(record))
accounts = read_accounts(accounts_path)
updated_accounts = list(accounts)
account_index = find_account_index(updated_accounts, record.student)
updated_accounts[account_index] = update_account_remaining(updated_accounts[account_index], record.duration_hours)
new_accounts = replace_account_lines(
original_accounts,
{updated_accounts[account_index].student_id: updated_accounts[account_index]},
)
target_backup_id = str(target.get("backup_id") or "").strip()
rollback_backup = create_data_backup(
"rollback-operation",
{
accounts_path: original_accounts,
classnotes_path: original_classnotes,
},
[str(target.get("id") or ""), target_backup_id, proposed_line],
)
try:
atomic_write_text(accounts_path, new_accounts)
atomic_write_text(classnotes_path, new_classnotes)
except Exception:
atomic_write_text(accounts_path, original_accounts)
atomic_write_text(classnotes_path, original_classnotes)
raise
return {
"target_log_id": str(target.get("id") or ""),
"target_backup_id": target_backup_id,
"backup_id": rollback_backup.name,
"rollback_mode": "precise_class_record",
"removed_lines": [class_record_to_line(record)],
"restored_hours": record.duration_hours,
"restored_files": [str(accounts_path), str(classnotes_path)],
}
def list_operation_logs(
path: Path,
limit: int = 100,
offset: int = 0,
operation: str = "",
status_filter: str = "",
student: str = "",
backup_paths: list[Path] | None = None,
) -> dict:
raw_rows = read_operation_log_rows(path)
rows: list[dict] = []
backup_dirs: list[Path] | None = None
metadata_items: list[tuple[Path, dict]] | None = None
if backup_paths is not None:
backup_dirs, metadata_items = backup_context(backup_paths)
for item in raw_rows:
item = localize_operation_log_item(item)
if backup_paths is not None:
item.update(operation_log_rollback_state(item, raw_rows, backup_paths, backup_dirs, metadata_items))
if operation and item.get("operation") != operation:
continue
if status_filter and item.get("status") != status_filter:
continue
if student and student not in str(item.get("student", "")):
continue
rows.append(item)
rows.reverse()
shown, normalized_offset, has_more = paginate_items(rows, offset, limit)
return {
"count": len(rows),
"returned": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
"items": shown,
}
def rollback_operation_log(
operation_logs_path: Path,
log_id: str,
backup_paths: list[Path],
) -> dict:
rows = read_operation_log_rows(operation_logs_path)
target: dict | None = None
for row in rows:
if str(row.get("id") or "") == log_id:
target = localize_operation_log_item(row)
break
if target is None:
raise ValueError(f"未找到操作记录: {log_id}")
state = operation_log_rollback_state(target, rows, backup_paths)
if not state.get("can_rollback"):
raise ValueError(str(state.get("rollback_block_reason") or "该记录不能撤回"))
target_backup_id = str(target.get("backup_id") or "").strip()
backup_dir = find_backup_dir(target_backup_id, backup_paths)
if state.get("rollback_mode") == "precise_class_record":
metadata = read_backup_metadata(backup_dir)
paths: dict[str, Path] = {}
for file_meta in metadata.get("files") or []:
if isinstance(file_meta, dict):
path = backup_source_path(backup_dir, file_meta)
paths[path.name] = path
classnotes_path = paths.get("classnotes.txt")
accounts_path = paths.get("学生课时账户.md")
if classnotes_path is None or accounts_path is None:
raise ValueError("登记上课记录备份缺少 classnotes.txt 或 学生课时账户.md")
return rollback_class_record_registration(target, classnotes_path, accounts_path)
metadata = read_backup_metadata(backup_dir)
file_contents: dict[Path, str] = {}
restore_contents: list[tuple[Path, str]] = []
for file_meta in metadata.get("files") or []:
if not isinstance(file_meta, dict):
continue
source_path = backup_source_path(backup_dir, file_meta)
backup_file = backup_dir / str(file_meta.get("name") or source_path.name)
if not backup_file.exists():
raise ValueError(f"备份文件不存在: {backup_file.name}")
file_contents[source_path] = source_path.read_text(encoding="utf-8") if source_path.exists() else ""
restore_contents.append((source_path, backup_file.read_text(encoding="utf-8")))
if not restore_contents:
raise ValueError("备份没有可恢复文件")
rollback_backup = create_data_backup(
"rollback-operation",
file_contents,
[log_id, target_backup_id],
)
for source_path, content in restore_contents:
atomic_write_text(source_path, content)
return {
"target_log_id": log_id,
"target_backup_id": target_backup_id,
"backup_id": rollback_backup.name,
"restored_files": [str(path) for path, _content in restore_contents],
}
def normalize_filter_date(value: str) -> str:
text = value.strip().replace(".", "-")
if not text:
return ""
parts = text.split("-")
if len(parts) == 3:
text = f"{int(parts[0]):04d}-{int(parts[1]):02d}-{int(parts[2]):02d}"
return date.fromisoformat(text).isoformat()
def parse_course_summary_file_identity(root: Path, path: Path) -> dict:
relative = path.relative_to(root)
student = canonical_name(relative.parts[0]) if relative.parts else ""
stem = path.stem
parts = stem.split("_")
teacher = ""
subject = ""
if len(parts) >= 3:
student = canonical_name(parts[0])
teacher = canonical_teacher_name("_".join(parts[1:-1]))
subject = parts[-1]
return {
"student": student,
"teacher": teacher,
"subject": normalize_subject(subject),
"source_path": str(path),
"relative_path": str(relative),
}
def parse_course_summary_title_date(title: str) -> str:
match = COURSE_SUMMARY_DATE_RE.search(title)
if not match:
return ""
raw = match.group("date").replace(".", "-")
parts = raw.split("-")
if len(parts) != 3:
return ""
normalized = f"{int(parts[0]):04d}-{int(parts[1]):02d}-{int(parts[2]):02d}"
try:
return date.fromisoformat(normalized).isoformat()
except ValueError:
return ""
def parse_course_summary_time_text(text: str) -> str:
colon_match = re.search(
rf"(\d{{1,2}})\s*[:.]\s*(\d{{1,2}})\d*\s*{TIME_RANGE_SEPARATOR}\s*(\d{{1,2}})\s*[:.]\s*(\d{{1,2}})\d*",
text,
)
if colon_match:
start_hour, start_minute, end_hour, end_minute = (int(value) for value in colon_match.groups())
candidate = f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}"
else:
point_match = re.search(
rf"(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?\s*{TIME_RANGE_SEPARATOR}\s*(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?",
text,
)
if not point_match:
return ""
start_hour = int(point_match.group(1))
start_minute = int(point_match.group(2) or 0)
end_hour = int(point_match.group(3))
end_minute = int(point_match.group(4) or 0)
candidate = f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}"
try:
return normalize_time_range_text(candidate)
except ValueError:
return ""
def iter_course_summary_markdown(root: Path) -> Iterable[dict]:
if not root.exists():
return
for path in sorted(root.rglob("*.md")):
if not path.is_file():
continue
if "backups" in path.relative_to(root).parts:
continue
text = path.read_text(encoding="utf-8")
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text))
if not matches:
continue
identity = parse_course_summary_file_identity(root, path)
group = ""
group_match = re.search(r"^##\s+(.+)$", text[: matches[0].start()], flags=re.M)
if group_match:
group = group_match.group(1).strip()
for index, match in enumerate(matches):
title = match.group("title").strip()
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
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,
"id": item_id,
"title": title,
"date_iso": parse_course_summary_title_date(title),
"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 ""),
}
def course_summary_matches(item: dict, q: str, student: str, teacher: str, subject: str, date_from: str, date_to: str) -> bool:
if student and student not in str(item.get("student", "")):
return False
if teacher and teacher not in str(item.get("teacher", "")):
return False
if subject and normalize_subject(subject) not in str(item.get("subject", "")):
return False
item_date = str(item.get("date_iso") or "")
if date_from and (not item_date or item_date < date_from):
return False
if date_to and (not item_date or item_date > date_to):
return False
if q:
haystack = "\n".join(
str(item.get(key, ""))
for key in ("student", "teacher", "subject", "title", "group", "body", "relative_path")
)
return q in haystack
return True
def course_summary_matched_fields(item: dict, q: str) -> list[str]:
if not q:
return []
labels = {
"student": "学生",
"teacher": "老师",
"subject": "科目",
"title": "标题",
"group": "群名",
"body": "正文",
"relative_path": "来源",
}
return [label for key, label in labels.items() if q in str(item.get(key, ""))]
def course_summary_record_key(student: str, teacher: str, subject: str, date_iso: str, time_range: str) -> tuple[str, str, str, str, str]:
return (
canonical_name(student.strip()),
canonical_teacher_name(teacher.strip()),
normalize_subject(subject.strip()),
date_iso.strip(),
normalize_time_range_text(time_range) if time_range else "",
)
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 ""),
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 summary_as_duplicate_candidate(summary: dict, saved_path: str = "") -> dict:
body = str(summary.get("body") or "")
return {
"id": str(summary.get("source_id") or sha1_text(json.dumps(summary, ensure_ascii=False, sort_keys=True), 20)),
"title": str(summary.get("title") or ""),
"student": str(summary.get("student") or ""),
"teacher": str(summary.get("teacher") or ""),
"subject": str(summary.get("subject") or ""),
"date_iso": str(summary.get("date_iso") or ""),
"time_range": str(summary.get("time_range") or ""),
"group": str(summary.get("group") or ""),
"source_path": saved_path,
"relative_path": "",
"source_id": str(summary.get("source_id") or ""),
"message_time": str(summary.get("message_time") or ""),
"sender": str(summary.get("sender") or ""),
"body": body,
"body_preview": body[:260] + ("..." if len(body) > 260 else ""),
}
def normalize_semantic_key_text(value: str) -> str:
return re.sub(r"\s+", "", value.strip())
def find_course_summary_duplicate_conflicts(
root: Path,
summary: dict,
*,
source_id_duplicate: bool,
semantic_duplicate: bool,
) -> list[dict]:
conflicts: list[dict] = []
seen_ids: set[str] = set()
summary_source_id = str(summary.get("source_id") or "")
summary_body_key = normalize_semantic_key_text(str(summary.get("body") or ""))
try:
summary_key = course_summary_duplicate_key(summary)
except ValueError:
summary_key = ("", "", "", "", "")
for item in iter_course_summary_markdown(root):
matched = False
if source_id_duplicate and summary_source_id and str(item.get("source_id") or "") == summary_source_id:
matched = True
if semantic_duplicate:
item_body_key = normalize_semantic_key_text(str(item.get("body") or ""))
try:
item_key = course_summary_duplicate_key(item)
except ValueError:
item_key = ("", "", "", "", "")
matched = matched or (
item_key == summary_key
and item_body_key == summary_body_key
)
if not matched:
continue
candidate = course_summary_duplicate_candidate(item)
candidate_id = str(candidate.get("id") or candidate.get("source_id") or "")
if candidate_id in seen_ids:
continue
seen_ids.add(candidate_id)
conflicts.append(candidate)
return conflicts
def course_summary_duplicate_review_context(
summaries_root: Path,
summary: dict,
seen_source_ids: set[str],
seen_semantic_keys: set[str],
) -> tuple[list[str], list[dict], bool, bool]:
source_id = str(summary.get("source_id") or "")
semantic_key = course_summary_semantic_key(summary)
source_id_duplicate = source_id in seen_source_ids
semantic_duplicate = semantic_key in seen_semantic_keys
reasons: list[str] = []
if source_id_duplicate:
reasons.append("来源ID重复,新增课程小结需人工复核")
if semantic_duplicate:
reasons.append("学生、日期、老师、科目、时间和正文均重复,新增课程小结需人工复核")
conflicts = find_course_summary_duplicate_conflicts(
summaries_root,
summary,
source_id_duplicate=source_id_duplicate,
semantic_duplicate=semantic_duplicate,
)
if not conflicts:
if source_id_duplicate:
reasons.append("状态文件中已存在相同来源ID,但正式小结库未找到对应文件")
if semantic_duplicate:
reasons.append("状态文件中已存在相同语义指纹,但正式小结库未找到对应文件")
return reasons, conflicts, source_id_duplicate, semantic_duplicate
def is_duplicate_course_summary_review_task(task: dict) -> bool:
return bool(
task.get("pending_summary_save")
or task.get("duplicate_source_id")
or task.get("duplicate_semantic_key")
or task.get("duplicate_reasons")
or task.get("duplicate_conflicts")
)
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 "")
body = str(item.get("body") or "")
title = str(item.get("title") or "")
display_teacher = display_names.get(teacher, teacher)
if teacher and display_teacher != teacher:
body = body.replace(teacher, display_teacher)
title = title.replace(teacher, display_teacher)
return {
"id": str(item.get("id") or ""),
"title": title,
"date_iso": str(item.get("date_iso") or ""),
"time_range": str(item.get("time_range") or ""),
"teacher": display_teacher,
"subject": str(item.get("subject") or ""),
"body": body,
}
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:
continue
key = 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 ""),
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 "")))
if len(values) > 1:
index[key] = [max(values, key=course_summary_quality_score)]
return index
def class_record_binding_key(record: ClassRecord) -> tuple[str, str, str, str, str]:
return course_summary_record_key(
record.student,
record.teacher,
record.subject,
record.date.replace(".", "-"),
record.time,
)
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),
"date": record.date,
"date_iso": record.date.replace(".", "-"),
"weekday": record.weekday,
"time": record.time,
"student": record.student,
"duration": record.duration,
"duration_hours": record.duration_hours,
"teacher": record.teacher,
"subject": record.subject,
}
def course_summary_binding_diff(item: dict, record: ClassRecord) -> list[str]:
differences: list[str] = []
item_key = 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 ""),
)
record_key = class_record_binding_key(record)
labels = ["学生", "老师", "科目", "日期", "时间"]
for label, item_value, record_value in zip(labels, item_key, record_key):
if item_value != record_value:
differences.append(f"{label}不一致")
return differences
def course_summary_candidate_score(item: dict, record: ClassRecord) -> tuple[int, int, int, int, int, str, str]:
item_student = canonical_name(str(item.get("student") or ""))
item_teacher = canonical_teacher_name(str(item.get("teacher") or ""))
item_subject = normalize_subject(str(item.get("subject") or ""))
item_date = str(item.get("date_iso") or "")
item_time = normalize_time_range_text(str(item.get("time_range") or "")) if item.get("time_range") else ""
record_date = record.date.replace(".", "-")
record_subject = normalize_subject(record.subject)
score = 0
score += 40 if item_student and item_student == record.student else 0
score += 25 if item_date and item_date == record_date else 0
score += 15 if item_teacher and item_teacher == record.teacher else 0
score += 12 if item_subject and item_subject == record_subject else 0
score += 8 if item_time and item_time == record.time else 0
same_day_student = 1 if item_student and item_date and item_student == record.student and item_date == record_date else 0
same_day = 1 if item_date and item_date == record_date else 0
same_student = 1 if item_student and item_student == record.student else 0
return (score, same_day_student, same_day, same_student, -abs(len(record.subject) - len(item_subject)), record.date, record.time)
def course_summary_binding_reason(item: dict, candidates: list[dict]) -> str:
if not str(item.get("date_iso") or ""):
return "课程小结缺少可识别日期"
if not str(item.get("time_range") or ""):
return "课程小结缺少时间,无法完成五元组绑定"
if not str(item.get("student") or ""):
return "课程小结缺少学生"
if not str(item.get("teacher") or ""):
return "课程小结缺少老师"
if not str(item.get("subject") or ""):
return "课程小结缺少科目"
if candidates:
return "未找到完全一致的上课记录,可按候选记录修正小结字段"
return "未找到相同学生和日期的上课记录"
def course_summary_binding_status(item: dict, record_keys: dict[tuple[str, str, str, str, str], ClassRecord], records: list[ClassRecord]) -> dict:
try:
summary_key = course_summary_duplicate_key(item)
except ValueError:
summary_key = ("", "", "", "", "")
if summary_key and all(summary_key) and summary_key in record_keys:
record = record_keys[summary_key]
return {
"status": "matched",
"label": "已绑定",
"record": course_summary_record_snapshot(record),
"candidates": [],
"reason": "",
"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 = [
record
for record in records
if item_student and item_date and record.student == item_student and record.date.replace(".", "-") == item_date
]
same_date = [
record
for record in records
if item_date and record.date.replace(".", "-") == item_date
]
same_student = [
record
for record in records
if item_student and record.student == item_student
]
candidate_pool = same_student_date or same_date or same_student
scored: list[tuple[tuple[int, int, int, int, int, str, str], ClassRecord]] = []
for record in candidate_pool:
score = course_summary_candidate_score(item, record)
if score[0] <= 0:
continue
scored.append((score, record))
scored.sort(key=lambda pair: pair[0], reverse=True)
candidates = [
{
**course_summary_record_snapshot(record),
"differences": course_summary_binding_diff(item, record),
}
for _score, record in scored[:3]
]
status = "missing_time" if not str(item.get("time_range") or "") else "unmatched"
if candidates and str(item.get("time_range") or ""):
status = "mismatch"
labels = {
"missing_time": "缺时间",
"mismatch": "字段不一致",
"unmatched": "未绑定",
}
return {
"status": status,
"label": labels[status],
"record": None,
"candidates": candidates,
"reason": course_summary_binding_reason(item, candidates),
"differences": candidates[0]["differences"] if candidates else [],
}
def query_course_summaries(
root: Path,
classnotes_path: Path | None = None,
q: str = "",
student: str = "",
teacher: str = "",
subject: str = "",
date_from: str = "",
date_to: str = "",
missing_time: bool = False,
binding_status: str = "",
has_candidate: str = "",
limit: int = 200,
offset: int = 0,
) -> dict:
normalized_from = normalize_filter_date(date_from)
normalized_to = normalize_filter_date(date_to)
if normalized_from and normalized_to and normalized_from > normalized_to:
raise ValueError("开始日期不能晚于结束日期")
normalized_binding_status = binding_status.strip()
allowed_binding_statuses = {"", "matched", "unmatched", "missing_time", "mismatch"}
if normalized_binding_status not in allowed_binding_statuses:
raise ValueError("绑定状态筛选无效")
normalized_has_candidate = has_candidate.strip().lower()
if normalized_has_candidate not in {"", "true", "false"}:
raise ValueError("候选记录筛选无效")
keyword = q.strip()
matched = [
item
for item in iter_course_summary_markdown(root)
if course_summary_matches(
item,
keyword,
canonical_name(student.strip()),
canonical_teacher_name(teacher.strip()),
subject.strip(),
normalized_from,
normalized_to,
)
and (not missing_time or not str(item.get("time_range") or ""))
]
for item in matched:
item["matched_fields"] = course_summary_matched_fields(item, keyword)
item["matched_record"] = False
item["binding"] = {
"status": "unchecked",
"label": "未检查",
"record": None,
"candidates": [],
"reason": "未读取上课记录",
"differences": [],
}
if classnotes_path is not None and classnotes_path.exists():
records = read_classnotes(classnotes_path)
record_keys = {class_record_binding_key(record): record for record in records}
for item in matched:
binding = course_summary_binding_status(item, record_keys, records)
item["binding"] = binding
item["matched_record"] = binding["status"] == "matched"
if normalized_binding_status:
matched = [
item
for item in matched
if str((item.get("binding") or {}).get("status") or "") == normalized_binding_status
]
if normalized_has_candidate:
expect_candidate = normalized_has_candidate == "true"
matched = [
item
for item in matched
if bool((item.get("binding") or {}).get("candidates") or []) == expect_candidate
]
matched.sort(
key=lambda item: (
str(item.get("date_iso") or "0000-00-00"),
str(item.get("student") or ""),
str(item.get("teacher") or ""),
str(item.get("subject") or ""),
str(item.get("title") or ""),
),
reverse=True,
)
limited, normalized_offset, has_more = paginate_items(matched, offset, limit)
return {
"count": len(matched),
"returned": len(limited),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
"items": limited,
}
def replace_course_summary_block(root: Path, path: Path, summary_id: str, new_title: str | None = None, delete: bool = False) -> dict:
text = path.read_text(encoding="utf-8")
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text))
identity = parse_course_summary_file_identity(root, path)
for index, match in enumerate(matches):
title = match.group("title").strip()
start = match.start()
body_start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
body = text[body_start:end].strip()
body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip()
body_text = body_without_meta or body
item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20)
if item_id != summary_id:
continue
if delete:
new_text = text[:start].rstrip() + "\n\n" + text[end:].lstrip()
else:
if not new_title:
raise ValueError("课程小结标题不能为空")
new_text = f"{text[:match.start('title')]}{new_title}{text[match.end('title'):]}"
atomic_write_text(path, new_text.rstrip() + "\n")
return {"id": summary_id, "title": title, "path": str(path), "deleted": delete, "new_title": new_title or ""}
raise ValueError("未找到课程小结")
def replace_course_summary_body(root: Path, path: Path, summary_id: str, new_body: str) -> dict:
text = path.read_text(encoding="utf-8")
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text))
identity = parse_course_summary_file_identity(root, path)
for index, match in enumerate(matches):
title = match.group("title").strip()
body_start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
body = text[body_start:end].strip()
body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip()
body_text = body_without_meta or body
item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20)
if item_id != summary_id:
continue
updated_body = new_body.strip()
meta_match = re.match(r"(?P<meta>(?:>\s+.*(?:\n|$))+)\s*", body)
metadata = meta_match.group("meta").rstrip() if meta_match else ""
updated_block = f"{metadata}\n\n{updated_body}" if metadata else updated_body
new_text = f"{text[:body_start].rstrip()}\n\n{updated_block}\n\n{text[end:].lstrip()}"
atomic_write_text(path, new_text.rstrip() + "\n")
new_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{updated_body[:200]}", 20)
return {
"id": summary_id,
"new_id": new_id,
"title": title,
"path": str(path),
"body": updated_body,
}
raise ValueError("未找到课程小结")
def course_summary_block_payload(root: Path, path: Path, summary_id: str) -> dict:
text = path.read_text(encoding="utf-8")
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text))
identity = parse_course_summary_file_identity(root, path)
group = ""
if matches:
group_match = re.search(r"^##\s+(.+)$", text[: matches[0].start()], flags=re.M)
if group_match:
group = group_match.group(1).strip()
for index, match in enumerate(matches):
title = match.group("title").strip()
start = match.start()
body_start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
raw_body = text[body_start:end].strip()
body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", raw_body).strip()
body_text = body_without_meta or raw_body
item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20)
if item_id != summary_id:
continue
return {
**identity,
"id": item_id,
"title": title,
"index": index,
"start": start,
"end": end,
"group": group,
"raw_body": raw_body,
"body": body_text,
"date_iso": parse_course_summary_title_date(title),
"time_range": parse_course_summary_time_text(f"{title}\n{body_text}"),
"source_id": source_match.group(1) if (source_match := re.search(r"来源ID`([^`]+)`", raw_body)) else "",
"message_time": sent_at_match.group(1) if (sent_at_match := re.search(r"发送时间:`([^`]+)`", raw_body)) else "",
"sender": sender_match.group(1) if (sender_match := re.search(r"发送者:`([^`]+)`", raw_body)) else "",
}
raise ValueError("未找到课程小结")
def remove_course_summary_block_text(text: str, block: dict) -> str:
start = int(block["start"])
end = int(block["end"])
return (text[:start].rstrip() + "\n\n" + text[end:].lstrip()).rstrip() + "\n"
def course_summary_identity_markdown_text(root: Path, existing: str, path: Path, item: dict) -> tuple[str, str, str]:
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(existing))
existing_headings = {match.group("title").strip() for match in matches}
title = str(item.get("title") or "").strip()
if not title:
raise ValueError("课程小结标题不能为空")
heading = title if title not in existing_headings else f"{title}{sha1_text(str(item.get('source_id') or '') + str(item.get('body') or ''), 8)}"
group_name = str(item.get("group") or item.get("student") or "").strip()
raw_body = str(item.get("raw_body") or item.get("body") or "").strip()
lines: list[str] = []
if not existing.strip():
lines.extend([f"# {path.stem}", "", f"## {group_name}", ""])
elif group_name and f"## {group_name}" not in existing:
lines.extend(["", f"## {group_name}", ""])
lines.extend([f"### {heading}", "", raw_body, ""])
prefix = existing
if existing and not existing.endswith("\n"):
prefix += "\n"
new_text = prefix + "\n".join(lines).rstrip() + "\n"
body_text = re.sub(r"^(?:>\s+.*\n)+\s*", "", raw_body).strip() or raw_body
relative_path = str(path.relative_to(root))
return new_text, heading, sha1_text(f"{relative_path}|{heading}|{len(matches)}|{body_text[:200]}", 20)
def find_course_summary_item(root: Path, summary_id: str) -> dict:
for item in iter_course_summary_markdown(root):
if str(item.get("id") or "") == summary_id:
return item
raise ValueError("未找到课程小结")
def update_course_summary_time(root: Path, summary_id: str, time_range: str) -> dict:
item = find_course_summary_item(root, summary_id)
normalized_time = normalize_time_range_text(time_range)
path = Path(str(item.get("source_path") or ""))
subject = normalize_subject(str(item.get("subject") or "待核对科目")) or "待核对科目"
date_iso = str(item.get("date_iso") or "")
if not date_iso:
raise ValueError("课程小结缺少日期,不能补齐时间")
old_title = str(item.get("title") or "")
date_prefix = date_iso
new_title = re.sub(
r"^\d{4}[.-]\d{1,2}[.-]\d{1,2}(?:\s+\d{1,2}:\d{2}-\d{1,2}:\d{2})?",
f"{date_prefix} {normalized_time}",
old_title,
)
if new_title == old_title:
new_title = f"{date_prefix} {normalized_time} {subject}课堂小结"
original = path.read_text(encoding="utf-8")
backup_dir = create_data_backup("admin-update-course-summary-time", {path: original}, [summary_id, new_title])
result = replace_course_summary_block(root, path, summary_id, new_title=new_title)
result["backup_id"] = backup_dir.name
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return result
def update_course_summary_body(root: Path, summary_id: str, body: str) -> dict:
new_body = body.strip()
if not new_body:
raise ValueError("课程小结正文不能为空")
item = find_course_summary_item(root, summary_id)
path = Path(str(item.get("source_path") or ""))
original = path.read_text(encoding="utf-8")
backup_dir = create_data_backup("admin-update-course-summary-body", {path: original}, [summary_id])
result = replace_course_summary_body(root, path, summary_id, new_body)
result["backup_id"] = backup_dir.name
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return result
def update_course_summary_identity(
root: Path,
state_path: Path,
teachers_path: Path,
summary_id: str,
student: str,
teacher: str,
subject: str = "",
) -> dict:
new_student = canonical_name(str(student or "").strip())
if not new_student:
raise ValueError("学生不能为空")
new_teacher = resolve_existing_teacher_input(str(teacher or ""), read_teachers(teachers_path))
item = find_course_summary_item(root, summary_id)
new_subject = normalize_subject(str(subject or "").strip()) or normalize_subject(str(item.get("subject") or ""))
if not new_subject:
raise ValueError("科目不能为空")
source_path = Path(str(item.get("source_path") or ""))
block = course_summary_block_payload(root, source_path, summary_id)
old_student = str(item.get("student") or "")
old_teacher = str(item.get("teacher") or "")
old_subject = normalize_subject(str(item.get("subject") or ""))
if new_student == old_student and new_teacher == old_teacher and new_subject == old_subject:
raise ValueError("学生、老师和科目没有变化")
title = str(block.get("title") or "")
if new_subject != old_subject:
updated_title = title.replace(old_subject, new_subject, 1) if old_subject else title
if updated_title == title:
time_range = str(item.get("time_range") or "")
date_iso = str(item.get("date_iso") or "")
updated_title = f"{date_iso} {time_range + ' ' if time_range else ''}{new_subject}课堂小结".strip()
block = {**block, "title": updated_title}
moved_item = {
**block,
"student": new_student,
"teacher": new_teacher,
"subject": new_subject,
"date_iso": str(item.get("date_iso") or ""),
"time_range": str(item.get("time_range") or ""),
"duration_minutes": duration_minutes_from_time_range(str(item.get("time_range") or "")),
}
target_path = course_summary_path(root, moved_item)
source_text = source_path.read_text(encoding="utf-8")
target_exists = target_path.exists()
target_text = target_path.read_text(encoding="utf-8") if target_exists else ""
new_source_text = remove_course_summary_block_text(source_text, block)
target_base_text = new_source_text if target_path == source_path else target_text
new_target_text, heading, new_id = course_summary_identity_markdown_text(root, target_base_text, target_path, moved_item)
state = read_course_summary_state(state_path)
original_state_text = state_path.read_text(encoding="utf-8") if state_path.exists() else json.dumps(default_course_summary_state(), ensure_ascii=False, indent=2) + "\n"
semantic_keys = {str(value) for value in state.get("seen_semantic_keys", [])}
old_key_item = {
**moved_item,
"student": old_student,
"teacher": old_teacher,
"subject": old_subject,
}
semantic_keys.discard(course_summary_semantic_key(old_key_item))
semantic_keys.discard(course_summary_semantic_key({
**item,
"duration_minutes": duration_minutes_from_time_range(str(item.get("time_range") or "")),
}))
semantic_keys.add(course_summary_semantic_key(moved_item))
state["seen_semantic_keys"] = sorted(semantic_keys)
new_state_text = json.dumps(state, ensure_ascii=False, indent=2) + "\n"
backup_contents = {
source_path: source_text,
state_path: original_state_text,
}
if target_path != source_path:
backup_contents[target_path] = target_text
backup_dir = create_data_backup(
"admin-update-course-summary-identity",
backup_contents,
[summary_id, old_student, old_teacher, old_subject, new_student, new_teacher, new_subject],
)
try:
if target_path == source_path:
atomic_write_text(source_path, new_target_text)
else:
atomic_write_text(source_path, new_source_text)
target_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text(target_path, new_target_text)
atomic_write_text(state_path, new_state_text)
except Exception:
atomic_write_text(source_path, source_text)
if target_path != source_path:
if target_exists:
atomic_write_text(target_path, target_text)
elif target_path.exists():
target_path.unlink()
atomic_write_text(state_path, original_state_text)
raise
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {
"id": summary_id,
"new_id": new_id,
"old_student": old_student,
"old_teacher": old_teacher,
"old_subject": old_subject,
"student": new_student,
"teacher": new_teacher,
"subject": new_subject,
"old_path": str(source_path),
"new_path": str(target_path),
"heading": heading,
"backup_id": backup_dir.name,
}
def delete_course_summary(root: Path, summary_id: str) -> dict:
item = find_course_summary_item(root, summary_id)
path = Path(str(item.get("source_path") or ""))
original = path.read_text(encoding="utf-8")
backup_dir = create_data_backup("admin-delete-course-summary", {path: original}, [summary_id])
result = replace_course_summary_block(root, path, summary_id, delete=True)
result["backup_id"] = backup_dir.name
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
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 "待核对老师")
subject = safe_filename_part(summary["subject"] or "待核对科目")
return root / student / f"{student}_{teacher}_{subject}.md"
def course_summary_heading(summary: dict, existing_headings: set[str]) -> str:
subject = normalize_subject(str(summary.get("subject") or "待核对科目"))
time_range = str(summary.get("time_range") or "").strip()
base = f"{summary['date_iso']} {time_range + ' ' if time_range else ''}{subject}课堂小结"
if base not in existing_headings:
return base
return f"{base}{sha1_text(str(summary.get('source_id') or '') + str(summary.get('body') or ''), 8)}"
def course_summary_markdown_text(
existing: str,
path: Path,
summary: dict,
*,
allow_same_source_id: bool = False,
) -> tuple[str, bool, str]:
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(existing))
existing_headings = {match.group("title").strip() for match in matches}
body = str(summary.get("body") or "").rstrip()
group_name = str(summary.get("group") or summary["student"])
heading = course_summary_heading(summary, existing_headings)
source_id = str(summary.get("source_id") or "").strip()
if not allow_same_source_id and source_id and f"来源ID`{source_id}`" in existing:
return existing, False, heading
existing_blocks: set[tuple[str, str]] = set()
for index, match in enumerate(matches):
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(existing)
block_body = existing[start:end].strip()
block_body = re.sub(r"^(?:>\s+.*\n)+\s*", "", block_body).strip()
existing_blocks.add((match.group("title").strip(), re.sub(r"\s+", "", block_body)))
if (heading, re.sub(r"\s+", "", body)) in existing_blocks:
return existing, False, heading
lines: list[str] = []
if not existing.strip():
lines.extend([f"# {path.stem}", "", f"## {group_name}", ""])
elif f"## {group_name}" not in existing:
lines.extend(["", f"## {group_name}", ""])
lines.extend(
[
f"### {heading}",
"",
f"> 来源ID`{summary['source_id']}`",
f"> 发送时间:`{summary.get('message_time') or ''}`",
f"> 发送者:`{summary.get('sender') or ''}`",
"",
body,
"",
]
)
prefix = existing
if existing and not existing.endswith("\n"):
prefix += "\n"
return prefix + "\n".join(lines).rstrip() + "\n", True, heading
def save_course_summary_markdown(root: Path, summary: dict) -> dict:
path = course_summary_path(root, summary)
existing = path.read_text(encoding="utf-8") if path.exists() else ""
new_text, added, heading = course_summary_markdown_text(existing, path, summary)
if not added:
return {"path": str(path), "added": False, "heading": heading}
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text(path, new_text)
return {"path": str(path), "added": True, "heading": heading}
def message_date_after_class_date(summary: dict) -> bool:
message_date = str(summary.get("message_date") or "")
if len(message_date) != 10:
return False
try:
return date.fromisoformat(str(summary["date_iso"])) > date.fromisoformat(message_date)
except ValueError:
return False
def course_summary_to_class_record_line(summary: dict) -> str:
date_iso = normalize_summary_date(summary.get("date_iso"))
record_date = date_iso.replace("-", ".")
weekday = WEEKDAYS[date.fromisoformat(date_iso).weekday()]
time_range = normalize_time_range_text(summary.get("time_range"))
minutes = summary.get("duration_minutes")
if minutes is None:
raise ValueError("课程小结缺少时长")
duration = duration_text_from_minutes(int(minutes))
return (
f"{record_date}-{weekday}-{time_range}-{summary['student']}-"
f"{duration}-{summary['teacher']}-{normalize_subject(str(summary['subject']))}"
)
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()
recognition_source = str(summary.get("recognition_source") or "")
if confidence not in HIGH_CONFIDENCE_VALUES and recognition_source not in AUTO_RECOGNITION_SOURCES:
reasons.append("识别置信度不足")
if not summary.get("teacher_trusted"):
reasons.append("发送者老师映射未确认")
if summary.get("teacher") in UNKNOWN_TEACHERS:
reasons.append("老师待核对")
if normalize_subject(str(summary.get("subject") or "")) in UNKNOWN_SUBJECTS:
reasons.append("科目待核对")
if not summary.get("time_range"):
reasons.append("时间段缺失")
if summary.get("duration_minutes") is None:
reasons.append("时长缺失")
if message_date_after_class_date(summary):
reasons.append("课程日期晚于消息发送日期")
line = ""
try:
line = course_summary_to_class_record_line(summary)
parse_class_record_line(line)
except ValueError as exc:
reasons.append(str(exc))
try:
find_account_index(read_accounts(accounts_path), str(summary["student"]))
except ValueError as exc:
reasons.append(str(exc))
if line:
existing_lines = {raw.strip() for raw in classnotes_path.read_text(encoding="utf-8").splitlines()}
if line in existing_lines:
reasons.append("classnotes 已存在同一条上课记录")
return reasons, line
def create_course_summary_review_task(
tasks_path: Path,
summary: dict,
proposed_line: str,
reasons: list[str],
saved_path: str = "",
extra_fields: dict | None = None,
) -> dict:
tasks = read_admin_tasks(tasks_path)
now = datetime.now().isoformat(timespec="seconds")
task = {
"id": int(tasks["next_id"]),
"type": "course_summary_review",
"status": "pending",
"created_at": now,
"updated_at": now,
"source_id": summary["source_id"],
"student": summary["student"],
"summary": summary,
"proposed_line": proposed_line,
"reasons": reasons,
"saved_path": saved_path,
}
if extra_fields:
task.update(extra_fields)
tasks["next_id"] = int(tasks["next_id"]) + 1
tasks["items"].append(task)
write_admin_tasks(tasks_path, tasks)
return task_to_dict(task)
def create_incomplete_course_summary_review_task(
tasks_path: Path,
raw: dict,
reasons: list[str],
) -> dict:
body = str(raw.get("body") or raw.get("content") or "").strip()
source_id = str(raw.get("source_id") or sha1_text(json.dumps(raw, ensure_ascii=False, sort_keys=True), 24))
summary = {
"source_id": source_id,
"student": canonical_name(str(raw.get("student") or "").strip()),
"date_iso": str(raw.get("date_iso") or raw.get("date") or raw.get("class_date") or "").strip(),
"time_range": str(raw.get("time_range") or raw.get("raw_time") or raw.get("time") or "").strip(),
"duration_minutes": raw.get("duration_minutes"),
"duration": str(raw.get("duration") or "").strip(),
"teacher": canonical_teacher_name(str(raw.get("teacher") or "").strip()),
"subject": str(raw.get("subject") or "").strip(),
"group": str(raw.get("group") or "").strip(),
"sender": str(raw.get("sender") or raw.get("sender_name") 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,
"recognition_source": str(raw.get("recognition_source") or raw.get("source") or "").strip(),
"confidence": str(raw.get("confidence") or "").strip(),
"teacher_trusted": payload_bool(raw.get("teacher_trusted") or raw.get("sender_teacher_trusted")),
"remark": str(raw.get("remark") or "").strip(),
}
return create_course_summary_review_task(tasks_path, summary, "", reasons)
def editable_course_summary_payload(summary: dict, updates: dict) -> dict:
merged = dict(summary)
for key in ("student", "date_iso", "time_range", "teacher", "subject"):
if key in updates:
merged[key] = str(updates.get(key) or "").strip()
normalized = normalize_course_summary(merged)
normalized["source_id"] = str(summary.get("source_id") or normalized.get("source_id") or "")
normalized["group"] = str(summary.get("group") or "")
normalized["sender"] = str(summary.get("sender") or "")
normalized["sender_id"] = str(summary.get("sender_id") or "")
normalized["message_time"] = str(summary.get("message_time") or "")
normalized["message_date"] = str(summary.get("message_date") or normalized.get("message_date") or "")
normalized["db"] = str(summary.get("db") or "")
normalized["local_id"] = str(summary.get("local_id") or "")
normalized["title"] = str(summary.get("title") or "")
normalized["body"] = str(summary.get("body") or normalized.get("body") or "")
normalized["recognition_source"] = str(summary.get("recognition_source") or "")
normalized["confidence"] = str(summary.get("confidence") or "")
normalized["teacher_trusted"] = True
normalized["remark"] = str(summary.get("remark") or "")
return normalized
def update_course_summary_review_task(
tasks_path: Path,
summaries_root: Path,
state_path: Path,
classnotes_path: Path,
accounts_path: Path,
task_id: int,
updates: dict,
) -> dict:
tasks = read_admin_tasks(tasks_path)
task = find_admin_task(tasks, task_id)
if task.get("type") != "course_summary_review":
raise ValueError("该任务不是课程小结审核")
if task.get("status") not in {"pending", "conflict"}:
raise ValueError("该任务已处理,不能修改")
summary = task.get("summary") or {}
updated_summary = editable_course_summary_payload(summary, updates)
reasons, proposed_line = auto_register_reasons(updated_summary, classnotes_path, accounts_path)
if is_duplicate_course_summary_review_task(task):
state = read_course_summary_state(state_path)
source_ids = {str(item) for item in state.get("seen_source_ids", [])}
semantic_keys = {str(item) for item in state.get("seen_semantic_keys", [])}
source_ids.discard(str(summary.get("source_id") or ""))
if task.get("semantic_key"):
semantic_keys.discard(str(task.get("semantic_key") or ""))
semantic_keys.discard(course_summary_semantic_key(summary))
duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = course_summary_duplicate_review_context(
summaries_root,
updated_summary,
source_ids,
semantic_keys,
)
merged_reasons = [*duplicate_reasons]
merged_reasons.extend(reason for reason in reasons if reason not in merged_reasons)
reasons = merged_reasons
task["duplicate_reasons"] = duplicate_reasons
task["duplicate_conflicts"] = duplicate_conflicts
task["duplicate_source_id"] = source_id_duplicate
task["duplicate_semantic_key"] = semantic_duplicate
task["duplicate_source"] = summary_as_duplicate_candidate(
updated_summary,
str(course_summary_path(summaries_root, updated_summary)),
)
task["semantic_key"] = course_summary_semantic_key(updated_summary)
task["pending_summary_save"] = True
task["saved_path"] = str(course_summary_path(summaries_root, updated_summary))
source_ids.add(str(updated_summary.get("source_id") or ""))
semantic_keys.add(task["semantic_key"])
state["seen_source_ids"] = sorted(source_ids)
state["seen_semantic_keys"] = sorted(semantic_keys)
write_course_summary_state(state_path, state)
now = datetime.now().isoformat(timespec="seconds")
task["status"] = "pending"
task["updated_at"] = now
task.pop("message", None)
task["student"] = updated_summary["student"]
task["summary"] = updated_summary
task["proposed_line"] = proposed_line
task["reasons"] = reasons
write_admin_tasks(tasks_path, tasks)
return {"task": task_to_dict(task)}
def link_existing_course_summary_task(tasks_path: Path, classnotes_path: Path, task_id: int) -> dict:
tasks = read_admin_tasks(tasks_path)
task = find_admin_task(tasks, task_id)
if task.get("type") != "course_summary_review":
raise ValueError("该任务不是课程小结审核")
if task.get("status") not in {"pending", "conflict"}:
raise ValueError("该任务已处理,不能重复操作")
proposed_line = str(task.get("proposed_line") or "").strip()
if not proposed_line:
raise ValueError("课程小结缺少候选登记行,不能关联已有记录")
existing_lines = {raw.strip() for raw in classnotes_path.read_text(encoding="utf-8").splitlines()}
if proposed_line not in existing_lines:
raise ValueError("候选登记行在上课记录中不存在,不能关联已有记录")
now = datetime.now().isoformat(timespec="seconds")
task["status"] = "approved"
task["updated_at"] = now
task["reviewed_at"] = now
task["linked_record_line"] = proposed_line
task["message"] = "已关联已有上课记录,未重复扣课时"
write_admin_tasks(tasks_path, tasks)
return {"task": task_to_dict(task), "linked_record_line": proposed_line}
def approve_course_summary_task(
tasks_path: Path,
summaries_root: Path,
classnotes_path: Path,
accounts_path: Path,
task_id: int,
) -> dict:
tasks = read_admin_tasks(tasks_path)
task = find_admin_task(tasks, task_id)
if task.get("type") != "course_summary_review":
raise ValueError("该任务不是课程小结审核")
if task.get("status") not in {"pending", "conflict"}:
raise ValueError("该任务已处理,不能重复批准")
proposed_line = str(task.get("proposed_line") or "").strip()
summary = task.get("summary") or {}
if not proposed_line:
try:
proposed_line = course_summary_to_class_record_line(summary)
except ValueError as exc:
task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["message"] = str(exc)
write_admin_tasks(tasks_path, tasks)
raise
if not summary:
raise ValueError("课程小结信息缺失,不能批准入账")
try:
original_classnotes = classnotes_path.read_text(encoding="utf-8")
original_accounts = accounts_path.read_text(encoding="utf-8")
original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
should_save_summary = bool(task.get("pending_summary_save"))
summary_path: Path | None = None
original_summary_text = ""
summary_existed = False
new_summary_text = ""
summary_added = False
summary_heading = ""
if should_save_summary:
summary_path = course_summary_path(summaries_root, summary)
summary_existed = summary_path.exists()
original_summary_text = summary_path.read_text(encoding="utf-8") if summary_existed else ""
new_summary_text, summary_added, summary_heading = course_summary_markdown_text(
original_summary_text,
summary_path,
summary,
allow_same_source_id=True,
)
record = parse_class_record_line(proposed_line)
record_line = class_record_to_line(record)
existing_lines = {raw.strip() for raw in original_classnotes.splitlines()}
if record_line in existing_lines:
raise ValueError(f"上课记录已存在: {record_line}")
accounts = read_accounts(accounts_path)
updated_accounts = list(accounts)
account_index = find_account_index(updated_accounts, record.student)
updated_accounts[account_index] = update_account_remaining(
updated_accounts[account_index],
-record.duration_hours,
)
separator = "" if not original_classnotes or original_classnotes.endswith("\n") else "\n"
new_classnotes = f"{original_classnotes}{separator}{record_line}\n"
updated_accounts_by_id = {updated_accounts[account_index].student_id: updated_accounts[account_index]}
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
task["status"] = "approved"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["reviewed_at"] = task["updated_at"]
task["proposed_line"] = record_line
task["registered_line"] = record_line
if summary_path is not None:
task["saved_path"] = str(summary_path)
if summary_heading:
task["summary_heading"] = summary_heading
backup_dir = create_data_backup(
"admin-approve-course-summary",
{
accounts_path: original_accounts,
classnotes_path: original_classnotes,
tasks_path: original_tasks,
**({summary_path: original_summary_text} if summary_path is not None else {}),
},
[record_line],
)
task["backup_id"] = backup_dir.name
new_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
try:
atomic_write_text(accounts_path, new_accounts)
atomic_write_text(classnotes_path, new_classnotes)
if summary_path is not None and summary_added:
atomic_write_text(summary_path, new_summary_text)
atomic_write_text(tasks_path, new_tasks)
except Exception:
atomic_write_text(accounts_path, original_accounts)
atomic_write_text(classnotes_path, original_classnotes)
atomic_write_text(tasks_path, original_tasks)
if summary_path is not None:
if summary_existed:
atomic_write_text(summary_path, original_summary_text)
elif summary_path.exists():
summary_path.unlink()
raise
except ValueError as exc:
task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["message"] = str(exc)
write_admin_tasks(tasks_path, tasks)
raise
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"task": task_to_dict(task), "backup_id": backup_dir.name}
def approve_admin_task(
tasks_path: Path,
summaries_root: Path,
classnotes_path: Path,
accounts_path: Path,
task_id: int,
) -> dict:
task = find_admin_task(read_admin_tasks(tasks_path), task_id)
if task.get("type") == "class_record_correction":
return approve_correction_task(tasks_path, classnotes_path, accounts_path, task_id)
if task.get("type") == "class_record_deletion":
return approve_deletion_task(tasks_path, classnotes_path, accounts_path, task_id)
if task.get("type") == "course_summary_review":
return approve_course_summary_task(tasks_path, summaries_root, classnotes_path, accounts_path, task_id)
raise ValueError("不支持的审核任务类型")
COURSE_SUMMARY_RESULT_COUNTER_KEYS = ("saved", "auto_registered", "review_pending", "duplicates", "rejected")
def snapshot_course_summary_result(result: dict) -> dict:
return {
**{key: result[key] for key in COURSE_SUMMARY_RESULT_COUNTER_KEYS},
"operation_log_ids": list(result["operation_log_ids"]),
"items": list(result["items"]),
}
def restore_course_summary_result(result: dict, snapshot: dict) -> None:
for key in COURSE_SUMMARY_RESULT_COUNTER_KEYS:
result[key] = snapshot[key]
result["operation_log_ids"] = list(snapshot["operation_log_ids"])
result["items"] = list(snapshot["items"])
def register_course_summary_texts(
*,
classnotes_path: Path,
accounts_path: Path,
tasks_path: Path,
summaries_root: Path,
state_path: Path,
operation_logs_path: Path,
lines: list[str] | None = None,
line: str | None = None,
) -> dict:
texts = normalize_lines(lines=lines, line=line)
now = datetime.now().isoformat(timespec="seconds")
accounts = read_accounts(accounts_path)
known_students = [account.student for account in accounts]
result = {
"received": len(texts),
"saved": 0,
"auto_registered": 0,
"review_pending": 0,
"duplicates": 0,
"rejected": 0,
"operation_log_ids": [],
"items": [],
}
for index, text in enumerate(texts):
raw: dict | None = None
normalized: dict | None = None
file_snapshots: dict[Path, str | None] | None = None
result_snapshot = snapshot_course_summary_result(result)
try:
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)
summary_path = course_summary_path(summaries_root, 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", []))
file_snapshots = snapshot_text_files([
classnotes_path,
accounts_path,
tasks_path,
state_path,
operation_logs_path,
summary_path,
])
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = course_summary_duplicate_review_context(
summaries_root,
normalized,
seen_source_ids,
seen_semantic_keys,
)
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
if source_id_duplicate:
duplicate_reason = "来源ID重复,已转入审核"
else:
duplicate_reason = "课程内容与已有课程小结重复,已转入审核"
review_reasons = [duplicate_reason, *duplicate_reasons]
review_reasons.extend(reason for reason in reasons if reason not in review_reasons)
task = create_course_summary_review_task(
tasks_path,
normalized,
proposed_line,
review_reasons,
str(summary_path),
extra_fields={
"duplicate_source": summary_as_duplicate_candidate(normalized),
"duplicate_reasons": duplicate_reasons,
"duplicate_conflicts": duplicate_conflicts,
"duplicate_source_id": source_id_duplicate,
"duplicate_semantic_key": semantic_duplicate,
"semantic_key": semantic_key,
"pending_summary_save": True,
},
)
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": 0,
"auto_registered": 0,
"review_pending": 1,
"duplicates": 1,
"rejected": 0,
},
}
)
state["batches"] = state["batches"][-200:]
write_course_summary_state(state_path, state)
log_id = append_operation_log(
operation_logs_path,
"课程小结登记",
"待审核",
source_id=source_id,
student=normalized["student"],
teacher=normalized.get("teacher", ""),
subject=normalized.get("subject", ""),
proposed_line=proposed_line,
reasons=review_reasons,
task_id=str(task.get("id") or ""),
saved_path=str(summary_path),
duplicate_source_id=source_id_duplicate,
duplicate_semantic_key=semantic_duplicate,
)
result["review_pending"] += 1
result["duplicates"] += 1
result["operation_log_ids"].append(log_id)
result["items"].append(
{
"source_id": source_id,
"status": "review_pending",
"task_id": task.get("id"),
"reasons": review_reasons,
"duplicate_conflicts": duplicate_conflicts,
}
)
continue
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
if 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),
)
task = create_course_summary_review_task(
tasks_path,
normalized,
proposed_line,
reasons,
str(saved.get("path") or ""),
)
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": 0,
"review_pending": 1,
"duplicates": 0,
"rejected": 0,
},
}
)
state["batches"] = state["batches"][-200:]
write_course_summary_state(state_path, state)
log_id = append_operation_log(
operation_logs_path,
"课程小结登记",
"待审核",
source_id=source_id,
student=normalized["student"],
teacher=normalized.get("teacher", ""),
subject=normalized.get("subject", ""),
proposed_line=proposed_line,
reasons=reasons,
task_id=str(task.get("id") or ""),
saved_path=str(saved.get("path") or ""),
)
result["saved"] += 1 if saved.get("added") else 0
result["review_pending"] += 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,
"status": "review_pending",
"task_id": task.get("id"),
"reasons": reasons,
}
)
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),
)
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)
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,
"课程小结登记",
"自动入账",
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["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,
"status": "auto_registered",
"backup_id": str(register_result.get("backup_id") or ""),
}
)
except Exception as exc:
if file_snapshots is not None:
restore_text_file_snapshots(file_snapshots)
restore_course_summary_result(result, result_snapshot)
if not isinstance(exc, ValueError):
raise
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,
"课程小结登记",
"已驳回",
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),
}
)
return result
def ingest_course_summaries(
*,
classnotes_path: Path,
accounts_path: Path,
tasks_path: Path,
summaries_root: Path,
state_path: Path,
operation_logs_path: Path,
batch_id: str,
window: dict,
students: list[str],
summaries: list[dict],
) -> dict:
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", []))
result = {
"received": len(summaries),
"saved": 0,
"auto_registered": 0,
"review_pending": 0,
"duplicates": 0,
"rejected": 0,
"operation_log_ids": [],
"items": [],
}
for raw in summaries:
normalized: dict | None = None
backup_id = ""
file_snapshots: dict[Path, str | None] | None = None
result_snapshot = snapshot_course_summary_result(result)
seen_source_ids_before = set(seen_source_ids)
seen_semantic_keys_before = set(seen_semantic_keys)
try:
normalized = normalize_course_summary(raw)
ai_questions = [
str(item).strip()
for item in (raw.get("ai_questions") or [])
if str(item).strip()
]
source_id = normalized["source_id"]
semantic_key = course_summary_semantic_key(normalized)
summary_path = course_summary_path(summaries_root, normalized)
file_snapshots = snapshot_text_files([
classnotes_path,
accounts_path,
tasks_path,
state_path,
operation_logs_path,
summary_path,
])
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = course_summary_duplicate_review_context(
summaries_root,
normalized,
seen_source_ids,
seen_semantic_keys,
)
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
if source_id_duplicate:
duplicate_reason = "来源ID重复,已转入审核"
else:
duplicate_reason = "课程内容与已有课程小结重复,已转入审核"
review_reasons = [duplicate_reason, *duplicate_reasons]
review_reasons.extend(reason for reason in reasons if reason not in review_reasons)
task = create_course_summary_review_task(
tasks_path,
normalized,
proposed_line,
review_reasons,
str(summary_path),
extra_fields={
"duplicate_source": summary_as_duplicate_candidate(normalized),
"duplicate_reasons": duplicate_reasons,
"duplicate_conflicts": duplicate_conflicts,
"duplicate_source_id": source_id_duplicate,
"duplicate_semantic_key": semantic_duplicate,
"semantic_key": semantic_key,
"pending_summary_save": True,
},
)
seen_source_ids.add(source_id)
seen_semantic_keys.add(semantic_key)
result["review_pending"] += 1
result["duplicates"] += 1
log_id = append_operation_log(
operation_logs_path,
"课程小结接收",
"待审核",
batch_id=batch_id,
source_id=source_id,
student=normalized["student"],
teacher=normalized.get("teacher", ""),
subject=normalized.get("subject", ""),
proposed_line=proposed_line,
reasons=review_reasons,
task_id=task.get("id"),
saved_path=str(summary_path),
duplicate_source_id=source_id_duplicate,
duplicate_semantic_key=semantic_duplicate,
)
result["operation_log_ids"].append(log_id)
result["items"].append(
{
"source_id": source_id,
"status": "待审核",
"task_id": task.get("id"),
"backup_id": "",
"reasons": review_reasons,
"duplicate_conflicts": duplicate_conflicts,
}
)
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)
for question in ai_questions:
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,
normalized,
proposed_line,
reasons,
saved_path=str(saved.get("path") or ""),
)
result["review_pending"] += 1
status_value = "待审核"
task_id = task.get("id")
else:
register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
result["auto_registered"] += 1
status_value = "自动入账"
task_id = None
backup_id = str(register_result.get("backup_id") or "")
seen_source_ids.add(source_id)
seen_semantic_keys.add(semantic_key)
log_id = append_operation_log(
operation_logs_path,
"课程小结接收",
status_value,
batch_id=batch_id,
source_id=source_id,
student=normalized["student"],
teacher=normalized.get("teacher", ""),
subject=normalized.get("subject", ""),
proposed_line=proposed_line,
reasons=reasons,
ai_used=bool(raw.get("ai_used")),
ai_summary=str(raw.get("ai_summary") or ""),
task_id=task_id,
backup_id=backup_id,
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,
"status": status_value,
"task_id": task_id,
"backup_id": backup_id,
"reasons": reasons,
}
)
except Exception as exc:
if file_snapshots is not None:
restore_text_file_snapshots(file_snapshots)
seen_source_ids = seen_source_ids_before
seen_semantic_keys = seen_semantic_keys_before
restore_course_summary_result(result, result_snapshot)
ai_questions = [
str(item).strip()
for item in ((raw if isinstance(raw, dict) else {}).get("ai_questions") or [])
if str(item).strip()
]
if ai_questions:
reasons = [f"脚本追问:{question}" for question in ai_questions]
reasons.append(str(exc))
task = create_incomplete_course_summary_review_task(tasks_path, raw, reasons)
result["review_pending"] += 1
source_id = str(raw.get("source_id") or task.get("source_id") or "")
log_id = append_operation_log(
operation_logs_path,
"课程小结接收",
"待审核",
batch_id=batch_id,
source_id=source_id,
student=str(raw.get("student") or ""),
reasons=reasons,
ai_used=bool(raw.get("ai_used")),
ai_summary=str(raw.get("ai_summary") or ""),
task_id=task.get("id"),
)
result["operation_log_ids"].append(log_id)
result["items"].append({
"source_id": source_id,
"status": "待审核",
"task_id": task.get("id"),
"backup_id": "",
"reasons": reasons,
})
continue
result["rejected"] += 1
source_id = str((normalized or raw).get("source_id") or "")
log_id = append_operation_log(
operation_logs_path,
"课程小结接收",
"已驳回",
batch_id=batch_id,
source_id=source_id,
student=str((normalized or raw).get("student") or ""),
error=str(exc),
)
result["operation_log_ids"].append(log_id)
result["items"].append({"source_id": source_id, "status": "rejected", "error": str(exc)})
state["seen_source_ids"] = sorted(seen_source_ids)
state["seen_semantic_keys"] = sorted(seen_semantic_keys)
state.setdefault("batches", []).append(
{
"batch_id": batch_id,
"received_at": datetime.now().isoformat(timespec="seconds"),
"window": window,
"students": students,
"result": {key: result[key] for key in ("received", "saved", "auto_registered", "review_pending", "duplicates", "rejected")},
}
)
state["batches"] = state["batches"][-200:]
write_course_summary_state(state_path, state)
return result
def parse_record_date(text: str) -> date:
return datetime.strptime(text, "%Y.%m.%d").date()
def month_range(year: int, month: int) -> tuple[date, date]:
start = date(year, month, 1)
if month == 12:
return start, date(year, 12, 31)
return start, date(year, month + 1, 1) - timedelta(days=1)
def safe_date(year: int, month: int, day: int) -> date | None:
try:
return date(year, month, day)
except ValueError:
return None
def safe_month_range(year: int, month: int) -> tuple[date, date] | None:
try:
return month_range(year, month)
except ValueError:
return None
def previous_weekend_range(today: date) -> tuple[date, date]:
days_since_monday = today.weekday()
current_week_monday = today - timedelta(days=days_since_monday)
previous_saturday = current_week_monday - timedelta(days=2)
previous_sunday = current_week_monday - timedelta(days=1)
return previous_saturday, previous_sunday
def invalid_date_range() -> tuple[date, date]:
return date.max, date.min
def merge_optional_range(
current: tuple[date | None, date | None],
new_range: tuple[date, date] | None,
) -> tuple[date, date]:
if new_range is None:
return merge_range(current, invalid_date_range())
return merge_range(current, new_range)
def merge_range(
current: tuple[date | None, date | None],
new_range: tuple[date, date],
) -> tuple[date, date]:
current_start, current_end = current
new_start, new_end = new_range
if current_start is None or current_end is None:
return new_start, new_end
return max(current_start, new_start), min(current_end, new_end)
def normalize_range(
start: date,
end: date,
end_year_was_explicit: bool,
) -> tuple[date, date]:
if start <= end:
return start, end
if not end_year_was_explicit and start.month > end.month:
adjusted_end = safe_date(start.year + 1, end.month, end.day)
if adjusted_end:
return start, adjusted_end
return end, start
def parse_explicit_date_range(match: re.Match[str], today: date) -> tuple[date, date] | None:
start_year_text = match.group("sy")
end_year_text = match.group("ey")
start_month = int(match.group("sm"))
start_day = int(match.group("sd"))
end_month = int(match.group("em")) if match.group("em") else start_month
end_day = int(match.group("ed"))
start_year = int(start_year_text) if start_year_text else today.year
end_year = int(end_year_text) if end_year_text else start_year
start = safe_date(start_year, start_month, start_day)
end = safe_date(end_year, end_month, end_day)
if start is None or end is None:
return None
return normalize_range(start, end, end_year_was_explicit=bool(end_year_text))
def blank_spans(text: str, spans: list[tuple[int, int]]) -> str:
chars = list(text)
for start, end in spans:
for index in range(start, end):
chars[index] = " "
return "".join(chars)
def parse_date_filters(query: str, today: date) -> tuple[date | None, date | None]:
result: tuple[date | None, date | None] = (None, None)
range_spans: list[tuple[int, int]] = []
for pattern in (CHINESE_DATE_RANGE_RE, NUMERIC_DATE_RANGE_RE):
for match in pattern.finditer(query):
range_spans.append(match.span())
result = merge_optional_range(result, parse_explicit_date_range(match, today))
single_date_query = blank_spans(query, range_spans)
if "今天" in query:
result = merge_range(result, (today, today))
if "昨天" in query:
yesterday = today - timedelta(days=1)
result = merge_range(result, (yesterday, yesterday))
if "前天" in query:
day_before_yesterday = today - timedelta(days=2)
result = merge_range(result, (day_before_yesterday, day_before_yesterday))
if "本月" in query:
result = merge_range(result, month_range(today.year, today.month))
if "上月" in query:
last_month_year = today.year if today.month > 1 else today.year - 1
last_month = today.month - 1 if today.month > 1 else 12
result = merge_range(result, month_range(last_month_year, last_month))
if "上周末" in query:
result = merge_range(result, previous_weekend_range(today))
for match in re.finditer(r"最近\s*(\d+)\s*天", query):
days = int(match.group(1))
if days < 1:
result = merge_range(result, invalid_date_range())
else:
result = merge_range(result, (today - timedelta(days=days - 1), today))
for match in re.finditer(r"(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?", single_date_query):
year, month, day = map(int, match.groups())
day_date = safe_date(year, month, day)
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
for match in re.finditer(r"(?<!\d)(\d{4})[./-](\d{1,2})[./-](\d{1,2})(?!\d)", single_date_query):
year, month, day = map(int, match.groups())
day_date = safe_date(year, month, day)
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
for match in re.finditer(r"(\d{4})\s*年\s*(\d{1,2})\s*月(?!\s*\d)", single_date_query):
year, month = map(int, match.groups())
result = merge_optional_range(result, safe_month_range(year, month))
for match in re.finditer(r"(?<!\d)(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?", single_date_query):
month, day = map(int, match.groups())
day_date = safe_date(today.year, month, day)
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
for match in re.finditer(r"(?<!\d)(\d{1,2})[./](\d{1,2})(?!\d)", single_date_query):
month, day = map(int, match.groups())
day_date = safe_date(today.year, month, day)
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
for match in re.finditer(r"(?<!\d)(\d{1,2})\s*月(?!\s*\d)", single_date_query):
month = int(match.group(1))
result = merge_optional_range(result, safe_month_range(today.year, month))
return result
def unique_sorted(items: Iterable[str]) -> list[str]:
return sorted(set(items))
def role_hint_before(query: str, name: str, role: str) -> bool:
for match in re.finditer(re.escape(name), query):
prefix = query[max(0, match.start() - 4) : match.start()]
suffix = query[match.end() : match.end() + 4]
if any(word in prefix or word in suffix for word in ROLE_WORDS[role]):
return True
return False
def detect_people(query: str, records: list[ClassRecord]) -> tuple[list[str], list[str]]:
students = set()
teachers = set()
canonical_query = query
for alias, canonical in FALLBACK_ALIASES.items():
if alias in query and canonical != alias:
canonical_query += f" {canonical}"
for name in unique_sorted(record.student for record in records):
if name in canonical_query:
students.add(name)
for name in unique_sorted(record.teacher for record in records):
if name in canonical_query:
teachers.add(name)
for name in sorted(students & teachers):
student_hint = role_hint_before(canonical_query, name, "student")
teacher_hint = role_hint_before(canonical_query, name, "teacher")
if student_hint and not teacher_hint:
teachers.discard(name)
elif teacher_hint and not student_hint:
students.discard(name)
return sorted(students), sorted(teachers)
def parse_subject_code(text: str) -> str:
value = text.strip()
if value in SUBJECTS:
return value
result: list[str] = []
index = 0
while index < len(value):
if value.startswith("道法", index):
result.append("道法")
index += 2
continue
char = value[index]
result.append(SUBJECT_ALIASES.get(char, char))
index += 1
return "".join(result)
def normalize_subject(text: str) -> str:
return parse_subject_code(text).strip()
def detect_subjects(query: str) -> list[str]:
subjects = {subject for subject in SUBJECTS if subject in query}
for alias, subject in SUBJECT_ALIASES.items():
if re.search(rf"(?<![\u4e00-\u9fff]){re.escape(alias)}(?![\u4e00-\u9fff])", query):
subjects.add(subject)
for match in re.finditer(r"[一二三四五六七八九高初]*([数语英物化生史地政]|道法)+课", query):
parsed = parse_subject_code(match.group(0).replace("", ""))
for subject in SUBJECTS:
if subject in parsed:
subjects.add(subject)
return sorted(subjects)
def build_query_spec(query: str, records: list[ClassRecord], today: date | None = None) -> QuerySpec:
effective_today = today or date.today()
start_date, end_date = parse_date_filters(query, effective_today)
students, teachers = detect_people(query, records)
subjects = detect_subjects(query)
return QuerySpec(
raw_query=query,
start_date=start_date,
end_date=end_date,
students=students,
teachers=teachers,
subjects=subjects,
)
def build_public_query_spec(query: str, records: list[ClassRecord], teachers: list[Teacher], today: date | None = None) -> QuerySpec:
spec = build_query_spec(query, records, today=today)
return replace(spec, raw_query=query)
def filter_records(records: list[ClassRecord], spec: QuerySpec) -> list[ClassRecord]:
matched: list[ClassRecord] = []
for record in records:
record_date = parse_record_date(record.date)
if spec.start_date and record_date < spec.start_date:
continue
if spec.end_date and record_date > spec.end_date:
continue
if spec.students and record.student not in spec.students:
continue
if spec.teachers and record.teacher not in spec.teachers:
continue
if spec.subjects and not any(subject in record.subject for subject in spec.subjects):
continue
matched.append(record)
return sorted(matched, key=lambda item: (item.date, item.time, item.student, item.teacher, item.subject))
def format_date_range(spec: QuerySpec) -> str:
if not spec.start_date and not spec.end_date:
return "未限制"
if spec.start_date and spec.end_date and spec.start_date > spec.end_date:
return "无效日期"
if spec.start_date == spec.end_date:
return spec.start_date.strftime("%Y.%m.%d")
start = spec.start_date.strftime("%Y.%m.%d") if spec.start_date else "不限"
end = spec.end_date.strftime("%Y.%m.%d") if spec.end_date else "不限"
return f"{start}{end}"
def summarize_records(records: list[ClassRecord]) -> dict:
by_student: defaultdict[str, float] = defaultdict(float)
by_teacher: defaultdict[str, float] = defaultdict(float)
by_subject: defaultdict[str, float] = defaultdict(float)
for record in records:
by_student[record.student] += record.duration_hours
by_teacher[record.teacher] += record.duration_hours
by_subject[record.subject] += record.duration_hours
students = dict(sorted((key, round(value, 2)) for key, value in by_student.items()))
teachers = dict(sorted((key, round(value, 2)) for key, value in by_teacher.items()))
subjects = dict(sorted((key, round(value, 2)) for key, value in by_subject.items()))
total_hours = round(sum(record.duration_hours for record in records), 2)
return {
"count": len(records),
"total_hours": total_hours,
"total_duration": duration_text_from_hours(total_hours),
"students": students,
"students_duration": duration_text_map(students),
"teachers": teachers,
"teachers_duration": duration_text_map(teachers),
"subjects": subjects,
"subjects_duration": duration_text_map(subjects),
}
def summarize_public_records(records: list[ClassRecord], teachers: list[Teacher]) -> dict:
display_names = teacher_alias_map(teachers)
by_student: defaultdict[str, float] = defaultdict(float)
by_teacher: defaultdict[str, float] = defaultdict(float)
by_subject: defaultdict[str, float] = defaultdict(float)
for record in records:
by_student[record.student] += record.duration_hours
by_teacher[display_names.get(record.teacher, record.teacher)] += record.duration_hours
by_subject[record.subject] += record.duration_hours
students = dict(sorted((key, round(value, 2)) for key, value in by_student.items()))
teachers_summary = dict(sorted((key, round(value, 2)) for key, value in by_teacher.items()))
subjects = dict(sorted((key, round(value, 2)) for key, value in by_subject.items()))
total_hours = round(sum(record.duration_hours for record in records), 2)
return {
"count": len(records),
"total_hours": total_hours,
"total_duration": duration_text_from_hours(total_hours),
"students": students,
"students_duration": duration_text_map(students),
"teachers": teachers_summary,
"teachers_duration": duration_text_map(teachers_summary),
"subjects": subjects,
"subjects_duration": duration_text_map(subjects),
}
def record_identity(record: ClassRecord) -> str:
return sha1_text(class_record_to_line(record), 20)
def record_to_dict(record: ClassRecord) -> dict:
return {
"date": record.date,
"weekday": record.weekday,
"time": record.time,
"student": record.student,
"duration": record.duration,
"duration_hours": record.duration_hours,
"teacher": record.teacher,
"subject": record.subject,
}
def public_record_to_dict(
record: ClassRecord,
teachers: list[Teacher],
summary_index: dict[tuple[str, str, str, str, str], list[dict]] | None = None,
) -> dict:
display_names = teacher_alias_map(teachers)
summary_key = course_summary_record_key(
record.student,
record.teacher,
record.subject,
record.date.replace(".", "-"),
record.time,
)
summaries = [
course_summary_to_public(item, teachers)
for item in (summary_index or {}).get(summary_key, [])
]
return {
"record_id": record_identity(record),
"date": record.date,
"weekday": record.weekday,
"time": record.time,
"student": record.student,
"duration": record.duration,
"duration_hours": record.duration_hours,
"teacher": display_names.get(record.teacher, record.teacher),
"subject": record.subject,
"summary_count": len(summaries),
"summaries": summaries,
}
def account_to_dict(account: Account) -> dict:
return {
"student_id": account.student_id,
"student": account.student,
"payments": [
{"date": payment.date, "hours": payment.hours, "duration": duration_text_from_hours(payment.hours)}
for payment in account.payments
],
"payments_count": len(account.payments),
"remaining": account.remaining,
"remaining_duration": duration_text_from_hours(account.remaining),
"account_status": account.account_status,
"primary_entry_year": account.primary_entry_year,
"note": account.note,
}
def has_filter_condition(spec: QuerySpec) -> bool:
return bool(spec.start_date or spec.end_date or spec.students or spec.teachers or spec.subjects)
def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> dict:
spec = build_query_spec(query, records)
matched = filter_records(records, spec) if has_filter_condition(spec) else []
shown, normalized_offset, has_more = paginate_items(matched, 0, limit)
return {
"query": {
"raw_query": spec.raw_query,
"date_range": format_date_range(spec),
"students": spec.students,
"teachers": spec.teachers,
"subjects": spec.subjects,
},
"summary": summarize_records(matched),
"records": [record_to_dict(record) for record in shown],
"total_records": len(matched),
"shown_records": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
}
def query_public_records(
records: list[ClassRecord],
teachers: list[Teacher],
query: str,
limit: int = 200,
offset: int = 0,
summaries_root: Path | None = None,
) -> dict:
spec = build_public_query_spec(query, records, teachers)
matched = filter_records(records, spec) if has_filter_condition(spec) else []
shown, normalized_offset, has_more = paginate_items(matched, offset, limit)
display_names = teacher_alias_map(teachers)
summary_index = course_summary_index_for_records(summaries_root, records) if summaries_root is not None else {}
return {
"query": {
"raw_query": spec.raw_query,
"date_range": format_date_range(spec),
"students": spec.students,
"teachers": [display_names.get(teacher, teacher) for teacher in spec.teachers],
"subjects": spec.subjects,
},
"summary": summarize_public_records(matched, teachers),
"records": [public_record_to_dict(record, teachers, summary_index) for record in shown],
"total_records": len(matched),
"shown_records": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
}
def find_record_by_identity(records: list[ClassRecord], record_id: str) -> ClassRecord:
target = record_id.strip()
for record in records:
if record_identity(record) == target:
return record
raise ValueError(f"未找到上课记录: {record_id}")
def account_summary(accounts: list[Account]) -> dict:
result = {
"total": len(accounts),
"normal": 0,
"warning": 0,
"debt": 0,
"completed": 0,
"refunded": 0,
"other": 0,
}
for account in accounts:
status = account.account_status
if status == "正常":
result["normal"] += 1
elif status == "预警":
result["warning"] += 1
elif status == "欠费":
result["debt"] += 1
elif status == "结课":
result["completed"] += 1
elif status == "退费":
result["refunded"] += 1
else:
result["other"] += 1
return result
def dashboard_period_bounds(period: str, today: date | None = None) -> tuple[str, date | None, date | None]:
normalized = period.strip().lower() or "month"
today = today or date.today()
if normalized == "today":
return normalized, today, today
if normalized in {"7d", "week"}:
return "7d", today - timedelta(days=6), today
if normalized == "all":
return normalized, None, None
return "month", today.replace(day=1), today
def record_date_value(record: ClassRecord) -> date:
return date.fromisoformat(record.date.replace(".", "-"))
def records_in_period(records: list[ClassRecord], start: date | None, end: date | None) -> list[ClassRecord]:
result = []
for record in records:
current = record_date_value(record)
if start is not None and current < start:
continue
if end is not None and current > end:
continue
result.append(record)
return result
def top_duration_items(values: dict[str, float], limit: int = 8) -> list[dict]:
return [
{"name": name, "hours": round(hours, 2), "duration": duration_text_from_hours(hours)}
for name, hours in sorted(values.items(), key=lambda item: (-item[1], item[0]))[:limit]
]
def dashboard_course_summary_statuses(root: Path, records: list[ClassRecord]) -> dict:
result = {
"total": 0,
"matched": 0,
"auto_bound": 0,
"unmatched": 0,
"missing_time": 0,
"mismatch": 0,
"with_candidate": 0,
}
record_keys = {class_record_binding_key(record): record for record in records}
for item in iter_course_summary_markdown(root):
result["total"] += 1
binding = course_summary_binding_status(item, record_keys, records)
status = str(binding.get("status") or "unmatched")
if status in result:
result[status] += 1
else:
result["unmatched"] += 1
if binding.get("auto_bound"):
result["auto_bound"] += 1
if binding.get("candidates"):
result["with_candidate"] += 1
return result
def dashboard_task_summary(tasks_path: Path) -> dict:
tasks = read_admin_tasks(tasks_path).get("items", [])
active = [task for task in tasks if task.get("status") in {"pending", "conflict"}]
by_type: defaultdict[str, int] = defaultdict(int)
by_status: defaultdict[str, int] = defaultdict(int)
for task in active:
by_type[str(task.get("type") or "unknown")] += 1
by_status[str(task.get("status") or "unknown")] += 1
return {
"active": len(active),
"pending": by_status.get("pending", 0),
"conflict": by_status.get("conflict", 0),
"by_type": dict(sorted(by_type.items())),
}
def admin_dashboard_summary(
*,
classnotes_path: Path,
accounts_path: Path,
teachers_path: Path,
tasks_path: Path,
summaries_root: Path,
operation_logs_path: Path,
period: str = "month",
) -> dict:
normalized_period, start, end = dashboard_period_bounds(period)
records = read_classnotes(classnotes_path) if classnotes_path.exists() else []
period_records = records_in_period(records, start, end)
accounts = read_accounts(accounts_path) if accounts_path.exists() else []
teachers = read_teachers(teachers_path)
summary = summarize_records(period_records)
daily: defaultdict[str, float] = defaultdict(float)
for record in period_records:
daily[record.date.replace(".", "-")] += record.duration_hours
active_students = [account for account in accounts if account.account_status not in {"结课", "退费"}]
low_remaining = sorted(active_students, key=lambda account: (account.remaining, account.student))[:8]
logs = list_operation_logs(operation_logs_path, limit=10).get("items", []) if operation_logs_path.exists() else []
return {
"period": {
"value": normalized_period,
"start": start.isoformat() if start else "",
"end": end.isoformat() if end else "",
},
"overview": {
"records": summary["count"],
"hours": summary["total_hours"],
"duration": summary["total_duration"],
"students": len(summary["students"]),
"teachers": len(summary["teachers"]),
"subjects": len(summary["subjects"]),
},
"teaching": {
"daily": [
{"date": key, "hours": round(value, 2), "duration": duration_text_from_hours(value)}
for key, value in sorted(daily.items())
],
"teachers": top_duration_items(summary["teachers"]),
"subjects": top_duration_items(summary["subjects"]),
"students": top_duration_items(summary["students"]),
},
"students": {
"summary": account_summary(accounts),
"low_remaining": [
{
"student": account.student,
"student_id": account.student_id,
"remaining": account.remaining,
"remaining_duration": duration_text_from_hours(account.remaining),
"status": account.account_status,
}
for account in low_remaining
],
},
"teachers": {
"total": len(teachers),
"active": sum(1 for teacher in teachers if teacher.status == "在岗"),
"inactive": sum(1 for teacher in teachers if teacher.status != "在岗"),
},
"course_summaries": dashboard_course_summary_statuses(summaries_root, records),
"tasks": dashboard_task_summary(tasks_path),
"logs": logs,
}
def filter_accounts(accounts: list[Account], keyword: str = "", status: str = "") -> list[Account]:
keyword = keyword.strip()
status = status.strip()
result = []
for account in accounts:
if keyword and keyword not in account.student and keyword not in account.student_id:
continue
if status and account.account_status != status:
continue
result.append(account)
return sorted(result, key=lambda item: (item.remaining, item.student))