diff --git a/app/app/ai_register.py b/app/app/ai_register.py new file mode 100644 index 0000000..2aec9c4 --- /dev/null +++ b/app/app/ai_register.py @@ -0,0 +1,349 @@ +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 AI_REGISTER_TIMEOUT_SECONDS, CODEX_AUTH_PATH, CODEX_CONFIG_PATH +from .data import ( + 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, +) + + +REGISTER_TYPES = {"class_record", "payment", "course_summary"} +MAX_SESSION_AGE_SECONDS = 30 * 60 +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() + + +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("不支持的 AI 登记类型") + return register_type + + +def collect_input_lines(text: str | None = None, lines: list[str] | None = None) -> list[str]: + if lines is not None: + return normalize_lines(lines=lines) + raw = str(text or "") + 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 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 = 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 _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], +) -> dict[str, Any]: + config = load_codex_ai_config() + system_prompt = ( + "你是教务登记标准化助手。只返回 JSON,不要 Markdown。" + "任务是把管理员输入转成现有系统可校验的标准登记文本,或提出中文追问。" + "不能编造学生、日期、时间、老师、科目、课时。" + ) + 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, + "answers": answers, + "response_schema": { + "status": "ready 或 needs_info", + "standard_lines": ["完整时提供"], + "questions": ["缺信息时提供中文问题"], + "summary": "简短中文预览", + }, + } + instructions = ( + "输出必须是紧凑 JSON。" + "ready 示例:{\"status\":\"ready\",\"standard_lines\":[\"...\"],\"questions\":[],\"summary\":\"...\"}。" + "needs_info 示例:{\"status\":\"needs_info\",\"standard_lines\":[],\"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: + if register_type == "class_record": + return f"请按标准格式补齐或修改上课记录:{message}" + if register_type == "payment": + return f"请按“学生-YYYY-MM-DD:课时”补齐或修改缴费记录:{message}" + return f"请补齐或修改课程小结信息:{message}" + + +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_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 { + "status": "ready", + "standard_lines": standard_lines, + "questions": [], + "summary": "已按标准格式通过本地校验", + "ai_used": False, + } + + +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) + 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_preview(normalized_type, source_lines) + if local is not None and not merged_answers: + 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": "模型不可用,已使用本地标准格式校验预览", + } + 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), + } + + 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 + 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, + } + + try: + standard_lines = validate_standard_lines(normalized_type, model_payload.get("standard_lines") or []) + except ValueError as exc: + 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), + } + 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, + } diff --git a/app/app/config.py b/app/app/config.py index ddd266f..4004437 100644 --- a/app/app/config.py +++ b/app/app/config.py @@ -15,6 +15,9 @@ 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", "90")) 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 cb278d9..62e44b4 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -2047,6 +2047,39 @@ def create_course_summary_review_task( 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_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 approve_course_summary_task( tasks_path: Path, classnotes_path: Path, @@ -2062,8 +2095,7 @@ def approve_course_summary_task( proposed_line = str(task.get("proposed_line") or "").strip() if not proposed_line: - summary = dict(task.get("summary") or {}) - proposed_line = course_summary_to_class_record_line(summary) + raise ValueError("课程小结信息未补齐,不能直接批准入账") try: result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line) @@ -2251,8 +2283,14 @@ def ingest_course_summaries( for raw in summaries: normalized: dict | None = None + backup_id = "" 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) if source_id in seen_source_ids or semantic_key in seen_semantic_keys: @@ -2273,6 +2311,10 @@ def ingest_course_summaries( 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) if reasons: task = create_course_summary_review_task( tasks_path, @@ -2304,6 +2346,8 @@ def ingest_course_summaries( 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 ""), @@ -2319,6 +2363,38 @@ def ingest_course_summaries( } ) except Exception as exc: + 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( diff --git a/app/app/main.py b/app/app/main.py index e2b53f2..d1c1604 100644 --- a/app/app/main.py +++ b/app/app/main.py @@ -3,7 +3,7 @@ from __future__ import annotations from fastapi import FastAPI, Request from fastapi.responses import JSONResponse -from .routers import accounts, admin, health, ingest, pages, records +from .routers import accounts, admin, ai_register, health, ingest, pages, records app = FastAPI(title="新时空教务管理系统", version="1.0.0") @@ -19,4 +19,5 @@ app.include_router(health.router) app.include_router(records.router) app.include_router(accounts.router) app.include_router(admin.router) +app.include_router(ai_register.router) app.include_router(ingest.router) diff --git a/app/app/routers/ai_register.py b/app/app/routers/ai_register.py new file mode 100644 index 0000000..adbc5b4 --- /dev/null +++ b/app/app/routers/ai_register.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from ..ai_register import preview_register +from ..auth import verify_admin_auth +from ..schemas import AiRegisterPreviewPayload + + +router = APIRouter() + + +@router.post("/api/ai/register/preview") +def ai_register_preview(payload: AiRegisterPreviewPayload, _user: str = Depends(verify_admin_auth)): + try: + return { + "ok": True, + **preview_register( + register_type=payload.type, + text=payload.text, + lines=payload.lines, + answers=payload.answers, + conversation_id=payload.conversation_id, + ), + } + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/app/app/routers/ingest.py b/app/app/routers/ingest.py index e148d5d..7f3fb8e 100644 --- a/app/app/routers/ingest.py +++ b/app/app/routers/ingest.py @@ -3,6 +3,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException from ..auth import verify_ingest_token +from ..ai_register import preview_register from ..config import ( ACCOUNTS_PATH, ADMIN_TASKS_PATH, @@ -19,9 +20,83 @@ from ..schemas import CourseSummaryIngestPayload router = APIRouter() +def _summary_text(raw: dict) -> str: + parts = [] + mapping = [ + ("student", "学生"), + ("date_iso", "日期"), + ("date", "日期"), + ("time_range", "时间"), + ("raw_time", "时间"), + ("teacher", "老师"), + ("subject", "科目"), + ] + for key, label in mapping: + value = str(raw.get(key) or "").strip() + if value: + parts.append(f"{label}:{value}") + body = str(raw.get("body") or raw.get("content") or "").strip() + if body: + parts.append("小结:") + parts.append(body) + return "\n".join(parts) + + +def _needs_ai_summary(raw: dict) -> bool: + required = ["student", "teacher", "subject"] + if any(not str(raw.get(key) or "").strip() for key in required): + return True + if not str(raw.get("date_iso") or raw.get("date") or raw.get("class_date") or "").strip(): + return True + if not str(raw.get("time_range") or raw.get("raw_time") or raw.get("time") or "").strip(): + return True + return False + + +def _standard_text_to_raw(text: str, original: dict) -> dict: + from ..data import extract_course_summary_from_text, normalize_course_summary + + raw = extract_course_summary_from_text(text, known_students=[]) + normalized = normalize_course_summary({**original, **raw}) + return {**original, **normalized} + + +def _preprocess_summaries_with_ai(summaries: list[dict]) -> list[dict]: + processed: list[dict] = [] + for raw in summaries: + if not _needs_ai_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)]}) + 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_summary": str(preview.get("summary") or ""), + }) + except ValueError as exc: + processed.append({**raw, "ai_used": True, "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_summary": str(preview.get("summary") or ""), + "ai_questions": questions or ["模型未能补齐课程小结信息"], + }) + return processed + + @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]) with write_lock: result = ingest_course_summaries( classnotes_path=CLASSNOTES_PATH, @@ -33,7 +108,7 @@ def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str batch_id=payload.batch_id, window=payload.window, students=payload.students, - summaries=[item.dict() for item in payload.summaries], + summaries=summaries, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/app/app/schemas.py b/app/app/schemas.py index ef8618f..aab1796 100644 --- a/app/app/schemas.py +++ b/app/app/schemas.py @@ -8,6 +8,14 @@ class RegisterLinesPayload(BaseModel): lines: list[str] | None = Field(default=None, description="多条原始登记文本") +class AiRegisterPreviewPayload(BaseModel): + type: str + text: str | None = None + lines: list[str] | None = None + answers: dict[str, str] = Field(default_factory=dict) + conversation_id: str | None = None + + class PaymentPayload(BaseModel): date: str hours: float @@ -54,7 +62,7 @@ class DeletionSubmitPayload(BaseModel): class CourseSummaryPayload(BaseModel): source_id: str = "" - student: str + student: str = "" date_iso: str = "" date: str = "" time_range: str = "" diff --git a/app/app/static/admin.html b/app/app/static/admin.html index b983325..e9974ff 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -386,32 +386,37 @@

上课记录登记

+

缴费登记

+

课程小结登记

-
- + +

- + diff --git a/app/app/static/admin.js b/app/app/static/admin.js index 4960f33..80446f5 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -79,14 +79,19 @@ const logMeta = document.querySelector("#logMeta"); const logRows = document.querySelector("#logRows"); const classRegisterForm = document.querySelector("#classRegisterForm"); const classRegisterLines = document.querySelector("#classRegisterLines"); +const classRegisterPreview = document.querySelector("#classRegisterPreview"); const classRegisterStatus = document.querySelector("#classRegisterStatus"); +const classRegisterConfirm = document.querySelector("#classRegisterConfirm"); const paymentRegisterForm = document.querySelector("#paymentRegisterForm"); const paymentRegisterLines = document.querySelector("#paymentRegisterLines"); +const paymentRegisterPreview = document.querySelector("#paymentRegisterPreview"); const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus"); +const paymentRegisterConfirm = document.querySelector("#paymentRegisterConfirm"); const summaryRegisterForm = document.querySelector("#summaryRegisterForm"); -const summaryRegisterItems = document.querySelector("#summaryRegisterItems"); -const addSummaryRegisterItemBtn = document.querySelector("#addSummaryRegisterItemBtn"); +const summaryRegisterText = document.querySelector("#summaryRegisterText"); +const summaryRegisterPreview = document.querySelector("#summaryRegisterPreview"); const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus"); +const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm"); let currentAccounts = []; let editingAccountId = ""; @@ -94,15 +99,11 @@ let currentTeachers = []; let editingTeacherId = ""; let currentSummaryReviews = []; let activeSummaryReview = null; -let summaryRegisterItemSeq = 0; - -const SUMMARY_REQUIRED_FIELDS = [ - ["student", "学生"], - ["date", "日期"], - ["time", "时间"], - ["teacher", "老师"], - ["subject", "科目"], -]; +const registerPreviewState = { + class_record: null, + payment: null, + course_summary: null, +}; function fmtHours(value) { const totalMinutes = Math.round(Number(value || 0) * 60); @@ -764,115 +765,127 @@ function linesFromTextarea(textarea) { .filter(Boolean); } -async function submitRegister(event, textarea, statusNode, url) { +function clearRegisterPreview(type, resetState = true) { + const previewNode = { + class_record: classRegisterPreview, + payment: paymentRegisterPreview, + course_summary: summaryRegisterPreview, + }[type]; + const confirmButton = { + class_record: classRegisterConfirm, + payment: paymentRegisterConfirm, + course_summary: summaryRegisterConfirm, + }[type]; + if (resetState) registerPreviewState[type] = null; + if (previewNode) { + previewNode.hidden = true; + previewNode.innerHTML = ""; + } + if (confirmButton) confirmButton.hidden = true; +} + +function renderRegisterPreview(type, data, statusNode) { + const previewNode = { + class_record: classRegisterPreview, + payment: paymentRegisterPreview, + course_summary: summaryRegisterPreview, + }[type]; + const confirmButton = { + class_record: classRegisterConfirm, + payment: paymentRegisterConfirm, + course_summary: summaryRegisterConfirm, + }[type]; + previewNode.hidden = false; + if (data.status === "ready") { + const warning = data.warning ? `

${escapeHtml(data.warning)}

` : ""; + previewNode.innerHTML = `${warning}标准登记内容
${escapeHtml((data.standard_lines || []).join("\n\n"))}
`; + confirmButton.hidden = false; + registerPreviewState[type] = { + conversationId: data.conversation_id, + standardLines: data.standard_lines || [], + }; + statusNode.textContent = data.ai_used ? "模型已生成预览,请确认后写入" : "已通过本地标准格式校验,请确认后写入"; + return; + } + if (data.status === "needs_info") { + const questions = (data.questions || []).map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); + previewNode.innerHTML = `需要补充信息`; + confirmButton.hidden = true; + registerPreviewState[type] = { + conversationId: data.conversation_id, + standardLines: [], + }; + statusNode.textContent = "请补充信息后再次生成预览"; + return; + } + const questions = (data.questions || []).map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); + previewNode.innerHTML = `无法生成预览${questions ? `` : ""}`; + confirmButton.hidden = true; + registerPreviewState[type] = { + conversationId: data.conversation_id, + standardLines: [], + }; + statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`; +} + +async function previewRegister(event, type, lines, statusNode) { event.preventDefault(); - statusNode.textContent = "正在提交"; + if (!lines.length) { + clearRegisterPreview(type); + statusNode.textContent = "请先填写登记内容"; + return; + } + const previewNode = { + class_record: classRegisterPreview, + payment: paymentRegisterPreview, + course_summary: summaryRegisterPreview, + }[type]; + const answerNode = previewNode ? previewNode.querySelector("[data-ai-answer]") : null; + const state = registerPreviewState[type] || {}; + const answers = answerNode && answerNode.value.trim() ? { followup: answerNode.value.trim() } : {}; + clearRegisterPreview(type, !state.conversationId); + statusNode.textContent = "正在生成预览"; + try { + const data = await fetchJson("/api/ai/register/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type, + lines, + answers, + conversation_id: state.conversationId || null, + }), + }); + renderRegisterPreview(type, data, statusNode); + } catch (error) { + statusNode.textContent = `预览失败:${error.message}`; + } +} + +async function confirmRegister(type, statusNode, url, onSuccess) { + const state = registerPreviewState[type]; + if (!state || !state.standardLines || !state.standardLines.length) { + statusNode.textContent = "请先生成可确认的预览"; + return; + } + statusNode.textContent = "正在写入"; try { const data = await fetchJson(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ lines: linesFromTextarea(textarea) }), + body: JSON.stringify({ lines: state.standardLines }), }); - statusNode.textContent = `已登记 ${data.registered} 条;备份 ${data.backup_id}`; - textarea.value = ""; + if (type === "course_summary") { + statusNode.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;重复 ${data.duplicates} 条;舍弃 ${data.rejected} 条`; + } else { + statusNode.textContent = `已登记 ${data.registered} 条;备份 ${data.backup_id}`; + } + clearRegisterPreview(type); + onSuccess(); await loadAdminHealth(); await loadAccounts(); } catch (error) { - statusNode.textContent = `提交失败:${error.message}`; - } -} - -function addSummaryRegisterItem(value = "") { - summaryRegisterItemSeq += 1; - const itemId = `summary-register-${summaryRegisterItemSeq}`; - const item = document.createElement("div"); - item.className = "summary-register-item"; - item.innerHTML = ` - -
    - - - - - -
    - - `; - summaryRegisterItems.appendChild(item); -} - -function inferSummaryField(body, field) { - const patterns = { - student: /(?:学生|学员)[::]\s*([^\n;;,,]+)/, - date: /(?:日期|上课日期)[::]\s*(\d{4}[./-]\d{1,2}[./-]\d{1,2})|(\d{4}[./-]\d{1,2}[./-]\d{1,2})/, - time: /(?:时间|上课时间)[::]\s*(\d{1,2}:\d{2}-\d{1,2}:\d{2})|(\d{1,2}:\d{2}-\d{1,2}:\d{2})/, - teacher: /(?:老师|教师)[::]\s*([^\n;;,,]+)|([\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)/, - subject: /(?:科目|课程)[::]\s*([^\n;;,,]+)|(数学|语文|英语|物理|化学|生物|历史|地理|政治|道法)/, - }; - const match = String(body || "").match(patterns[field]); - if (!match) return ""; - return (match[1] || match[2] || "").trim(); -} - -function summaryRegisterItemPayload(item) { - const body = item.querySelector("[data-summary-field='body']").value.trim(); - const values = { body }; - SUMMARY_REQUIRED_FIELDS.forEach(([field]) => { - const input = item.querySelector(`[data-summary-field='${field}']`); - values[field] = input.value.trim() || inferSummaryField(body, field); - if (!input.value.trim() && values[field]) input.value = values[field]; - }); - const missing = SUMMARY_REQUIRED_FIELDS.filter(([field]) => !values[field]).map(([, label]) => label); - const missingNode = item.querySelector(".summary-register-missing"); - missingNode.hidden = missing.length === 0; - missingNode.textContent = missing.length ? `请补齐:${missing.join("、")};无法补齐请删除本框,本条不会提交。` : ""; - item.classList.toggle("has-missing", missing.length > 0); - if (!body || missing.length) return null; - return [ - `学生:${values.student}`, - `日期:${values.date}`, - `时间:${values.time}`, - `老师:${values.teacher}`, - `科目:${values.subject}`, - "小结:", - body, - ].join("\n"); -} - -function summaryRegisterLines() { - const items = Array.from(summaryRegisterItems.querySelectorAll(".summary-register-item")); - const lines = items.map(summaryRegisterItemPayload).filter(Boolean); - return { - lines, - blocked: items.some((item) => item.classList.contains("has-missing")), - }; -} - -async function submitSummaryRegister(event) { - event.preventDefault(); - const { lines, blocked } = summaryRegisterLines(); - if (blocked) { - summaryRegisterStatus.textContent = "还有课程小结缺字段,请补齐后再提交;无法补齐的请删除本框。"; - return; - } - if (!lines.length) { - summaryRegisterStatus.textContent = "请至少填写一条课程小结"; - return; - } - summaryRegisterStatus.textContent = "正在提交"; - try { - const data = await fetchJson("/api/register/course-summaries", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ lines }), - }); - summaryRegisterStatus.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;重复 ${data.duplicates} 条;舍弃 ${data.rejected} 条`; - summaryRegisterItems.innerHTML = ""; - addSummaryRegisterItem(); - await loadAdminHealth(); - await loadAccounts(); - } catch (error) { - summaryRegisterStatus.textContent = `提交失败:${error.message}`; + statusNode.textContent = `写入失败:${error.message}`; } } @@ -982,24 +995,29 @@ logFilterForm.addEventListener("submit", (event) => { loadOperationLogs(); }); classRegisterForm.addEventListener("submit", (event) => { - submitRegister(event, classRegisterLines, classRegisterStatus, "/api/register/class-records"); + previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus); }); paymentRegisterForm.addEventListener("submit", (event) => { - submitRegister(event, paymentRegisterLines, paymentRegisterStatus, "/api/register/payments"); + previewRegister(event, "payment", linesFromTextarea(paymentRegisterLines), paymentRegisterStatus); }); -addSummaryRegisterItemBtn.addEventListener("click", () => addSummaryRegisterItem()); -summaryRegisterItems.addEventListener("click", (event) => { - const button = event.target.closest(".summary-register-remove"); - if (!button) return; - const items = summaryRegisterItems.querySelectorAll(".summary-register-item"); - if (items.length <= 1) { - const textarea = button.closest(".summary-register-item").querySelector("textarea"); - if (textarea) textarea.value = ""; - return; - } - button.closest(".summary-register-item").remove(); +classRegisterConfirm.addEventListener("click", () => { + confirmRegister("class_record", classRegisterStatus, "/api/register/class-records", () => { + classRegisterLines.value = ""; + }); +}); +paymentRegisterConfirm.addEventListener("click", () => { + confirmRegister("payment", paymentRegisterStatus, "/api/register/payments", () => { + paymentRegisterLines.value = ""; + }); +}); +summaryRegisterForm.addEventListener("submit", (event) => { + previewRegister(event, "course_summary", linesFromTextarea(summaryRegisterText), summaryRegisterStatus); +}); +summaryRegisterConfirm.addEventListener("click", () => { + confirmRegister("course_summary", summaryRegisterStatus, "/api/register/course-summaries", () => { + summaryRegisterText.value = ""; + }); }); -summaryRegisterForm.addEventListener("submit", submitSummaryRegister); refreshBtn.addEventListener("click", () => { loadAdminHealth(); if (!panels.accounts.hidden) loadAccounts(); diff --git a/app/app/static/styles.css b/app/app/static/styles.css index c736402..d18f3bd 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -397,45 +397,34 @@ textarea:focus { gap: 12px; } -.summary-register-items { +.register-preview { display: grid; - gap: 10px; -} - -.summary-register-item { - display: grid; - gap: 7px; -} - -.summary-register-item.has-missing { - padding: 10px; - border: 1px solid #fecdca; - border-radius: 6px; - background: #fff7f6; -} - -.summary-register-item label { - color: #344054; - font-size: 13px; - font-weight: 700; -} - -.summary-register-fields { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: #f8fafc; } -.summary-register-missing { - margin: 0; - color: var(--danger); +.register-preview strong { + color: var(--text); + font-size: 13px; +} + +.register-preview pre { + margin: 0; + max-height: 220px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: #344054; + font-family: Arial, "Songti SC", SimSun, sans-serif; font-size: 13px; - font-weight: 700; line-height: 1.45; } -.summary-register-remove { - justify-self: start; +.register-preview textarea { + min-height: 72px; } .line-code { @@ -1158,10 +1147,6 @@ td { grid-template-columns: 1fr; } - .summary-register-fields { - grid-template-columns: 1fr; - } - .admin-tabs { overflow-x: auto; gap: 6px; diff --git a/app/docker-compose.yml b/app/docker-compose.yml index c913c12..f532c9c 100644 --- a/app/docker-compose.yml +++ b/app/docker-compose.yml @@ -17,8 +17,13 @@ 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:-90} 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