diff --git a/app/app/ai_register.py b/app/app/ai_register.py index 2aec9c4..e3b12a3 100644 --- a/app/app/ai_register.py +++ b/app/app/ai_register.py @@ -10,8 +10,9 @@ import tomllib from typing import Any from urllib import error, request -from .config import AI_REGISTER_TIMEOUT_SECONDS, CODEX_AUTH_PATH, CODEX_CONFIG_PATH +from .config import ACCOUNTS_PATH, AI_REGISTER_MODEL, AI_REGISTER_TIMEOUT_SECONDS, CODEX_AUTH_PATH, CODEX_CONFIG_PATH from .data import ( + SUBJECTS, class_record_to_line, course_summary_to_class_record_line, extract_course_summary_from_text, @@ -19,10 +20,48 @@ from .data import ( normalize_lines, parse_class_record_line, parse_payment_line, + read_accounts, ) 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\d{4})[./年-]\s*(?P\d{1,2})[./月-]\s*(?P\d{1,2})") +MONTH_DAY_RE = re.compile(r"(?\d{1,2})\s*[月./-]\s*(?P\d{1,2})\s*(?:日|号)?") +TIME_RANGE_FLEX_RE = re.compile( + r"(?P\d{1,2})\s*(?:[::点.])\s*(?P\d{1,2})?\s*" + r"(?:-|-|—|–|~|至|到)\s*" + r"(?P\d{1,2})\s*(?:[::点.])\s*(?P\d{1,2})?" +) +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 MAX_SESSIONS = 100 _SESSIONS: dict[str, dict[str, Any]] = {} @@ -113,7 +152,7 @@ def load_codex_ai_config( 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 = str(config.get("model") 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("/") @@ -128,6 +167,195 @@ def load_codex_ai_config( 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, {}) + + +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 [account.student for account in read_accounts(ACCOUNTS_PATH)] + except Exception: + return [] + + +def _normalize_date(value: object) -> str: + text = str(value or "").strip() + if not text: + return "" + match = DATE_RE.search(text) + if match: + year = int(match.group("y")) + month = int(match.group("m")) + day = int(match.group("d")) + datetime(year, month, day) + return f"{year:04d}-{month:02d}-{day:02d}" + match = MONTH_DAY_RE.search(text) + if match: + year = date.today().year + month = int(match.group("m")) + day = int(match.group("d")) + datetime(year, month, day) + 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}-\d{1,2}:\d{2}", text) + if standard: + raw = standard.group(0) + 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 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\d+)\s*小时\s*(?P\d+)?\s*分?", text) + if match: + return f"{int(match.group('h'))}小时{int(match.group('m') or 0)}分" + match = re.search(r"(?P\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[\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[\u4e00-\u9fa5]{2,4})(?=\s|[,,。;;:]|\d)", text) + return match.group("student") if match else "" + + +def _extract_teacher(text: str) -> str: + match = TEACHER_RE.search(text) + return match.group("teacher") if match else "" + + +def _extract_subject(text: str) -> str: + for subject in SUBJECTS: + if subject in text: + return subject + match = re.search(r"(?:科目|课程)[::\s]*(?P[\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": + continue + merged[key] = text + return merged + + +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()] + + +def _needs_info_response( + register_type: str, + fields: dict[str, str], + missing_fields: list[str], + *, + summary: str = "", + ai_used: bool = False, + timed_out: bool = False, + error: 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, + "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", + "error": error, + } + + +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")) + 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")) + 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 _json_from_text(text: str) -> dict[str, Any]: try: payload = json.loads(text) @@ -229,6 +457,75 @@ def _question_for_error(register_type: str, message: str) -> str: return f"请补齐或修改课程小结信息:{message}" +def _extract_class_record_fields(text: str) -> dict[str, str]: + return { + "student": _extract_student(text), + "date": _normalize_date(text), + "time": _normalize_time_range(text), + "teacher": _extract_teacher(text), + "subject": _extract_subject(text), + "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": str(raw.get("teacher") or _extract_teacher(text)).strip(), + "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 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) + try: + standard_lines = validate_standard_lines(register_type, [_standard_from_fields(register_type, fields)]) + except (ValueError, KeyError): + return None + 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 validate_standard_lines(register_type: str, lines: list[str]) -> list[str]: if not lines: raise ValueError("模型未返回标准行") @@ -257,17 +554,23 @@ def validate_standard_lines(register_type: str, lines: list[str]) -> list[str]: return standard_lines -def local_preview(register_type: str, lines: list[str]) -> dict[str, Any] | None: +def local_preview(register_type: str, lines: list[str], answers: dict[str, str] | None = None) -> dict[str, Any] | None: try: standard_lines = validate_standard_lines(register_type, lines) except ValueError: - return None + fields = _merge_answers(extract_local_fields(register_type, lines), answers or {}) + return fields_preview(register_type, fields) 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", } @@ -286,35 +589,35 @@ def preview_register( merged_answers = {**dict(session.get("answers") or {}), **(answers or {})} session["answers"] = merged_answers - local = local_preview(normalized_type, source_lines) - if local is not None and not merged_answers: + local = local_preview(normalized_type, source_lines, merged_answers) + if local is not None: return {"conversation_id": conversation_id, "type": normalized_type, **local} try: model_payload = call_model_for_standardization(normalized_type, source_lines, merged_answers) except ValueError as exc: - if local is not None: - return { - "conversation_id": conversation_id, - "type": normalized_type, - **local, - "warning": "模型不可用,已使用本地标准格式校验预览", - } + fields = _merge_answers(extract_local_fields(normalized_type, source_lines), merged_answers) + missing = _missing_fields(normalized_type, fields) + timed_out = "timed out" in str(exc).lower() or "timeout" in str(exc).lower() return { "conversation_id": conversation_id, "type": normalized_type, - "status": "error", - "standard_lines": [], - "questions": [_question_for_error(normalized_type, str(exc))], - "summary": "", - "ai_used": True, - "error": str(exc), + **_needs_info_response( + normalized_type, + 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(extract_local_fields(normalized_type, source_lines), merged_answers) return { "conversation_id": conversation_id, "type": normalized_type, @@ -323,11 +626,17 @@ def preview_register( "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(extract_local_fields(normalized_type, source_lines), merged_answers) return { "conversation_id": conversation_id, "type": normalized_type, @@ -337,6 +646,11 @@ def preview_register( "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, @@ -346,4 +660,9 @@ def preview_register( "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", } diff --git a/app/app/config.py b/app/app/config.py index 4004437..73d47bf 100644 --- a/app/app/config.py +++ b/app/app/config.py @@ -17,7 +17,8 @@ COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/c 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", "90")) +AI_REGISTER_TIMEOUT_SECONDS = float(os.getenv("AI_REGISTER_TIMEOUT_SECONDS", "8")) +AI_REGISTER_MODEL = os.getenv("AI_REGISTER_MODEL", "").strip() 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/static/admin.js b/app/app/static/admin.js index 2c281a0..da6d7ee 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -993,7 +993,23 @@ function renderRegisterPreview(type, data, statusNode) { } if (data.status === "needs_info") { const questions = (data.questions || []).map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); - previewNode.innerHTML = `需要补充信息
      ${questions}
    `; + const fields = data.fields || {}; + const labels = data.field_labels || {}; + const missing = new Set(data.missing_fields || []); + const fieldNames = Object.keys(labels); + const fieldInputs = fieldNames + .map((name) => { + const label = labels[name] || name; + const value = fields[name] || ""; + const requiredMark = missing.has(name) ? " *" : ""; + const input = + name === "body" + ? `` + : ``; + return ``; + }) + .join(""); + previewNode.innerHTML = `需要补充信息
      ${questions}
    ${fieldInputs}
    `; confirmButton.hidden = true; registerPreviewState[type] = { conversationId: data.conversation_id, @@ -1025,8 +1041,13 @@ async function previewRegister(event, type, lines, statusNode) { course_summary: summaryRegisterPreview, }[type]; const answerNode = previewNode ? previewNode.querySelector("[data-ai-answer]") : null; + const fieldNodes = previewNode ? previewNode.querySelectorAll("[data-ai-field]") : []; const state = registerPreviewState[type] || {}; - const answers = answerNode && answerNode.value.trim() ? { followup: answerNode.value.trim() } : {}; + const answers = {}; + if (answerNode && answerNode.value.trim()) answers.followup = answerNode.value.trim(); + fieldNodes.forEach((node) => { + if (node.value.trim()) answers[node.dataset.aiField] = node.value.trim(); + }); clearRegisterPreview(type, !state.conversationId); statusNode.textContent = "正在生成预览"; try { diff --git a/app/app/static/styles.css b/app/app/static/styles.css index adc293f..f36ac8c 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -431,6 +431,29 @@ textarea:focus { min-height: 72px; } +.register-field-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; +} + +.register-field-grid label { + display: grid; + gap: 4px; + color: #52606d; + font-size: 13px; +} + +.register-field-grid input, +.register-field-grid textarea { + width: 100%; + box-sizing: border-box; +} + +.register-field-grid label:has(textarea) { + grid-column: 1 / -1; +} + .line-code { display: block; max-width: 420px; diff --git a/app/docker-compose.yml b/app/docker-compose.yml index f532c9c..e8337ad 100644 --- a/app/docker-compose.yml +++ b/app/docker-compose.yml @@ -19,7 +19,8 @@ services: 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:-90} + AI_REGISTER_TIMEOUT_SECONDS: ${AI_REGISTER_TIMEOUT_SECONDS:-8} + AI_REGISTER_MODEL: ${AI_REGISTER_MODEL:-} INGEST_AUTH_TOKEN: ${INGEST_AUTH_TOKEN:-} ports: - "${APP_PORT:-18080}:8000"