接入 AI 登记预览与追问
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
@@ -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_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"))
|
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"))
|
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", "")
|
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
|
||||||
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
|
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
|
||||||
|
|||||||
+78
-2
@@ -2047,6 +2047,39 @@ def create_course_summary_review_task(
|
|||||||
return task_to_dict(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(
|
def approve_course_summary_task(
|
||||||
tasks_path: Path,
|
tasks_path: Path,
|
||||||
classnotes_path: Path,
|
classnotes_path: Path,
|
||||||
@@ -2062,8 +2095,7 @@ def approve_course_summary_task(
|
|||||||
|
|
||||||
proposed_line = str(task.get("proposed_line") or "").strip()
|
proposed_line = str(task.get("proposed_line") or "").strip()
|
||||||
if not proposed_line:
|
if not proposed_line:
|
||||||
summary = dict(task.get("summary") or {})
|
raise ValueError("课程小结信息未补齐,不能直接批准入账")
|
||||||
proposed_line = course_summary_to_class_record_line(summary)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
|
result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
|
||||||
@@ -2251,8 +2283,14 @@ def ingest_course_summaries(
|
|||||||
|
|
||||||
for raw in summaries:
|
for raw in summaries:
|
||||||
normalized: dict | None = None
|
normalized: dict | None = None
|
||||||
|
backup_id = ""
|
||||||
try:
|
try:
|
||||||
normalized = normalize_course_summary(raw)
|
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"]
|
source_id = normalized["source_id"]
|
||||||
semantic_key = course_summary_semantic_key(normalized)
|
semantic_key = course_summary_semantic_key(normalized)
|
||||||
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
|
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
|
result["saved"] += 1 if saved.get("added") else 0
|
||||||
|
|
||||||
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
|
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:
|
if reasons:
|
||||||
task = create_course_summary_review_task(
|
task = create_course_summary_review_task(
|
||||||
tasks_path,
|
tasks_path,
|
||||||
@@ -2304,6 +2346,8 @@ def ingest_course_summaries(
|
|||||||
subject=normalized.get("subject", ""),
|
subject=normalized.get("subject", ""),
|
||||||
proposed_line=proposed_line,
|
proposed_line=proposed_line,
|
||||||
reasons=reasons,
|
reasons=reasons,
|
||||||
|
ai_used=bool(raw.get("ai_used")),
|
||||||
|
ai_summary=str(raw.get("ai_summary") or ""),
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
backup_id=backup_id,
|
backup_id=backup_id,
|
||||||
saved_path=str(saved.get("path") or ""),
|
saved_path=str(saved.get("path") or ""),
|
||||||
@@ -2319,6 +2363,38 @@ def ingest_course_summaries(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
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
|
result["rejected"] += 1
|
||||||
source_id = str((normalized or raw).get("source_id") or "")
|
source_id = str((normalized or raw).get("source_id") or "")
|
||||||
log_id = append_operation_log(
|
log_id = append_operation_log(
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
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")
|
app = FastAPI(title="新时空教务管理系统", version="1.0.0")
|
||||||
@@ -19,4 +19,5 @@ app.include_router(health.router)
|
|||||||
app.include_router(records.router)
|
app.include_router(records.router)
|
||||||
app.include_router(accounts.router)
|
app.include_router(accounts.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
|
app.include_router(ai_register.router)
|
||||||
app.include_router(ingest.router)
|
app.include_router(ingest.router)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from ..auth import verify_ingest_token
|
from ..auth import verify_ingest_token
|
||||||
|
from ..ai_register import preview_register
|
||||||
from ..config import (
|
from ..config import (
|
||||||
ACCOUNTS_PATH,
|
ACCOUNTS_PATH,
|
||||||
ADMIN_TASKS_PATH,
|
ADMIN_TASKS_PATH,
|
||||||
@@ -19,9 +20,83 @@ from ..schemas import CourseSummaryIngestPayload
|
|||||||
router = APIRouter()
|
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")
|
@router.post("/api/ingest/course-summaries")
|
||||||
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
|
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
|
||||||
try:
|
try:
|
||||||
|
summaries = _preprocess_summaries_with_ai([item.dict() for item in payload.summaries])
|
||||||
with write_lock:
|
with write_lock:
|
||||||
result = ingest_course_summaries(
|
result = ingest_course_summaries(
|
||||||
classnotes_path=CLASSNOTES_PATH,
|
classnotes_path=CLASSNOTES_PATH,
|
||||||
@@ -33,7 +108,7 @@ def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str
|
|||||||
batch_id=payload.batch_id,
|
batch_id=payload.batch_id,
|
||||||
window=payload.window,
|
window=payload.window,
|
||||||
students=payload.students,
|
students=payload.students,
|
||||||
summaries=[item.dict() for item in payload.summaries],
|
summaries=summaries,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|||||||
+9
-1
@@ -8,6 +8,14 @@ class RegisterLinesPayload(BaseModel):
|
|||||||
lines: list[str] | None = Field(default=None, description="多条原始登记文本")
|
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):
|
class PaymentPayload(BaseModel):
|
||||||
date: str
|
date: str
|
||||||
hours: float
|
hours: float
|
||||||
@@ -54,7 +62,7 @@ class DeletionSubmitPayload(BaseModel):
|
|||||||
|
|
||||||
class CourseSummaryPayload(BaseModel):
|
class CourseSummaryPayload(BaseModel):
|
||||||
source_id: str = ""
|
source_id: str = ""
|
||||||
student: str
|
student: str = ""
|
||||||
date_iso: str = ""
|
date_iso: str = ""
|
||||||
date: str = ""
|
date: str = ""
|
||||||
time_range: str = ""
|
time_range: str = ""
|
||||||
|
|||||||
@@ -386,32 +386,37 @@
|
|||||||
<form id="classRegisterForm" class="admin-form">
|
<form id="classRegisterForm" class="admin-form">
|
||||||
<h3>上课记录登记</h3>
|
<h3>上课记录登记</h3>
|
||||||
<textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea>
|
<textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea>
|
||||||
|
<div id="classRegisterPreview" class="register-preview" hidden></div>
|
||||||
<p id="classRegisterStatus" class="inline-account-loading"></p>
|
<p id="classRegisterStatus" class="inline-account-loading"></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="submit">提交上课记录</button>
|
<button type="submit">生成预览</button>
|
||||||
|
<button id="classRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form id="paymentRegisterForm" class="admin-form">
|
<form id="paymentRegisterForm" class="admin-form">
|
||||||
<h3>缴费登记</h3>
|
<h3>缴费登记</h3>
|
||||||
<textarea id="paymentRegisterLines" rows="10" placeholder="每行一条:学生-2026-06-01:53"></textarea>
|
<textarea id="paymentRegisterLines" rows="10" placeholder="每行一条:学生-2026-06-01:53"></textarea>
|
||||||
|
<div id="paymentRegisterPreview" class="register-preview" hidden></div>
|
||||||
<p id="paymentRegisterStatus" class="inline-account-loading"></p>
|
<p id="paymentRegisterStatus" class="inline-account-loading"></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="submit">提交缴费记录</button>
|
<button type="submit">生成预览</button>
|
||||||
|
<button id="paymentRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form id="summaryRegisterForm" class="admin-form">
|
<form id="summaryRegisterForm" class="admin-form">
|
||||||
<h3>课程小结登记</h3>
|
<h3>课程小结登记</h3>
|
||||||
<div id="summaryRegisterItems" class="summary-register-items"></div>
|
<textarea id="summaryRegisterText" rows="12" placeholder="粘贴一条或多条课程小结原文,学生/日期/时间/老师/科目由系统自动提取"></textarea>
|
||||||
<button id="addSummaryRegisterItemBtn" class="secondary-button" type="button">再填一条</button>
|
<div id="summaryRegisterPreview" class="register-preview" hidden></div>
|
||||||
<p id="summaryRegisterStatus" class="inline-account-loading"></p>
|
<p id="summaryRegisterStatus" class="inline-account-loading"></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="submit">提交课程小结</button>
|
<button type="submit">生成预览</button>
|
||||||
|
<button id="summaryRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/admin.js?v=20260615-summary-time-review"></script>
|
<script src="/static/admin.js?v=20260616-summary-single-input"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+143
-125
@@ -79,14 +79,19 @@ const logMeta = document.querySelector("#logMeta");
|
|||||||
const logRows = document.querySelector("#logRows");
|
const logRows = document.querySelector("#logRows");
|
||||||
const classRegisterForm = document.querySelector("#classRegisterForm");
|
const classRegisterForm = document.querySelector("#classRegisterForm");
|
||||||
const classRegisterLines = document.querySelector("#classRegisterLines");
|
const classRegisterLines = document.querySelector("#classRegisterLines");
|
||||||
|
const classRegisterPreview = document.querySelector("#classRegisterPreview");
|
||||||
const classRegisterStatus = document.querySelector("#classRegisterStatus");
|
const classRegisterStatus = document.querySelector("#classRegisterStatus");
|
||||||
|
const classRegisterConfirm = document.querySelector("#classRegisterConfirm");
|
||||||
const paymentRegisterForm = document.querySelector("#paymentRegisterForm");
|
const paymentRegisterForm = document.querySelector("#paymentRegisterForm");
|
||||||
const paymentRegisterLines = document.querySelector("#paymentRegisterLines");
|
const paymentRegisterLines = document.querySelector("#paymentRegisterLines");
|
||||||
|
const paymentRegisterPreview = document.querySelector("#paymentRegisterPreview");
|
||||||
const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus");
|
const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus");
|
||||||
|
const paymentRegisterConfirm = document.querySelector("#paymentRegisterConfirm");
|
||||||
const summaryRegisterForm = document.querySelector("#summaryRegisterForm");
|
const summaryRegisterForm = document.querySelector("#summaryRegisterForm");
|
||||||
const summaryRegisterItems = document.querySelector("#summaryRegisterItems");
|
const summaryRegisterText = document.querySelector("#summaryRegisterText");
|
||||||
const addSummaryRegisterItemBtn = document.querySelector("#addSummaryRegisterItemBtn");
|
const summaryRegisterPreview = document.querySelector("#summaryRegisterPreview");
|
||||||
const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
|
const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
|
||||||
|
const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm");
|
||||||
|
|
||||||
let currentAccounts = [];
|
let currentAccounts = [];
|
||||||
let editingAccountId = "";
|
let editingAccountId = "";
|
||||||
@@ -94,15 +99,11 @@ let currentTeachers = [];
|
|||||||
let editingTeacherId = "";
|
let editingTeacherId = "";
|
||||||
let currentSummaryReviews = [];
|
let currentSummaryReviews = [];
|
||||||
let activeSummaryReview = null;
|
let activeSummaryReview = null;
|
||||||
let summaryRegisterItemSeq = 0;
|
const registerPreviewState = {
|
||||||
|
class_record: null,
|
||||||
const SUMMARY_REQUIRED_FIELDS = [
|
payment: null,
|
||||||
["student", "学生"],
|
course_summary: null,
|
||||||
["date", "日期"],
|
};
|
||||||
["time", "时间"],
|
|
||||||
["teacher", "老师"],
|
|
||||||
["subject", "科目"],
|
|
||||||
];
|
|
||||||
|
|
||||||
function fmtHours(value) {
|
function fmtHours(value) {
|
||||||
const totalMinutes = Math.round(Number(value || 0) * 60);
|
const totalMinutes = Math.round(Number(value || 0) * 60);
|
||||||
@@ -764,115 +765,127 @@ function linesFromTextarea(textarea) {
|
|||||||
.filter(Boolean);
|
.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 ? `<p>${escapeHtml(data.warning)}</p>` : "";
|
||||||
|
previewNode.innerHTML = `${warning}<strong>标准登记内容</strong><pre>${escapeHtml((data.standard_lines || []).join("\n\n"))}</pre>`;
|
||||||
|
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) => `<li>${escapeHtml(item)}</li>`).join("");
|
||||||
|
previewNode.innerHTML = `<strong>需要补充信息</strong><ul>${questions}</ul><textarea data-ai-answer rows="3" placeholder="在这里补充缺失信息后再次生成预览"></textarea>`;
|
||||||
|
confirmButton.hidden = true;
|
||||||
|
registerPreviewState[type] = {
|
||||||
|
conversationId: data.conversation_id,
|
||||||
|
standardLines: [],
|
||||||
|
};
|
||||||
|
statusNode.textContent = "请补充信息后再次生成预览";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const questions = (data.questions || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
|
||||||
|
previewNode.innerHTML = `<strong>无法生成预览</strong>${questions ? `<ul>${questions}</ul>` : ""}`;
|
||||||
|
confirmButton.hidden = true;
|
||||||
|
registerPreviewState[type] = {
|
||||||
|
conversationId: data.conversation_id,
|
||||||
|
standardLines: [],
|
||||||
|
};
|
||||||
|
statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewRegister(event, type, lines, statusNode) {
|
||||||
event.preventDefault();
|
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 {
|
try {
|
||||||
const data = await fetchJson(url, {
|
const data = await fetchJson(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ lines: linesFromTextarea(textarea) }),
|
body: JSON.stringify({ lines: state.standardLines }),
|
||||||
});
|
});
|
||||||
statusNode.textContent = `已登记 ${data.registered} 条;备份 ${data.backup_id}`;
|
if (type === "course_summary") {
|
||||||
textarea.value = "";
|
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 loadAdminHealth();
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusNode.textContent = `提交失败:${error.message}`;
|
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 = `<label for="${itemId}">课程小结</label>
|
|
||||||
<textarea id="${itemId}" data-summary-field="body" rows="7" placeholder="每个输入框填写一条课程小结">${escapeHtml(value)}</textarea>
|
|
||||||
<div class="summary-register-fields">
|
|
||||||
<input data-summary-field="student" autocomplete="off" placeholder="学生" />
|
|
||||||
<input data-summary-field="date" autocomplete="off" placeholder="日期 2026.06.15" />
|
|
||||||
<input data-summary-field="time" autocomplete="off" placeholder="时间 08:00-09:00" />
|
|
||||||
<input data-summary-field="teacher" autocomplete="off" placeholder="老师" />
|
|
||||||
<input data-summary-field="subject" autocomplete="off" placeholder="科目" />
|
|
||||||
</div>
|
|
||||||
<p class="summary-register-missing" hidden></p>
|
|
||||||
<button class="small-button summary-register-remove" type="button">删除本框</button>`;
|
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -982,24 +995,29 @@ logFilterForm.addEventListener("submit", (event) => {
|
|||||||
loadOperationLogs();
|
loadOperationLogs();
|
||||||
});
|
});
|
||||||
classRegisterForm.addEventListener("submit", (event) => {
|
classRegisterForm.addEventListener("submit", (event) => {
|
||||||
submitRegister(event, classRegisterLines, classRegisterStatus, "/api/register/class-records");
|
previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus);
|
||||||
});
|
});
|
||||||
paymentRegisterForm.addEventListener("submit", (event) => {
|
paymentRegisterForm.addEventListener("submit", (event) => {
|
||||||
submitRegister(event, paymentRegisterLines, paymentRegisterStatus, "/api/register/payments");
|
previewRegister(event, "payment", linesFromTextarea(paymentRegisterLines), paymentRegisterStatus);
|
||||||
});
|
});
|
||||||
addSummaryRegisterItemBtn.addEventListener("click", () => addSummaryRegisterItem());
|
classRegisterConfirm.addEventListener("click", () => {
|
||||||
summaryRegisterItems.addEventListener("click", (event) => {
|
confirmRegister("class_record", classRegisterStatus, "/api/register/class-records", () => {
|
||||||
const button = event.target.closest(".summary-register-remove");
|
classRegisterLines.value = "";
|
||||||
if (!button) return;
|
});
|
||||||
const items = summaryRegisterItems.querySelectorAll(".summary-register-item");
|
});
|
||||||
if (items.length <= 1) {
|
paymentRegisterConfirm.addEventListener("click", () => {
|
||||||
const textarea = button.closest(".summary-register-item").querySelector("textarea");
|
confirmRegister("payment", paymentRegisterStatus, "/api/register/payments", () => {
|
||||||
if (textarea) textarea.value = "";
|
paymentRegisterLines.value = "";
|
||||||
return;
|
});
|
||||||
}
|
});
|
||||||
button.closest(".summary-register-item").remove();
|
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", () => {
|
refreshBtn.addEventListener("click", () => {
|
||||||
loadAdminHealth();
|
loadAdminHealth();
|
||||||
if (!panels.accounts.hidden) loadAccounts();
|
if (!panels.accounts.hidden) loadAccounts();
|
||||||
|
|||||||
+20
-35
@@ -397,45 +397,34 @@ textarea:focus {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-register-items {
|
.register-preview {
|
||||||
display: grid;
|
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;
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-register-missing {
|
.register-preview strong {
|
||||||
margin: 0;
|
color: var(--text);
|
||||||
color: var(--danger);
|
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-size: 13px;
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-register-remove {
|
.register-preview textarea {
|
||||||
justify-self: start;
|
min-height: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-code {
|
.line-code {
|
||||||
@@ -1158,10 +1147,6 @@ td {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-register-fields {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-tabs {
|
.admin-tabs {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ services:
|
|||||||
COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries}
|
COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries}
|
||||||
COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json}
|
COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json}
|
||||||
OPERATION_LOGS_PATH: ${OPERATION_LOGS_PATH:-/data/operation_logs.jsonl}
|
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:-}
|
INGEST_AUTH_TOKEN: ${INGEST_AUTH_TOKEN:-}
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-18080}:8000"
|
- "${APP_PORT:-18080}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ../data:/data
|
- ../data:/data
|
||||||
|
- /root/.codex/config.toml:/run/codex/config.toml:ro
|
||||||
|
- /root/.codex/auth.json:/run/codex/auth.json:ro
|
||||||
|
|||||||
Reference in New Issue
Block a user