Files
xsk-education-management/app/app/ai_register.py
T
2026-07-06 16:51:14 +08:00

712 lines
26 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 datetime import date, datetime, timedelta
import re
import secrets
from typing import Any
from .data import (
SUBJECTS,
canonical_teacher_name,
class_record_to_line,
course_summary_to_class_record_line,
extract_course_summary_from_text,
normalize_course_summary,
normalize_lines,
parse_class_record_line,
parse_payment_line,
)
from .repository import list_student_names
REGISTER_TYPES = {"class_record", "payment", "course_summary"}
FIELD_LABELS = {
"class_record": {
"student": "学生",
"date": "日期",
"time": "时间",
"teacher": "老师",
"subject": "科目",
"duration": "时长",
},
"payment": {
"student": "学生",
"date": "缴费日期",
"hours": "课时数",
},
"course_summary": {
"student": "学生",
"date": "日期",
"time": "时间",
"teacher": "老师",
"subject": "科目",
"body": "小结正文",
},
}
REQUIRED_FIELDS = {
"class_record": ["student", "date", "time", "teacher", "subject"],
"payment": ["student", "date", "hours"],
"course_summary": ["student", "date", "time", "teacher", "subject", "body"],
}
DATE_RE = re.compile(r"(?P<y>\d{4})[./年-]\s*(?P<m>\d{1,2})[./月-]\s*(?P<d>\d{1,2})")
MONTH_DAY_RE = re.compile(r"(?<!\d)(?P<m>\d{1,2})\s*[月./-]\s*(?P<d>\d{1,2})\s*(?:日|号)?")
TIME_RANGE_FLEX_RE = re.compile(
r"(?P<sh>\d{1,2})\s*(?:[::点.])\s*(?P<sm>\d{1,2})?\s*"
r"(?:-||—||~|至|到)\s*"
r"(?P<eh>\d{1,2})\s*(?:[::点.])\s*(?P<em>\d{1,2})?"
)
TIME_RANGE_HOUR_RE = re.compile(
r"(?<![\d./-])(?P<sh>\d{1,2})\s*(?:-||—||~|至|到)\s*(?P<eh>\d{1,2})(?![\d./-])"
)
TIME_RANGE_COMPACT_RE = re.compile(
r"(?<!\d)(?P<start>\d{3,4})\s*(?:-||—||~|至|到)\s*(?P<end>\d{3,4})(?!\d)"
)
TEACHER_RE = re.compile(r"(?P<teacher>[\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)")
HOURS_RE = re.compile(r"(?P<hours>\d+(?:\.\d+)?)\s*(?:课时|小时)")
MAX_SESSION_AGE_SECONDS = 30 * 60
MAX_SESSIONS = 100
WAITING_PLACEHOLDER = "[待补充]"
_SESSIONS: dict[str, dict[str, Any]] = {}
def _now() -> datetime:
return datetime.now()
def _cleanup_sessions() -> None:
cutoff = _now() - timedelta(seconds=MAX_SESSION_AGE_SECONDS)
stale = [
session_id
for session_id, session in _SESSIONS.items()
if session.get("updated_at", _now()) < cutoff
]
for session_id in stale:
_SESSIONS.pop(session_id, None)
if len(_SESSIONS) <= MAX_SESSIONS:
return
ordered = sorted(_SESSIONS.items(), key=lambda item: item[1].get("updated_at", _now()))
for session_id, _session in ordered[: len(_SESSIONS) - MAX_SESSIONS]:
_SESSIONS.pop(session_id, None)
def _session_for(conversation_id: str | None, register_type: str, text: str) -> tuple[str, dict[str, Any]]:
_cleanup_sessions()
if conversation_id and conversation_id in _SESSIONS:
session = _SESSIONS[conversation_id]
else:
conversation_id = secrets.token_urlsafe(16)
session = {"type": register_type, "text": text, "answers": {}, "created_at": _now()}
_SESSIONS[conversation_id] = session
session["type"] = register_type
if text:
session["text"] = text
session["updated_at"] = _now()
return conversation_id, session
def normalize_register_type(value: str) -> str:
text = str(value or "").strip()
aliases = {
"class": "class_record",
"class_records": "class_record",
"class-records": "class_record",
"上课记录": "class_record",
"payments": "payment",
"缴费": "payment",
"course-summaries": "course_summary",
"course_summaries": "course_summary",
"课程小结": "course_summary",
}
register_type = aliases.get(text, text)
if register_type not in REGISTER_TYPES:
raise ValueError("不支持的登记类型")
return register_type
def collect_input_lines(
text: str | None = None,
lines: list[str] | None = None,
register_type: str = "",
) -> list[str]:
if lines is not None:
return normalize_lines(lines=lines)
raw = str(text or "")
if register_type in {"class_record", "payment"}:
return normalize_lines(lines=raw.splitlines())
if "\n\n" in raw:
chunks = [item.strip() for item in re.split(r"\n\s*\n", raw) if item.strip()]
if chunks:
return chunks
return normalize_lines(line=raw)
def _field_labels(register_type: str) -> dict[str, str]:
return FIELD_LABELS.get(register_type, {})
def _questions_for_missing(register_type: str, missing_fields: list[str]) -> list[str]:
labels = _field_labels(register_type)
return [f"请补充{labels.get(field, field)}" for field in missing_fields]
def _known_students() -> list[str]:
try:
return list_student_names()
except Exception:
return []
def _normalize_date(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
for match in DATE_RE.finditer(text):
year = int(match.group("y"))
month = int(match.group("m"))
day = int(match.group("d"))
try:
datetime(year, month, day)
except ValueError:
continue
return f"{year:04d}-{month:02d}-{day:02d}"
for match in MONTH_DAY_RE.finditer(text):
year = date.today().year
month = int(match.group("m"))
day = int(match.group("d"))
try:
datetime(year, month, day)
except ValueError:
continue
return f"{year:04d}-{month:02d}-{day:02d}"
return ""
def _record_date(value: str) -> str:
return value.replace("-", ".")
def _weekday(value: str) -> str:
weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
return weekdays[datetime.strptime(value, "%Y-%m-%d").date().weekday()]
def _normalize_time_range(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
standard = re.search(r"\d{1,2}:\d{2}\s*(?:-||—||~|至|到)\s*\d{1,2}:\d{2}", text)
if standard:
raw = re.sub(r"\s*(?:|—||~|至|到)\s*", "-", standard.group(0))
raw = re.sub(r"\s*-\s*", "-", raw)
start, end = raw.split("-", 1)
sh, sm = [int(part) for part in start.split(":", 1)]
eh, em = [int(part) for part in end.split(":", 1)]
else:
match = TIME_RANGE_FLEX_RE.search(text)
if match:
sh = int(match.group("sh"))
sm = int(match.group("sm") or 0)
eh = int(match.group("eh"))
em = int(match.group("em") or 0)
else:
compact = TIME_RANGE_COMPACT_RE.search(text)
if compact:
start = compact.group("start").zfill(4)
end = compact.group("end").zfill(4)
sh = int(start[:-2])
sm = int(start[-2:])
eh = int(end[:-2])
em = int(end[-2:])
else:
hour_only = TIME_RANGE_HOUR_RE.search(text)
if not hour_only:
return ""
sh = int(hour_only.group("sh"))
sm = 0
eh = int(hour_only.group("eh"))
em = 0
if sh > 23 or eh > 23 or sm > 59 or em > 59:
return ""
if eh * 60 + em <= sh * 60 + sm:
return ""
return f"{sh:02d}:{sm:02d}-{eh:02d}:{em:02d}"
def _duration_from_time_range(time_range: str) -> str:
start, end = time_range.split("-", 1)
sh, sm = [int(part) for part in start.split(":", 1)]
eh, em = [int(part) for part in end.split(":", 1)]
minutes = eh * 60 + em - sh * 60 - sm
return f"{minutes // 60}小时{minutes % 60}"
def _duration_from_text(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
if re.fullmatch(r"\d+小时\d+分", text):
return text
match = re.search(r"(?P<h>\d+)\s*小时\s*(?P<m>\d+)?\s*分?", text)
if match:
return f"{int(match.group('h'))}小时{int(match.group('m') or 0)}"
match = re.search(r"(?P<hours>\d+(?:\.\d+)?)\s*(?:课时|小时)", text)
if match:
total = int(round(float(match.group("hours")) * 60))
return f"{total // 60}小时{total % 60}"
return ""
def _extract_student(text: str) -> str:
match = re.search(r"(?:学生|学员)[:\s]*(?P<student>[\u4e00-\u9fa5A-Za-z0-9]{2,8})", text)
if match:
return match.group("student")
for student in _known_students():
if student and student in text:
return student
match = re.search(r"^\s*(?P<student>[\u4e00-\u9fa5]{2,4})(?=\s|[,,。;;:]|\d)", text)
return match.group("student") if match else ""
def _extract_class_record_student(text: str, teacher: str, subject: str) -> str:
student = _extract_student(text)
if student:
return student
remainder = text
for pattern in (DATE_RE, MONTH_DAY_RE, TIME_RANGE_FLEX_RE, TIME_RANGE_COMPACT_RE, TIME_RANGE_HOUR_RE):
remainder = pattern.sub(" ", remainder)
remainder = re.sub(r"\d{1,2}:\d{2}\s*(?:-||—||~|至|到)\s*\d{1,2}:\d{2}", " ", remainder)
remainder = re.sub(r"星期[一二三四五六日]", " ", remainder)
remainder = re.sub(r"\d+(?:\.\d+)?\s*(?:课时|小时)", " ", remainder)
remainder = re.sub(r"\d+\s*小时\s*\d*\s*分?", " ", remainder)
if teacher:
remainder = re.sub(rf"{re.escape(teacher)}(?:老师|教师)?", " ", remainder)
if subject:
remainder = remainder.replace(subject, " ")
remainder = re.sub(r"(?:学生|学员|老师|教师|科目|课程|时间|日期|上课)", " ", remainder)
for candidate in re.findall(r"[\u4e00-\u9fa5]{2,4}", remainder):
if candidate not in SUBJECTS and candidate not in {"老师", "教师", "学生", "学员"}:
return candidate
return ""
def _extract_teacher(text: str) -> str:
match = TEACHER_RE.search(text)
return canonical_teacher_name(match.group("teacher")) if match else ""
def _normalize_teacher_hint(value: object) -> str:
text = canonical_teacher_name(str(value or "").strip())
return re.sub(r"^以下都是", "", text).strip()
def _extract_subject(text: str) -> str:
for subject in SUBJECTS:
if subject in text:
return subject
match = re.search(r"(?:科目|课程)[:\s]*(?P<subject>[\u4e00-\u9fa5A-Za-z0-9]{1,12})", text)
return match.group("subject") if match else ""
def _merge_answers(fields: dict[str, str], answers: dict[str, str]) -> dict[str, str]:
merged = dict(fields)
for key, value in answers.items():
text = str(value or "").strip()
if not text:
continue
if key == "followup" or key.startswith("items."):
continue
merged[key] = text
return merged
def _answers_for_item(answers: dict[str, str], index: int, *, include_legacy: bool = False) -> dict[str, str]:
prefix = f"items.{index}."
item_answers = {
key.removeprefix(prefix): value
for key, value in answers.items()
if key.startswith(prefix)
}
if include_legacy:
for key, value in answers.items():
if key == "followup" or key.startswith("items."):
continue
item_answers.setdefault(key, value)
return item_answers
def _field_is_missing(register_type: str, field: str, fields: dict[str, str]) -> bool:
value = str(fields.get(field) or "").strip()
if not value:
return True
if field == "date":
return not _normalize_date(value)
if register_type == "class_record" and field == "time":
return not _normalize_time_range(value)
if register_type == "payment" and field == "hours":
hours = re.sub(r"\s*(?:课时|小时)$", "", value)
try:
return float(hours) <= 0
except ValueError:
return True
return False
def _missing_fields(register_type: str, fields: dict[str, str]) -> list[str]:
return [field for field in REQUIRED_FIELDS[register_type] if _field_is_missing(register_type, field, fields)]
def _needs_info_response(
register_type: str,
fields: dict[str, str],
missing_fields: list[str],
*,
summary: str = "",
timed_out: bool = False,
error: str = "",
draft_line: str = "",
draft_items: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
return {
"status": "needs_info",
"standard_lines": [],
"questions": _questions_for_missing(register_type, missing_fields) or [_question_for_error(register_type, error or "信息不完整")],
"summary": summary or "请补齐缺失字段后再次生成预览",
"ai_used": False,
"fields": fields,
"missing_fields": missing_fields,
"field_labels": _field_labels(register_type),
"timed_out": timed_out,
"recognition_source": "partial_local",
"error": error,
"draft_line": draft_line,
"draft_items": draft_items or [],
}
def _standard_from_fields(register_type: str, fields: dict[str, str]) -> str:
if register_type == "class_record":
date_value = _normalize_date(fields.get("date"))
time_range = _normalize_time_range(fields.get("time"))
if not date_value or not time_range:
raise ValueError("上课记录日期或时间缺失")
duration = _duration_from_text(fields.get("duration")) or _duration_from_time_range(time_range)
return (
f"{_record_date(date_value)}-{_weekday(date_value)}-{time_range}-"
f"{fields['student']}-{duration}-{fields['teacher']}-{fields['subject']}"
)
if register_type == "payment":
date_value = _normalize_date(fields.get("date"))
if not date_value:
raise ValueError("缴费日期缺失")
hours = str(fields.get("hours") or "").strip()
hours = re.sub(r"\s*(?:课时|小时)$", "", hours)
return f"{fields['student']}-{date_value}:{hours}"
date_value = _normalize_date(fields.get("date"))
time_range = _normalize_time_range(fields.get("time"))
return "\n".join(
[
f"学生:{fields['student']}",
f"日期:{date_value}",
f"时间:{time_range}",
f"老师:{fields['teacher']}",
f"科目:{fields['subject']}",
"小结:",
str(fields.get("body") or "").strip(),
]
)
def _draft_from_fields(register_type: str, fields: dict[str, str]) -> str:
if register_type == "class_record":
date_iso = _normalize_date(fields.get("date"))
time_range = _normalize_time_range(fields.get("time"))
student = fields.get("student") or WAITING_PLACEHOLDER
date_value = _record_date(date_iso) if date_iso else WAITING_PLACEHOLDER
weekday = _weekday(date_iso) if date_iso else WAITING_PLACEHOLDER
teacher = _normalize_teacher_hint(fields.get("teacher")) or WAITING_PLACEHOLDER
duration = _duration_from_text(fields.get("duration")) or (
_duration_from_time_range(time_range) if time_range else WAITING_PLACEHOLDER
)
subject = fields.get("subject") or WAITING_PLACEHOLDER
return f"{date_value}-{weekday}-{time_range or WAITING_PLACEHOLDER}-{student}-{duration}-{teacher}-{subject}"
if register_type == "payment":
student = fields.get("student") or WAITING_PLACEHOLDER
date_value = _normalize_date(fields.get("date")) or WAITING_PLACEHOLDER
hours = str(fields.get("hours") or "").strip() or WAITING_PLACEHOLDER
return f"{student}-{date_value}:{hours}"
student = fields.get("student") or "【学生】"
date_value = _normalize_date(fields.get("date")) or "【日期】"
time_range = _normalize_time_range(fields.get("time")) or "【时间】"
teacher = _normalize_teacher_hint(fields.get("teacher")) or "【老师】"
subject = fields.get("subject") or "【科目】"
body = str(fields.get("body") or "").strip() or "【小结正文】"
return "\n".join(
[
f"学生:{student}",
f"日期:{date_value}",
f"时间:{time_range}",
f"老师:{teacher}",
f"科目:{subject}",
"小结:",
body,
]
)
def _question_for_error(register_type: str, message: str) -> str:
if register_type == "class_record":
return f"请按标准格式补齐或修改上课记录:{message}"
if register_type == "payment":
return f"请按“学生-YYYY-MM-DD:课时”补齐或修改缴费记录:{message}"
return f"请补齐或修改课程小结信息:{message}"
def _extract_class_record_fields(text: str) -> dict[str, str]:
teacher = _extract_teacher(text)
subject = _extract_subject(text)
return {
"student": _extract_class_record_student(text, teacher, subject),
"date": _normalize_date(text),
"time": _normalize_time_range(text),
"teacher": teacher,
"subject": subject,
"duration": _duration_from_text(text),
}
def _extract_payment_fields(text: str) -> dict[str, str]:
hours_match = HOURS_RE.search(text)
return {
"student": _extract_student(text),
"date": _normalize_date(text),
"hours": hours_match.group("hours") if hours_match else "",
}
def _extract_course_summary_fields(text: str) -> dict[str, str]:
try:
raw = extract_course_summary_from_text(text, known_students=_known_students())
except ValueError:
raw = {}
body = str(raw.get("body") or "").strip()
if body == text.strip() and any(label in text for label in ("小结", "正文", "内容")):
body = re.split(r"(?:小结|正文|内容)[:]?", text, maxsplit=1)[-1].strip()
return {
"student": str(raw.get("student") or _extract_student(text)).strip(),
"date": _normalize_date(raw.get("date_iso") or text),
"time": _normalize_time_range(raw.get("time_range") or text),
"teacher": _normalize_teacher_hint(raw.get("teacher") or _extract_teacher(text)),
"subject": str(raw.get("subject") or _extract_subject(text)).strip(),
"body": body or text.strip(),
}
def extract_local_fields(register_type: str, lines: list[str]) -> dict[str, str]:
text = "\n".join(lines).strip()
if register_type == "class_record":
return _extract_class_record_fields(text)
if register_type == "payment":
return _extract_payment_fields(text)
return _extract_course_summary_fields(text)
def extract_line_fields(register_type: str, line: str) -> dict[str, str]:
if register_type == "class_record":
return _extract_class_record_fields(line)
if register_type == "payment":
return _extract_payment_fields(line)
return _extract_course_summary_fields(line)
def multi_line_fields_preview(register_type: str, lines: list[str], answers: dict[str, str]) -> dict[str, Any]:
draft_items: list[dict[str, Any]] = []
standard_lines: list[str] = []
all_ready = True
questions: list[str] = []
union_missing: list[str] = []
for index, line in enumerate(lines):
fields = extract_line_fields(register_type, line)
fields = _merge_answers(fields, _answers_for_item(answers, index, include_legacy=len(lines) == 1))
missing = _missing_fields(register_type, fields)
error = ""
standard_line = ""
if missing:
all_ready = False
else:
try:
standard_line = validate_standard_lines(register_type, [_standard_from_fields(register_type, fields)])[0]
standard_lines.append(standard_line)
except (ValueError, KeyError) as exc:
all_ready = False
error = str(exc)
for field in missing:
if field not in union_missing:
union_missing.append(field)
labels = _field_labels(register_type)
questions.extend(f"{index + 1} 条请补充{labels.get(field, field)}" for field in missing)
if error and not missing:
questions.append(f"{index + 1}{_question_for_error(register_type, error)}")
draft_items.append(
{
"index": index,
"source_line": line,
"fields": fields,
"missing_fields": missing,
"questions": _questions_for_missing(register_type, missing),
"draft_line": standard_line or _draft_from_fields(register_type, fields),
"error": error,
}
)
if all_ready:
return {
"status": "ready",
"standard_lines": standard_lines,
"questions": [],
"summary": "已通过本地规则生成预览",
"ai_used": False,
"fields": {},
"missing_fields": [],
"field_labels": _field_labels(register_type),
"timed_out": False,
"recognition_source": "local",
"draft_items": [],
}
return _needs_info_response(
register_type,
draft_items[0]["fields"] if draft_items else {},
union_missing,
summary="请逐条补齐缺失字段后再次生成预览",
error="",
draft_line="\n".join(str(item.get("draft_line") or "") for item in draft_items),
draft_items=draft_items,
)
def fields_preview(register_type: str, fields: dict[str, str]) -> dict[str, Any] | None:
missing = _missing_fields(register_type, fields)
if missing:
return _needs_info_response(register_type, fields, missing, draft_line=_draft_from_fields(register_type, fields))
try:
standard_lines = validate_standard_lines(register_type, [_standard_from_fields(register_type, fields)])
except (ValueError, KeyError) as exc:
return _needs_info_response(
register_type,
fields,
[],
summary="脚本无法生成可校验预览,请修改字段后再试",
error=str(exc),
draft_line=_draft_from_fields(register_type, fields),
)
return {
"status": "ready",
"standard_lines": standard_lines,
"questions": [],
"summary": "已通过本地规则生成预览",
"ai_used": False,
"fields": fields,
"missing_fields": [],
"field_labels": _field_labels(register_type),
"timed_out": False,
"recognition_source": "local",
}
def standard_lines_preview(register_type: str, standard_lines: list[str], summary: str, *, ai_used: bool, source: str) -> dict[str, Any]:
return {
"status": "ready",
"standard_lines": standard_lines,
"questions": [],
"summary": summary,
"ai_used": ai_used,
"fields": {},
"missing_fields": [],
"field_labels": _field_labels(register_type),
"timed_out": False,
"recognition_source": source,
}
def validate_standard_lines(register_type: str, lines: list[str]) -> list[str]:
if not lines:
raise ValueError("标准行不能为空")
standard_lines: list[str] = []
for index, line in enumerate(lines):
text = str(line or "").strip()
if not text:
continue
try:
if register_type == "class_record":
standard_lines.append(class_record_to_line(parse_class_record_line(text)))
elif register_type == "payment":
text = re.sub(r":\s*(\d+(?:\.\d+)?)\s*(?:课时|小时)\s*$", r":\1", text)
student, payment = parse_payment_line(text)
hours = int(payment.hours) if float(payment.hours).is_integer() else payment.hours
standard_lines.append(f"{student}-{payment.date}:{hours}")
else:
raw = extract_course_summary_from_text(text, index)
normalized = normalize_course_summary(raw)
course_summary_to_class_record_line(normalized)
standard_lines.append(text)
except ValueError as exc:
raise ValueError(f"{index + 1} 条校验失败:{exc}") from exc
if not standard_lines:
raise ValueError("标准行不能为空")
return standard_lines
def local_standard_preview(register_type: str, lines: list[str]) -> dict[str, Any] | None:
try:
standard_lines = validate_standard_lines(register_type, lines)
except ValueError:
return None
return standard_lines_preview(register_type, standard_lines, "已按标准格式通过本地校验", ai_used=False, source="local")
def preview_register(
*,
register_type: str,
text: str | None = None,
lines: list[str] | None = None,
answers: dict[str, str] | None = None,
conversation_id: str | None = None,
) -> dict[str, Any]:
normalized_type = normalize_register_type(register_type)
source_lines = collect_input_lines(text=text, lines=lines, register_type=normalized_type)
joined_text = "\n\n".join(source_lines)
conversation_id, session = _session_for(conversation_id, normalized_type, joined_text)
merged_answers = {**dict(session.get("answers") or {}), **(answers or {})}
session["answers"] = merged_answers
local = local_standard_preview(normalized_type, source_lines)
if local is not None:
return {"conversation_id": conversation_id, "type": normalized_type, **local}
if normalized_type in {"class_record", "payment"}:
return {
"conversation_id": conversation_id,
"type": normalized_type,
**multi_line_fields_preview(normalized_type, source_lines, merged_answers),
}
local_fields = _merge_answers(extract_local_fields(normalized_type, source_lines), merged_answers)
local_ready = fields_preview(normalized_type, local_fields)
if local_ready is not None:
return {"conversation_id": conversation_id, "type": normalized_type, **local_ready}
missing = _missing_fields(normalized_type, local_fields)
return {
"conversation_id": conversation_id,
"type": normalized_type,
**_needs_info_response(
normalized_type,
local_fields,
missing,
summary="脚本无法补齐全部字段,请补充信息后再次生成预览",
draft_line=_draft_from_fields(normalized_type, local_fields),
),
}