diff --git a/app/app/ai_register.py b/app/app/ai_register.py index 24e6745..85b084d 100644 --- a/app/app/ai_register.py +++ b/app/app/ai_register.py @@ -1,16 +1,11 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import date, datetime, timedelta -import json -from pathlib import Path import re import secrets -import tomllib from typing import Any -from urllib import error, request -from .config import ACCOUNTS_PATH, AI_REGISTER_MODEL, AI_REGISTER_TIMEOUT_SECONDS, CODEX_AUTH_PATH, CODEX_CONFIG_PATH +from .config import ACCOUNTS_PATH from .data import ( SUBJECTS, canonical_teacher_name, @@ -61,6 +56,12 @@ TIME_RANGE_FLEX_RE = re.compile( r"(?:-|-|—|–|~|至|到)\s*" r"(?P\d{1,2})\s*(?:[::点.])\s*(?P\d{1,2})?" ) +TIME_RANGE_HOUR_RE = re.compile( + r"(?\d{1,2})\s*(?:-|-|—|–|~|至|到)\s*(?P\d{1,2})(?![\d./-])" +) +TIME_RANGE_COMPACT_RE = re.compile( + r"(?\d{3,4})\s*(?:-|-|—|–|~|至|到)\s*(?P\d{3,4})(?!\d)" +) TEACHER_RE = re.compile(r"(?P[\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)") HOURS_RE = re.compile(r"(?P\d+(?:\.\d+)?)\s*(?:课时|小时)") MAX_SESSION_AGE_SECONDS = 30 * 60 @@ -68,15 +69,6 @@ MAX_SESSIONS = 100 _SESSIONS: dict[str, dict[str, Any]] = {} -@dataclass(frozen=True) -class CodexAiConfig: - model_provider: str - model: str - base_url: str - wire_api: str - api_key: str - - def _now() -> datetime: return datetime.now() @@ -127,7 +119,7 @@ def normalize_register_type(value: str) -> str: } register_type = aliases.get(text, text) if register_type not in REGISTER_TYPES: - raise ValueError("不支持的 AI 登记类型") + raise ValueError("不支持的登记类型") return register_type @@ -142,32 +134,6 @@ def collect_input_lines(text: str | None = None, lines: list[str] | None = None) return normalize_lines(line=raw) -def load_codex_ai_config( - config_path: Path = CODEX_CONFIG_PATH, - auth_path: Path = CODEX_AUTH_PATH, -) -> CodexAiConfig: - if not config_path.exists(): - raise ValueError(f"AI 配置文件不存在: {config_path}") - if not auth_path.exists(): - raise ValueError(f"AI 授权文件不存在: {auth_path}") - config = tomllib.loads(config_path.read_text(encoding="utf-8")) - auth = json.loads(auth_path.read_text(encoding="utf-8")) - provider_name = str(config.get("model_provider") or "").strip() - model = AI_REGISTER_MODEL or str(config.get("model") or "").strip() - providers = config.get("model_providers") or {} - provider = providers.get(provider_name) or {} - base_url = str(provider.get("base_url") or "").strip().rstrip("/") - wire_api = str(provider.get("wire_api") or "").strip() - api_key = str(auth.get("OPENAI_API_KEY") or "").strip() - if not provider_name or not model or not base_url or not wire_api: - raise ValueError("AI 配置缺少 model_provider、model、base_url 或 wire_api") - if wire_api != "responses": - raise ValueError(f"暂不支持的 AI wire_api: {wire_api}") - if not api_key: - raise ValueError("AI 授权文件缺少 OPENAI_API_KEY") - return CodexAiConfig(provider_name, model, base_url, wire_api, api_key) - - def _field_labels(register_type: str) -> dict[str, str]: return FIELD_LABELS.get(register_type, {}) @@ -218,20 +184,37 @@ 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}-\d{1,2}:\d{2}", text) + standard = re.search(r"\d{1,2}:\d{2}\s*(?:-|-|—|–|~|至|到)\s*\d{1,2}:\d{2}", text) if standard: - raw = standard.group(0) + 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 not match: - return "" - sh = int(match.group("sh")) - sm = int(match.group("sm") or 0) - eh = int(match.group("eh")) - em = int(match.group("em") or 0) + 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: @@ -274,11 +257,38 @@ def _extract_student(text: str) -> str: 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: @@ -299,13 +309,6 @@ def _merge_answers(fields: dict[str, str], answers: dict[str, str]) -> dict[str, return merged -def _model_fields(payload: dict[str, Any]) -> dict[str, str]: - fields = payload.get("fields") or {} - if not isinstance(fields, dict): - return {} - return {str(key): str(value or "").strip() for key, value in fields.items() if str(value or "").strip()} - - def _missing_fields(register_type: str, fields: dict[str, str]) -> list[str]: return [field for field in REQUIRED_FIELDS[register_type] if not str(fields.get(field) or "").strip()] @@ -316,22 +319,23 @@ def _needs_info_response( missing_fields: list[str], *, summary: str = "", - ai_used: bool = False, timed_out: bool = False, error: str = "", + draft_line: str = "", ) -> 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": ai_used, + "ai_used": False, "fields": fields, "missing_fields": missing_fields, "field_labels": _field_labels(register_type), "timed_out": timed_out, - "recognition_source": "partial_local" if not ai_used else "model", + "recognition_source": "partial_local", "error": error, + "draft_line": draft_line, } @@ -364,106 +368,37 @@ def _standard_from_fields(register_type: str, fields: dict[str, str]) -> str: ) -def _json_from_text(text: str) -> dict[str, Any]: - try: - payload = json.loads(text) - except json.JSONDecodeError: - match = re.search(r"\{.*\}", text, flags=re.S) - if not match: - raise ValueError("模型没有返回 JSON") - payload = json.loads(match.group(0)) - if not isinstance(payload, dict): - raise ValueError("模型返回必须是 JSON 对象") - return payload - - -def _response_text(payload: dict[str, Any]) -> str: - if isinstance(payload.get("output_text"), str): - return str(payload["output_text"]) - parts: list[str] = [] - for item in payload.get("output") or []: - for content in item.get("content") or []: - if isinstance(content.get("text"), str): - parts.append(content["text"]) - return "\n".join(parts).strip() - - -def call_model_for_standardization( - register_type: str, - source_lines: list[str], - answers: dict[str, str], - local_fields: dict[str, str] | None = None, - missing_fields: list[str] | None = None, -) -> dict[str, Any]: - config = load_codex_ai_config() - system_prompt = ( - "你是教务登记标准化助手。只返回 JSON,不要 Markdown。" - "任务是把管理员输入转成现有系统可校验的标准登记文本,或提出中文追问。" - "不能编造学生、日期、时间、老师、科目、课时。" +def _draft_from_fields(register_type: str, fields: dict[str, str]) -> str: + if register_type == "class_record": + student = fields.get("student") or "【学生】" + date_value = _record_date(_normalize_date(fields.get("date"))) if _normalize_date(fields.get("date")) else "【日期】" + time_range = _normalize_time_range(fields.get("time")) or "【时间】" + teacher = _normalize_teacher_hint(fields.get("teacher")) or "【老师】" + duration = _duration_from_text(fields.get("duration")) or "【时长】" + subject = fields.get("subject") or "【科目】" + return f"{date_value}-{_weekday(_normalize_date(fields.get('date'))) if _normalize_date(fields.get('date')) else '星期?'}-{time_range}-{student}-{duration}-{teacher}-{subject}" + if register_type == "payment": + student = fields.get("student") or "【学生】" + date_value = _normalize_date(fields.get("date")) or "【日期】" + hours = str(fields.get("hours") or "").strip() or "【课时数】" + 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, + ] ) - user_prompt = { - "type": register_type, - "today": date.today().isoformat(), - "formats": { - "class_record": "YYYY.MM.DD-星期X-HH:MM-HH:MM-学生-N小时N分-老师-科目", - "payment": "学生-YYYY-MM-DD:课时数,课时数只能是数字,例如 张三-2026-06-15:10", - "course_summary": "学生:...\n日期:YYYY.MM.DD\n时间:HH:MM-HH:MM\n老师:...\n科目:...\n小结:\n正文", - }, - "input": source_lines, - "script_fields": local_fields or {}, - "script_missing_fields": missing_fields or [], - "field_labels": _field_labels(register_type), - "answers": answers, - "response_schema": { - "status": "ready 或 needs_info", - "standard_lines": ["完整时提供"], - "fields": {"缺信息时提供已能确认的字段"}, - "questions": ["缺信息时提供中文问题"], - "summary": "简短中文预览", - }, - } - instructions = ( - "输出必须是紧凑 JSON。" - "script_fields 是脚本已识别字段,script_missing_fields 是脚本缺失字段。" - "如果能确认完整内容,必须返回 ready 和 standard_lines。" - "如果不能确认,返回 needs_info、fields、questions。" - "ready 示例:{\"status\":\"ready\",\"standard_lines\":[\"...\"],\"questions\":[],\"summary\":\"...\"}。" - "needs_info 示例:{\"status\":\"needs_info\",\"standard_lines\":[],\"fields\":{\"student\":\"...\"},\"questions\":[\"请补充...\"],\"summary\":\"...\"}。" - "不要解释,不要输出额外字段。" - ) - body = json.dumps( - { - "model": config.model, - "input": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": f"{instructions}\n{json.dumps(user_prompt, ensure_ascii=False)}"}, - ], - "max_output_tokens": 800, - "store": False, - }, - ensure_ascii=False, - ).encode("utf-8") - req = request.Request( - f"{config.base_url}/v1/responses", - data=body, - headers={ - "Authorization": f"Bearer {config.api_key}", - "Content-Type": "application/json", - }, - method="POST", - ) - try: - with request.urlopen(req, timeout=AI_REGISTER_TIMEOUT_SECONDS) as response: - payload = json.loads(response.read().decode("utf-8")) - except error.HTTPError as exc: - detail = exc.read().decode("utf-8", errors="replace")[:300] - raise ValueError(f"模型调用失败: HTTP {exc.code} {detail}") from exc - except (OSError, TimeoutError, json.JSONDecodeError) as exc: - raise ValueError(f"模型调用失败: {exc}") from exc - text = _response_text(payload) - if not text: - raise ValueError("模型响应为空") - return _json_from_text(text) def _question_for_error(register_type: str, message: str) -> str: @@ -475,12 +410,14 @@ def _question_for_error(register_type: str, message: str) -> str: def _extract_class_record_fields(text: str) -> dict[str, str]: + teacher = _extract_teacher(text) + subject = _extract_subject(text) return { - "student": _extract_student(text), + "student": _extract_class_record_student(text, teacher, subject), "date": _normalize_date(text), "time": _normalize_time_range(text), - "teacher": _extract_teacher(text), - "subject": _extract_subject(text), + "teacher": teacher, + "subject": subject, "duration": _duration_from_text(text), } @@ -506,7 +443,7 @@ def _extract_course_summary_fields(text: str) -> dict[str, str]: "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": str(raw.get("teacher") or _extract_teacher(text)).strip(), + "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(), } @@ -524,11 +461,18 @@ def extract_local_fields(register_type: str, lines: list[str]) -> dict[str, str] 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) + 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): - return None + 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, @@ -560,7 +504,7 @@ def standard_lines_preview(register_type: str, standard_lines: list[str], summar def validate_standard_lines(register_type: str, lines: list[str]) -> list[str]: if not lines: - raise ValueError("模型未返回标准行") + raise ValueError("标准行不能为空") standard_lines: list[str] = [] for index, line in enumerate(lines): text = str(line or "").strip() @@ -615,78 +559,17 @@ def preview_register( 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 and local_ready.get("status") == "ready": + if local_ready is not None: return {"conversation_id": conversation_id, "type": normalized_type, **local_ready} missing = _missing_fields(normalized_type, local_fields) - - try: - model_payload = call_model_for_standardization(normalized_type, source_lines, merged_answers, local_fields, missing) - except ValueError as exc: - timed_out = "timed out" in str(exc).lower() or "timeout" in str(exc).lower() - return { - "conversation_id": conversation_id, - "type": normalized_type, - **_needs_info_response( - normalized_type, - local_fields, - missing, - ai_used=True, - timed_out=timed_out, - error=str(exc), - summary="模型未及时生成可靠预览,请补齐字段后再生成", - ), - } - - status = str(model_payload.get("status") or "").strip() - questions = [str(item).strip() for item in model_payload.get("questions") or [] if str(item).strip()] - if status == "needs_info" or questions: - session["questions"] = questions - fields = _merge_answers(local_fields, _model_fields(model_payload)) - return { - "conversation_id": conversation_id, - "type": normalized_type, - "status": "needs_info", - "standard_lines": [], - "questions": questions or ["请补齐缺失信息"], - "summary": str(model_payload.get("summary") or ""), - "ai_used": True, - "fields": fields, - "missing_fields": _missing_fields(normalized_type, fields), - "field_labels": _field_labels(normalized_type), - "timed_out": False, - "recognition_source": "model", - } - - try: - standard_lines = validate_standard_lines(normalized_type, model_payload.get("standard_lines") or []) - except ValueError as exc: - fields = _merge_answers(local_fields, _model_fields(model_payload)) - return { - "conversation_id": conversation_id, - "type": normalized_type, - "status": "needs_info", - "standard_lines": [], - "questions": [_question_for_error(normalized_type, str(exc))], - "summary": str(model_payload.get("summary") or ""), - "ai_used": True, - "error": str(exc), - "fields": fields, - "missing_fields": _missing_fields(normalized_type, fields), - "field_labels": _field_labels(normalized_type), - "timed_out": False, - "recognition_source": "model", - } return { "conversation_id": conversation_id, "type": normalized_type, - "status": "ready", - "standard_lines": standard_lines, - "questions": [], - "summary": str(model_payload.get("summary") or "已生成标准登记内容"), - "ai_used": True, - "fields": {}, - "missing_fields": [], - "field_labels": _field_labels(normalized_type), - "timed_out": False, - "recognition_source": "model", + **_needs_info_response( + normalized_type, + local_fields, + missing, + summary="脚本无法补齐全部字段,请补充信息后再次生成预览", + draft_line=_draft_from_fields(normalized_type, local_fields), + ), } diff --git a/app/app/config.py b/app/app/config.py index 5bf8928..ddd266f 100644 --- a/app/app/config.py +++ b/app/app/config.py @@ -15,10 +15,6 @@ ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json")) COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries")) COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json")) OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl")) -CODEX_CONFIG_PATH = Path(os.getenv("CODEX_CONFIG_PATH", "/run/codex/config.toml")) -CODEX_AUTH_PATH = Path(os.getenv("CODEX_AUTH_PATH", "/run/codex/auth.json")) -AI_REGISTER_TIMEOUT_SECONDS = float(os.getenv("AI_REGISTER_TIMEOUT_SECONDS", "8")) -AI_REGISTER_MODEL = "gpt-5.4-mini" BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "") ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "") diff --git a/app/app/data.py b/app/app/data.py index 5c26645..cde652d 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -1794,6 +1794,8 @@ def operation_log_rollback_state( 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: @@ -1807,6 +1809,72 @@ def operation_log_rollback_state( 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, @@ -1856,6 +1924,19 @@ def rollback_operation_log( 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]] = [] @@ -2955,7 +3036,7 @@ def ingest_course_summaries( reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path) for question in ai_questions: - reason = f"模型追问:{question}" + reason = f"脚本追问:{question}" if reason not in reasons: reasons.append(reason) if reasons: @@ -3023,7 +3104,7 @@ def ingest_course_summaries( if str(item).strip() ] if ai_questions: - reasons = [f"模型追问:{question}" for question in 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 diff --git a/app/app/domain.py b/app/app/domain.py index 11634d7..71667d1 100644 --- a/app/app/domain.py +++ b/app/app/domain.py @@ -29,7 +29,7 @@ UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"} TEACHER_STATUSES = {"在岗", "离职"} UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"} HIGH_CONFIDENCE_VALUES = {"high", "高", "高置信", "true", "1", "yes"} -AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "manual_admin", "大模型高置信识别", "关键词"} +AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "manual_admin", "关键词"} ROLE_WORDS = { "student": ("学生", "学员", "孩子", "同学"), "teacher": ("老师", "教师"), diff --git a/app/app/routers/admin.py b/app/app/routers/admin.py index 1420f08..3572ce5 100644 --- a/app/app/routers/admin.py +++ b/app/app/routers/admin.py @@ -88,6 +88,9 @@ def admin_rollback_operation_log(log_id: str, _user: str = Depends(verify_admin_ target_backup_id=str(result.get("target_backup_id") or ""), backup_id=str(result.get("backup_id") or ""), restored_files=result.get("restored_files") or [], + rollback_mode=str(result.get("rollback_mode") or ""), + removed_lines=result.get("removed_lines") or [], + restored_hours=result.get("restored_hours") or "", ) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc diff --git a/app/app/routers/ingest.py b/app/app/routers/ingest.py index 7f3fb8e..1f0666a 100644 --- a/app/app/routers/ingest.py +++ b/app/app/routers/ingest.py @@ -42,7 +42,7 @@ def _summary_text(raw: dict) -> str: return "\n".join(parts) -def _needs_ai_summary(raw: dict) -> bool: +def _needs_script_summary(raw: dict) -> bool: required = ["student", "teacher", "subject"] if any(not str(raw.get(key) or "").strip() for key in required): return True @@ -61,34 +61,34 @@ def _standard_text_to_raw(text: str, original: dict) -> dict: return {**original, **normalized} -def _preprocess_summaries_with_ai(summaries: list[dict]) -> list[dict]: +def _preprocess_summaries_locally(summaries: list[dict]) -> list[dict]: processed: list[dict] = [] for raw in summaries: - if not _needs_ai_summary(raw): + if not _needs_script_summary(raw): processed.append(raw) continue try: preview = preview_register(register_type="course_summary", text=_summary_text(raw)) except ValueError as exc: - processed.append({**raw, "ai_used": True, "ai_questions": [str(exc)]}) + processed.append({**raw, "ai_used": False, "ai_questions": [str(exc)]}) continue if preview.get("status") == "ready" and preview.get("standard_lines"): try: updated = _standard_text_to_raw(str(preview["standard_lines"][0]), raw) processed.append({ **updated, - "ai_used": True, + "ai_used": False, "ai_summary": str(preview.get("summary") or ""), }) except ValueError as exc: - processed.append({**raw, "ai_used": True, "ai_questions": [str(exc)]}) + processed.append({**raw, "ai_used": False, "ai_questions": [str(exc)]}) continue questions = [str(item) for item in preview.get("questions") or [] if str(item)] processed.append({ **raw, - "ai_used": True, + "ai_used": False, "ai_summary": str(preview.get("summary") or ""), - "ai_questions": questions or ["模型未能补齐课程小结信息"], + "ai_questions": questions or ["脚本未能补齐课程小结信息"], }) return processed @@ -96,7 +96,7 @@ def _preprocess_summaries_with_ai(summaries: list[dict]) -> list[dict]: @router.post("/api/ingest/course-summaries") def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)): try: - summaries = _preprocess_summaries_with_ai([item.dict() for item in payload.summaries]) + summaries = _preprocess_summaries_locally([item.dict() for item in payload.summaries]) with write_lock: result = ingest_course_summaries( classnotes_path=CLASSNOTES_PATH, diff --git a/app/app/static/admin.js b/app/app/static/admin.js index da6d7ee..15663f2 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -869,6 +869,9 @@ function renderLogDetail(item) { if (item.backup_id) details.push(`备份:${item.backup_id}`); if (item.target_log_id) details.push(`撤回记录:${item.target_log_id}`); if (item.target_backup_id) details.push(`撤回备份:${item.target_backup_id}`); + if (item.rollback_mode) details.push(`撤回方式:${item.rollback_mode}`); + if (Array.isArray(item.removed_lines) && item.removed_lines.length) details.push(`删除记录:${item.removed_lines.join(";")}`); + if (item.restored_hours !== undefined && item.restored_hours !== "") details.push(`返还课时:${item.restored_hours}`); if (Array.isArray(item.restored_files) && item.restored_files.length) details.push(`恢复文件:${item.restored_files.join(";")}`); if (item.saved_path) details.push(`文件:${item.saved_path}`); return `
${details.map(escapeHtml).join("
") || "暂无详情"}
`; @@ -988,7 +991,7 @@ function renderRegisterPreview(type, data, statusNode) { conversationId: data.conversation_id, standardLines: data.standard_lines || [], }; - statusNode.textContent = data.ai_used ? "模型已生成预览,请确认后写入" : "已通过本地标准格式校验,请确认后写入"; + statusNode.textContent = "已通过脚本生成预览,请确认后写入"; return; } if (data.status === "needs_info") { @@ -997,6 +1000,7 @@ function renderRegisterPreview(type, data, statusNode) { const labels = data.field_labels || {}; const missing = new Set(data.missing_fields || []); const fieldNames = Object.keys(labels); + const draft = renderRegisterDraft(type, fields, missing); const fieldInputs = fieldNames .map((name) => { const label = labels[name] || name; @@ -1009,7 +1013,7 @@ function renderRegisterPreview(type, data, statusNode) { return ``; }) .join(""); - previewNode.innerHTML = `需要补充信息
    ${questions}
${fieldInputs}
`; + previewNode.innerHTML = `需要补充信息
    ${questions}
${draft}
${fieldInputs}
`; confirmButton.hidden = true; registerPreviewState[type] = { conversationId: data.conversation_id, @@ -1028,6 +1032,40 @@ function renderRegisterPreview(type, data, statusNode) { statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`; } +function inlineDraftPart(name, fields, missing, fallback) { + const value = fields[name] || ""; + if (!missing.has(name) && value) return `${escapeHtml(value)}`; + return ``; +} + +function renderRegisterDraft(type, fields, missing) { + if (type === "class_record") { + const dateText = inlineDraftPart("date", fields, missing, "日期"); + const timeText = inlineDraftPart("time", fields, missing, "时间"); + const studentText = inlineDraftPart("student", fields, missing, "学生"); + const durationText = inlineDraftPart("duration", fields, missing, "时长"); + const teacherText = inlineDraftPart("teacher", fields, missing, "老师"); + const subjectText = inlineDraftPart("subject", fields, missing, "科目"); + return `
${dateText}星期?${timeText}${studentText}${durationText}${teacherText}${subjectText}
`; + } + if (type === "payment") { + const studentText = inlineDraftPart("student", fields, missing, "学生"); + const dateText = inlineDraftPart("date", fields, missing, "日期"); + const hoursText = inlineDraftPart("hours", fields, missing, "课时数"); + return `
${studentText}-${dateText}:${hoursText}
`; + } + const studentText = inlineDraftPart("student", fields, missing, "学生"); + const dateText = inlineDraftPart("date", fields, missing, "日期"); + const timeText = inlineDraftPart("time", fields, missing, "时间"); + const teacherText = inlineDraftPart("teacher", fields, missing, "老师"); + const subjectText = inlineDraftPart("subject", fields, missing, "科目"); + const bodyValue = fields.body || ""; + const bodyInput = missing.has("body") + ? `` + : `
${escapeHtml(bodyValue)}
`; + return `
学生:${studentText}日期:${dateText}时间:${timeText}老师:${teacherText}科目:${subjectText}${bodyInput}
`; +} + async function previewRegister(event, type, lines, statusNode) { event.preventDefault(); if (!lines.length) { diff --git a/app/app/static/styles.css b/app/app/static/styles.css index f36ac8c..010549d 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -431,6 +431,48 @@ textarea:focus { min-height: 72px; } +.register-draft { + display: grid; + gap: 6px; + padding: 8px 10px; + border: 1px dashed var(--line); + border-radius: 6px; + background: #fff; + color: #344054; + font-size: 13px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.register-draft span { + min-height: 22px; +} + +.register-draft input, +.register-draft textarea { + width: 100%; + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 4px; + background: #fff; +} + +.payment-draft { + grid-template-columns: minmax(90px, 1fr) auto minmax(120px, 1fr) auto minmax(80px, 0.8fr); + align-items: center; +} + +.payment-draft b { + color: #667085; + font-weight: 700; +} + +.summary-draft pre { + margin: 0; + white-space: pre-wrap; +} + .register-field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); diff --git a/app/docker-compose.yml b/app/docker-compose.yml index 23bac95..c913c12 100644 --- a/app/docker-compose.yml +++ b/app/docker-compose.yml @@ -17,14 +17,8 @@ services: COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries} COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json} OPERATION_LOGS_PATH: ${OPERATION_LOGS_PATH:-/data/operation_logs.jsonl} - CODEX_CONFIG_PATH: ${CODEX_CONFIG_PATH:-/run/codex/config.toml} - CODEX_AUTH_PATH: ${CODEX_AUTH_PATH:-/run/codex/auth.json} - AI_REGISTER_TIMEOUT_SECONDS: ${AI_REGISTER_TIMEOUT_SECONDS:-8} - AI_REGISTER_MODEL: gpt-5.4-mini INGEST_AUTH_TOKEN: ${INGEST_AUTH_TOKEN:-} ports: - "${APP_PORT:-18080}:8000" volumes: - ../data:/data - - /root/.codex/config.toml:/run/codex/config.toml:ro - - /root/.codex/auth.json:/run/codex/auth.json:ro