Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 3 additions & 33 deletions backend/app/api/endpoints/admin_catalogue.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,12 @@
from fastapi import APIRouter, Depends

from app.api.deps.auth import get_current_user
from app.models import User
from app.services.admin_common import require_admin
from app.services.learning_resources import _builtin_descriptors, spaces_manifest
from app.services import admin_catalogue as service


router = APIRouter()


@router.get("/catalogue/preview")
def preview_catalogue(current_user: User = Depends(get_current_user)) -> dict:
require_admin(current_user)
latest: dict[str, dict] = {}
for descriptor in _builtin_descriptors():
if descriptor.get("kind") != "activity":
continue
key = str(descriptor["key"])
version = int(descriptor.get("version_number", 1))
previous = latest.get(key)
if previous is None or version > int(previous.get("version_number", 1)):
latest[key] = descriptor
items = []
for index, descriptor in enumerate(sorted(latest.values(), key=lambda item: (item["space"], item["key"])), 1):
items.append(
{
"id": index,
"resource_key": descriptor["key"],
"space_key": descriptor["space"],
"subject_key": descriptor["subject"],
"kind": "activity",
"version_number": int(descriptor.get("version_number", 1)),
"title": descriptor["title"],
"renderer": descriptor["renderer"],
"definition": descriptor["definition"],
"capabilities": descriptor["capabilities"],
"provenance": descriptor["provenance"],
"content_sha256": "catalogue-preview",
}
)
return {"spaces": spaces_manifest()["spaces"], "items": items, "total": len(items)}
def preview_catalogue(actor=Depends(get_current_user)) -> dict:
return service.preview_catalogue(actor=actor)
53 changes: 6 additions & 47 deletions backend/app/api/endpoints/ai_tutor.py
Original file line number Diff line number Diff line change
@@ -1,59 +1,18 @@
from urllib.parse import urljoin

import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends

from app.api.deps.auth import get_current_user
from app.core.config import get_settings
from app.models import User
from app.schemas.ai_tutor import AiTutorChatCreate, AiTutorChatRead, AiTutorConfigRead
from app.services import ai_tutor as service


router = APIRouter()


def _config() -> tuple[bool, str, str, str | None]:
settings = get_settings()
key = settings.ai_tutor_api_key.get_secret_value() if settings.ai_tutor_api_key else None
enabled = bool(settings.ai_tutor_enabled and key and settings.ai_tutor_provider == "deepseek")
return enabled, settings.ai_tutor_provider, settings.ai_tutor_model, key


@router.get("/config", response_model=AiTutorConfigRead)
def get_config(current_user: User = Depends(get_current_user)) -> AiTutorConfigRead:
del current_user
enabled, provider, model, _ = _config()
return AiTutorConfigRead(enabled=enabled, provider=provider, model=model if enabled else None)
def get_config(_actor=Depends(get_current_user)) -> AiTutorConfigRead:
return service.get_config()


