feat: add course summary search

This commit is contained in:
Codex
2026-06-15 12:08:04 +08:00
parent 8832a52310
commit df076f6951
7 changed files with 317 additions and 2 deletions
+165
View File
@@ -42,6 +42,8 @@ CLASSNOTE_RE = re.compile(
)
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})")
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
BACKUP_KEEP_COUNT = 50
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
@@ -1059,6 +1061,169 @@ def list_operation_logs(path: Path, limit: int = 100, operation: str = "", statu
return {"count": len(rows), "items": rows}
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_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 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
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
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),
"group": group,
"body": body_text,
"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 query_course_summaries(
root: Path,
q: str = "",
student: str = "",
teacher: str = "",
subject: str = "",
date_from: str = "",
date_to: str = "",
limit: int = 200,
) -> 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("开始日期不能晚于结束日期")
keyword = q.strip()
matched = [
item
for item in iter_course_summary_markdown(root)
if course_summary_matches(
item,
keyword,
canonical_name(student.strip()),
canonical_name(teacher.strip()),
subject.strip(),
normalized_from,
normalized_to,
)
]
for item in matched:
item["matched_fields"] = course_summary_matched_fields(item, keyword)
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 = matched[:limit]
return {
"count": len(matched),
"returned": len(limited),
"items": limited,
}
def course_summary_path(root: Path, summary: dict) -> Path:
student = safe_filename_part(summary["student"])
teacher = safe_filename_part(summary["teacher"] or "待核对老师")