接入 AI 登记预览与追问

This commit is contained in:
Codex
2026-06-16 13:47:03 +08:00
parent 1a9235408c
commit 6c68eb7152
11 changed files with 723 additions and 171 deletions
+349
View File
@@ -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,
}
+3
View File
@@ -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", "")
+78 -2
View File
@@ -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(
+2 -1
View File
@@ -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)
+27
View File
@@ -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
+76 -1
View File
@@ -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
+9 -1
View File
@@ -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 = ""
+11 -6
View File
@@ -386,32 +386,37 @@
<form id="classRegisterForm" class="admin-form">
<h3>上课记录登记</h3>
<textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea>
<div id="classRegisterPreview" class="register-preview" hidden></div>
<p id="classRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions">
<button type="submit">提交上课记录</button>
<button type="submit">生成预览</button>
<button id="classRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div>
</form>
<form id="paymentRegisterForm" class="admin-form">
<h3>缴费登记</h3>
<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>
<div class="modal-actions">
<button type="submit">提交缴费记录</button>
<button type="submit">生成预览</button>
<button id="paymentRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div>
</form>
<form id="summaryRegisterForm" class="admin-form">
<h3>课程小结登记</h3>
<div id="summaryRegisterItems" class="summary-register-items"></div>
<button id="addSummaryRegisterItemBtn" class="secondary-button" type="button">再填一条</button>
<textarea id="summaryRegisterText" rows="12" placeholder="粘贴一条或多条课程小结原文,学生/日期/时间/老师/科目由系统自动提取"></textarea>
<div id="summaryRegisterPreview" class="register-preview" hidden></div>
<p id="summaryRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions">
<button type="submit">提交课程小结</button>
<button type="submit">生成预览</button>
<button id="summaryRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div>
</form>
</div>
</section>
</main>
<script src="/static/admin.js?v=20260615-summary-time-review"></script>
<script src="/static/admin.js?v=20260616-summary-single-input"></script>
</body>
</html>
+142 -124
View File
@@ -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 ? `<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();
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 }),
});
if (type === "course_summary") {
statusNode.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;重复 ${data.duplicates} 条;舍弃 ${data.rejected}`;
} else {
statusNode.textContent = `已登记 ${data.registered} 条;备份 ${data.backup_id}`;
textarea.value = "";
}
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 = `<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}`;
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();
+20 -35
View File
@@ -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;
+5
View File
@@ -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