feat: ingest course summaries on vps
This commit is contained in:
+117
-3
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
import threading
|
||||
from urllib.parse import parse_qs, quote
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
@@ -19,11 +19,14 @@ from .data import (
|
||||
Account,
|
||||
DuplicateRecordError,
|
||||
Payment,
|
||||
approve_correction_task,
|
||||
append_operation_log,
|
||||
approve_admin_task,
|
||||
account_summary,
|
||||
account_to_dict,
|
||||
create_account,
|
||||
filter_accounts,
|
||||
ingest_course_summaries,
|
||||
list_operation_logs,
|
||||
list_admin_tasks,
|
||||
query_records,
|
||||
read_accounts,
|
||||
@@ -41,9 +44,13 @@ STATIC_DIR = APP_DIR / "static"
|
||||
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
|
||||
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
|
||||
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"))
|
||||
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
|
||||
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
|
||||
ADMIN_AUTH_PASSWORD = os.getenv("ADMIN_AUTH_PASSWORD") or ACCOUNTS_AUTH_PASSWORD
|
||||
INGEST_AUTH_TOKEN = os.getenv("INGEST_AUTH_TOKEN", "")
|
||||
RECORDS_SESSION_COOKIE = "xsk_records_session"
|
||||
ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
|
||||
ADMIN_SESSION_COOKIE = "xsk_admin_session"
|
||||
@@ -82,6 +89,42 @@ class CorrectionSubmitPayload(BaseModel):
|
||||
items: list[CorrectionItemPayload]
|
||||
|
||||
|
||||
class CourseSummaryPayload(BaseModel):
|
||||
source_id: str = ""
|
||||
student: str
|
||||
date_iso: str = ""
|
||||
date: str = ""
|
||||
time_range: str = ""
|
||||
raw_time: str = ""
|
||||
duration: str = ""
|
||||
duration_hours: float | None = None
|
||||
duration_minutes: int | None = None
|
||||
teacher: str = ""
|
||||
subject: str = ""
|
||||
group: str = ""
|
||||
sender: str = ""
|
||||
sender_name: str = ""
|
||||
sender_id: str = ""
|
||||
message_time: str = ""
|
||||
message_date: str = ""
|
||||
db: str = ""
|
||||
local_id: str | int | None = ""
|
||||
title: str = ""
|
||||
body: str
|
||||
recognition_source: str = ""
|
||||
confidence: str = ""
|
||||
teacher_trusted: bool = False
|
||||
sender_teacher_trusted: bool = False
|
||||
remark: str = ""
|
||||
|
||||
|
||||
class CourseSummaryIngestPayload(BaseModel):
|
||||
batch_id: str
|
||||
window: dict = Field(default_factory=dict)
|
||||
students: list[str] = Field(default_factory=list)
|
||||
summaries: list[CourseSummaryPayload] = Field(default_factory=list)
|
||||
|
||||
|
||||
async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
||||
body = await request.body()
|
||||
if not body.strip():
|
||||
@@ -217,6 +260,16 @@ def verify_any_auth(
|
||||
)
|
||||
|
||||
|
||||
def verify_ingest_token(x_ingest_token: str = Header(default="")) -> str:
|
||||
configured_password("课程小结推送", INGEST_AUTH_TOKEN)
|
||||
if not hmac.compare_digest(x_ingest_token, INGEST_AUTH_TOKEN):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="课程小结推送 token 不正确",
|
||||
)
|
||||
return "ingest"
|
||||
|
||||
|
||||
def safe_next_path(value: str | None) -> str:
|
||||
if not value or not value.startswith("/") or value.startswith("//"):
|
||||
return "/"
|
||||
@@ -634,6 +687,27 @@ async def register_payments(request: Request, _user: str = Depends(verify_admin_
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@app.post("/api/ingest/course-summaries")
|
||||
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = ingest_course_summaries(
|
||||
classnotes_path=CLASSNOTES_PATH,
|
||||
accounts_path=ACCOUNTS_PATH,
|
||||
tasks_path=ADMIN_TASKS_PATH,
|
||||
summaries_root=COURSE_SUMMARIES_ROOT,
|
||||
state_path=COURSE_SUMMARY_STATE_PATH,
|
||||
operation_logs_path=OPERATION_LOGS_PATH,
|
||||
batch_id=payload.batch_id,
|
||||
window=payload.window,
|
||||
students=payload.students,
|
||||
summaries=[item.dict() for item in payload.summaries],
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health(_user: str = Depends(verify_records_auth)):
|
||||
records = load_records()
|
||||
@@ -642,6 +716,9 @@ def health(_user: str = Depends(verify_records_auth)):
|
||||
"ok": True,
|
||||
"classnotes": file_meta(CLASSNOTES_PATH),
|
||||
"accounts": file_meta(ACCOUNTS_PATH),
|
||||
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
|
||||
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
|
||||
"operation_logs": file_meta(OPERATION_LOGS_PATH),
|
||||
"records_count": len(records),
|
||||
"accounts_count": len(accounts),
|
||||
"account_summary": account_summary(accounts),
|
||||
@@ -748,11 +825,39 @@ def admin_tasks(
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/api/admin/operation-logs")
|
||||
def admin_operation_logs(
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
operation: str = Query(""),
|
||||
status_filter: str = Query("", alias="status"),
|
||||
student: str = Query(""),
|
||||
_user: str = Depends(verify_admin_auth),
|
||||
):
|
||||
return list_operation_logs(
|
||||
OPERATION_LOGS_PATH,
|
||||
limit=limit,
|
||||
operation=operation,
|
||||
status_filter=status_filter,
|
||||
student=student,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/admin/tasks/{task_id}/approve")
|
||||
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = approve_correction_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, task_id)
|
||||
result = approve_admin_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
|
||||
task = result.get("task", {})
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"admin_task_approve",
|
||||
"approved",
|
||||
task_id=task_id,
|
||||
task_type=str(task.get("type") or ""),
|
||||
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
||||
source_id=str(task.get("source_id") or ""),
|
||||
backup_id=str(result.get("backup_id") or task.get("backup_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -763,6 +868,15 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
task = reject_admin_task(ADMIN_TASKS_PATH, task_id)
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"admin_task_reject",
|
||||
"rejected",
|
||||
task_id=task_id,
|
||||
task_type=str(task.get("type") or ""),
|
||||
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
||||
source_id=str(task.get("source_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, "task": task}
|
||||
|
||||
Reference in New Issue
Block a user