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 @@
- +