@router.post("/chat", response_model=AiTutorChatRead)
def chat(payload: AiTutorChatCreate, current_user: User = Depends(get_current_user)) -> AiTutorChatRead:
enabled, provider, model, api_key = _config()
if not enabled or not api_key:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="学校尚未启用 AI 助教")
context = payload.context or {}
context_line = ";".join(f"{key}={value}" for key, value in context.items() if value)
system = (
"你是星序学习平台的 AI 助教。使用简洁、可验证的中文回答,优先引导学生观察、提出假设和检查证据,"
"不要替代教师评分,不要声称学生已经掌握,也不要编造当前实验中不存在的参数。"
f"当前学习身份:{current_user.role}。当前页面上下文:{context_line or '未提供'}。"
)
endpoint = urljoin(get_settings().ai_tutor_base_url.rstrip("/") + "/", "chat/completions")
request_body = {
"model": model,
"messages": [{"role": "system", "content": system}, {"role": "user", "content": payload.message}],
"temperature": 0.35,
"stream": False,
}
try:
with httpx.Client(timeout=get_settings().ai_tutor_timeout_seconds) as client:
response = client.post(endpoint, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, json=request_body)
response.raise_for_status()
data = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="AI 助教暂时无法连接模型服务") from exc
choices = data.get("choices") if isinstance(data, dict) else None
answer = choices[0].get("message", {}).get("content") if choices and isinstance(choices[0], dict) else None
if not isinstance(answer, str) or not answer.strip():
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型没有返回可显示的回答")
return AiTutorChatRead(answer=answer.strip(), provider=provider, model=model)
def chat(payload: AiTutorChatCreate, actor=Depends(get_current_user)) -> AiTutorChatRead:
return service.chat(payload, actor=actor)
23 changes: 9 additions & 14 deletions backend/app/api/endpoints/content_platform.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""HTTP adapter for V8.4 shared course drafts and releases."""

from collections.abc import Callable
from typing import Any

from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, Request, status
from sqlalchemy.orm import Session

from app.api.deps.auth import get_current_user
from app.api.service_calls import service_call
from app.db.session import get_db
from app.schemas.content_platform import (
CheckpointAttemptCreate,
Expand All @@ -24,11 +24,6 @@
router = APIRouter()


def _service_call(db: Session, operation: Callable[..., Any], **kwargs: Any) -> Any:
from app.api.endpoints.course_workflow import service_call
return service_call(db, operation, **kwargs)


@router.post(
"/courses/{course_id}/units/{unit_id}/checkpoints/{checkpoint_key}/attempts",
response_model=CheckpointAttemptRead,
Expand All @@ -43,7 +38,7 @@ def submit_checkpoint_attempt(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
return _service_call(
return service_call(
db,
completion_service.submit_checkpoint_attempt,
actor=current_user,
Expand All @@ -61,7 +56,7 @@ def get_course_draft(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
return _service_call(
return service_call(
db,
platform_service.get_course_draft,
actor=current_user,
Expand All @@ -77,7 +72,7 @@ def replace_course_draft(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
return _service_call(
return service_call(
db,
platform_service.replace_course_draft,
actor=current_user,
Expand All @@ -93,7 +88,7 @@ def list_course_releases(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
return _service_call(
return service_call(
db,
platform_service.list_course_releases,
actor=current_user,
Expand All @@ -113,7 +108,7 @@ def create_course_release(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
return _service_call(
return service_call(
db,
platform_service.create_course_release,
actor=current_user,
Expand All @@ -132,7 +127,7 @@ def get_current_course_release(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
return _service_call(
return service_call(
db,
platform_service.get_current_course_release,
actor=current_user,
Expand All @@ -150,7 +145,7 @@ def get_course_release(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
return _service_call(
return service_call(
db,
platform_service.get_course_release,
actor=current_user,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/endpoints/course_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from starlette.concurrency import run_in_threadpool

from app.api.deps.auth import get_current_user
from app.api.endpoints.course_workflow import service_call
from app.api.service_calls import service_call
from app.db.session import get_db
from app.schemas.course_media import CourseMediaPage, CourseMediaRead
from app.services import course_media as media
Expand Down
31 changes: 2 additions & 29 deletions backend/app/api/endpoints/course_workflow.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
"""v2 course workflow transport. Services own authorization and one commit per command."""
from collections.abc import Callable
from typing import Any

from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.exc import IntegrityError, OperationalError
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session

from app.api.deps.auth import get_current_user
from app.api.service_calls import service_call
from app.db.session import get_db
from app.schemas.course_workflow import CourseCreateCommand, CourseDraftCommand, CourseDraftReadV2, CourseForkCommand, CourseForkRead, CourseRevisionRead, CourseWorkflowRead, WorkflowReceiptRead
from app.services import course_drafts_v2 as drafts
from app.services.course_workflow_support import read_operation
from app.services.content_platform import ContentPlatformError
from app.services.course_completion import CourseCompletionError
from app.core.learning_evidence_contract import LearningEvidenceError
from app.schemas import course_workflow as dto
from app.services import course_candidates as candidates, course_reviews_v2 as reviews, course_publications_v2 as publications

Expand All @@ -25,28 +20,6 @@ def operation_receipt(client_request_id: str, actor=Depends(get_current_user), d
return service_call(db, read_operation, actor=actor, client_request_id=client_request_id)


def service_call(db: Session, operation: Callable[..., Any], **kwargs):
try:
return operation(db, **kwargs)
except (ContentPlatformError, CourseCompletionError) as error:
db.rollback()
raise HTTPException(status_code=error.status_code, detail={"code": error.code, "message": error.message}) from error
except LearningEvidenceError as error:
db.rollback()
raise HTTPException(status_code=error.status_code, detail={"code": error.code, "message": error.detail}) from error
except IntegrityError as error:
db.rollback()
raise HTTPException(status_code=409, detail={"code": "workflow_write_conflict", "message": "数据已变化或操作已提交,请重新读取并保留原请求编号"}) from error
except OperationalError as error:
db.rollback()
if "database is locked" in str(error.orig).lower() or "database table is locked" in str(error.orig).lower():
raise HTTPException(status_code=409, detail={"code": "workflow_write_conflict", "message": "另一项写入正在提交,请保留原请求编号后重试"}) from error
raise
except HTTPException:
db.rollback()
raise


@router.get("/courses", response_model=list[CourseWorkflowRead])
def list_courses(school_id: int | None = None, actor=Depends(get_current_user), db: Session = Depends(get_db)):
return service_call(db, drafts.list_courses, actor=actor, school_id=school_id)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/endpoints/learning_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from sqlalchemy.orm import Session

from app.api.deps.auth import get_current_user
from app.api.endpoints.course_workflow import service_call
from app.api.service_calls import service_call
from app.db.session import get_db
from app.schemas.learning_history import ContextCheckpointAnswer, ContextCheckpointRead, LearningContextRead, LearningContextStart
from app.services import learning_assessments, learning_contexts
Expand Down
Loading
Loading