| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- """
- 知识图谱路由:Neo4j 查询与维护、目录、习题 CSV/JSON 批量导入。
- """
- import csv
- import json
- from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
- from sqlmodel import Session
- from app.db import get_session
- from app.deps import require_role
- from app.integrations.neo4j import Neo4jClient, get_neo4j_client
- from app.models import Exercise, UserRole
- from app.data.knowledge_catalog import iter_catalog_topics
- from app.utils.exercise_constants import normalize_resolution_for_store
- from app.utils.neo4j_exercise import enrich_exercise_from_neo4j
- from app.schemas import (
- ExerciseImportPreviewItem,
- ExerciseImportPreviewResponse,
- ExerciseImportSummary,
- GraphBackfillResponse,
- GraphSeedResponse,
- KnowledgePointInGraph,
- KnowledgeQueryResponse,
- PrerequisiteLinkRequest,
- )
- router = APIRouter(prefix="/knowledge-graph", tags=["knowledge-graph"])
- def _knowledge_to_response(neo4j: Neo4jClient, knowledge_code: str, kp: dict) -> KnowledgeQueryResponse:
- code = str(kp.get("code") or knowledge_code)
- try:
- related = neo4j.list_sibling_knowledge_points(code)
- except Exception:
- related = []
- try:
- prerequisites, next_steps = neo4j.list_prerequisite_edges(code)
- except Exception:
- prerequisites, next_steps = [], []
- return KnowledgeQueryResponse(
- knowledge_code=code,
- name=kp.get("name"),
- grade=kp.get("grade"),
- category=kp.get("category"),
- definition=kp.get("definition"),
- related=related,
- prerequisites=prerequisites,
- next_steps=next_steps,
- )
- def _parse_difficulty(raw: object, row_index: int) -> int | None:
- if raw in (None, ""):
- return None
- try:
- diff = int(str(raw).strip())
- except ValueError as exc:
- raise ValueError(f"第{row_index}行 difficulty 不是整数") from exc
- if diff < 1 or diff > 5:
- raise ValueError(f"第{row_index}行 difficulty 必须在 1-5 之间")
- return diff
- def _parse_weight(raw: object, row_index: int) -> int | None:
- if raw in (None, ""):
- return None
- try:
- w = int(str(raw).strip())
- except ValueError as exc:
- raise ValueError(f"第{row_index}行 weight 不是整数") from exc
- if w < 0:
- raise ValueError(f"第{row_index}行 weight 不能小于 0")
- return w
- def _normalize_row(row: dict[str, object], row_index: int, fallback_knowledge_code: str | None) -> Exercise:
- title = str(row.get("title") or "").strip()
- body = str(row.get("body") or "").strip()
- if not title or not body:
- raise ValueError(f"第{row_index}行缺少 title 或 body")
- row_code = str(row.get("knowledge_code") or "").strip()
- knowledge_code = row_code or (fallback_knowledge_code or "").strip() or None
- difficulty = _parse_difficulty(row.get("difficulty"), row_index)
- weight = _parse_weight(row.get("weight"), row_index)
- question_type = str(row.get("question_type") or "").strip() or None
- correct_answer = str(row.get("correct_answer") or "").strip() or None
- resolution = str(row.get("resolution") or "").strip() or None
- return Exercise(
- title=title,
- body=body,
- knowledge_code=knowledge_code,
- difficulty=difficulty,
- question_type=question_type,
- correct_answer=correct_answer,
- resolution=normalize_resolution_for_store(resolution),
- weight=weight or 0,
- )
- def _parse_upload_rows(raw: bytes, filename: str, fallback_knowledge_code: str | None) -> tuple[list[Exercise], list[str]]:
- exercises: list[Exercise] = []
- errors: list[str] = []
- if filename.endswith(".json"):
- payload = json.loads(raw.decode("utf-8"))
- if not isinstance(payload, list):
- raise HTTPException(status_code=400, detail="JSON 文件必须是数组")
- for idx, item in enumerate(payload, start=1):
- if not isinstance(item, dict):
- errors.append(f"第{idx}项不是对象")
- continue
- try:
- exercises.append(_normalize_row(item, idx, fallback_knowledge_code))
- except ValueError as err:
- errors.append(str(err))
- elif filename.endswith(".csv"):
- text = raw.decode("utf-8-sig")
- reader = csv.DictReader(text.splitlines())
- for idx, row in enumerate(reader, start=2):
- try:
- row_map: dict[str, object] = dict(row)
- exercises.append(_normalize_row(row_map, idx, fallback_knowledge_code))
- except ValueError as err:
- errors.append(str(err))
- else:
- raise HTTPException(status_code=400, detail="仅支持 .csv 或 .json 文件")
- return exercises, errors
- @router.get("/knowledge-points", response_model=list[KnowledgePointInGraph])
- def list_knowledge_points_in_graph(neo4j: Neo4jClient = Depends(get_neo4j_client)):
- """返回 Neo4j 中当前全部知识点(与「写入图」一致,不含仅存在于前端目录的项)。"""
- rows = neo4j.list_all_knowledge_points()
- return [KnowledgePointInGraph(code=r["code"], name=r.get("name"), grade=r.get("grade")) for r in rows]
- @router.get("/catalog")
- def list_knowledge_catalog(neo4j: Neo4jClient = Depends(get_neo4j_client)):
- """
- 返回 Neo4j 中完整知识点目录树:
- { [grade]: { [category]: [{ code, name }, ...] } }
- """
- return neo4j.list_knowledge_catalog()
- @router.get("/knowledge/{knowledge_code}", response_model=KnowledgeQueryResponse)
- def get_knowledge(knowledge_code: str, neo4j: Neo4jClient = Depends(get_neo4j_client)):
- kp = neo4j.get_knowledge_point(knowledge_code)
- if not kp:
- raise HTTPException(status_code=404, detail="Knowledge point not found")
- return _knowledge_to_response(neo4j, knowledge_code, kp)
- @router.post("/knowledge/{knowledge_code}", response_model=KnowledgeQueryResponse)
- def upsert_knowledge(
- knowledge_code: str,
- name: str,
- grade: str | None = None,
- category: str | None = None,
- neo4j: Neo4jClient = Depends(get_neo4j_client),
- _=Depends(require_role(UserRole.teacher)),
- ):
- kp = neo4j.upsert_knowledge_point(knowledge_code, name, grade=grade, category=category)
- return _knowledge_to_response(neo4j, knowledge_code, kp)
- @router.post("/prerequisite")
- def link_prerequisite_edge(
- payload: PrerequisiteLinkRequest,
- neo4j: Neo4jClient = Depends(get_neo4j_client),
- _=Depends(require_role(UserRole.teacher)),
- ):
- try:
- neo4j.link_prerequisite(payload.prerequisite_code, payload.dependent_code)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
- return {"ok": True}
- @router.delete("/prerequisite")
- def unlink_prerequisite_edge(
- prerequisite_code: str = Query(..., description="前置知识点编码"),
- dependent_code: str = Query(..., description="后置知识点编码"),
- neo4j: Neo4jClient = Depends(get_neo4j_client),
- _=Depends(require_role(UserRole.teacher)),
- ):
- try:
- deleted = neo4j.unlink_prerequisite(prerequisite_code, dependent_code)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
- return {"ok": True, "deleted": deleted}
- @router.patch("/categories/rename")
- def rename_category(
- grade: str,
- old_name: str,
- new_name: str,
- neo4j: Neo4jClient = Depends(get_neo4j_client),
- _=Depends(require_role(UserRole.teacher)),
- ):
- if not grade.strip() or not old_name.strip() or not new_name.strip():
- raise HTTPException(status_code=400, detail="grade, old_name, new_name 均不能为空")
- neo4j.rename_category(grade.strip(), old_name.strip(), new_name.strip())
- return {"ok": True}
- @router.delete("/categories")
- def delete_category(
- grade: str,
- name: str,
- neo4j: Neo4jClient = Depends(get_neo4j_client),
- _=Depends(require_role(UserRole.teacher)),
- ):
- if not grade.strip() or not name.strip():
- raise HTTPException(status_code=400, detail="grade 和 name 不能为空")
- neo4j.delete_category(grade.strip(), name.strip())
- return {"ok": True}
- @router.patch("/knowledge/{knowledge_code}/rename")
- def rename_knowledge_point(
- knowledge_code: str,
- new_name: str,
- neo4j: Neo4jClient = Depends(get_neo4j_client),
- _=Depends(require_role(UserRole.teacher)),
- ):
- if not new_name.strip():
- raise HTTPException(status_code=400, detail="new_name 不能为空")
- neo4j.rename_knowledge_point(knowledge_code, new_name.strip())
- return {"ok": True}
- @router.delete("/knowledge/{knowledge_code}")
- def delete_knowledge_point(
- knowledge_code: str,
- neo4j: Neo4jClient = Depends(get_neo4j_client),
- _=Depends(require_role(UserRole.teacher)),
- ):
- neo4j.delete_knowledge_point(knowledge_code)
- return {"ok": True}
- @router.post("/maintenance/backfill-categories", response_model=GraphBackfillResponse)
- def backfill_categories(
- neo4j: Neo4jClient = Depends(get_neo4j_client),
- _=Depends(require_role(UserRole.teacher)),
- ):
- result = neo4j.backfill_category_relations()
- return GraphBackfillResponse(**result)
- @router.post("/maintenance/seed-catalog", response_model=GraphSeedResponse)
- def seed_default_catalog(
- neo4j: Neo4jClient = Depends(get_neo4j_client),
- _=Depends(require_role(UserRole.teacher)),
- ):
- """
- 将默认初中数学知识点目录(与前端下拉框一致)全部 MERGE 进 Neo4j。
- 可重复执行,用于“补完”图数据或重装后恢复。
- """
- n = 0
- for grade, category, code, name in iter_catalog_topics():
- neo4j.upsert_knowledge_point(code, name, grade=grade, category=category)
- n += 1
- return GraphSeedResponse(upserted=n)
- @router.post("/exercises/import", response_model=ExerciseImportSummary)
- async def import_exercises(
- file: UploadFile = File(...),
- knowledge_code: str | None = None,
- session: Session = Depends(get_session),
- neo4j: Neo4jClient = Depends(get_neo4j_client),
- _=Depends(require_role(UserRole.teacher)),
- ):
- filename = (file.filename or "").lower()
- raw = await file.read()
- if not raw:
- raise HTTPException(status_code=400, detail="上传文件为空")
- try:
- exercises, errors = _parse_upload_rows(raw, filename, knowledge_code)
- except UnicodeDecodeError as exc:
- raise HTTPException(status_code=400, detail="文件编码错误,请使用 UTF-8") from exc
- except json.JSONDecodeError as exc:
- raise HTTPException(status_code=400, detail=f"JSON 解析失败: {exc.msg}") from exc
- if not exercises:
- return ExerciseImportSummary(total=0, created=0, skipped=len(errors), errors=errors)
- for ex in exercises:
- enrich_exercise_from_neo4j(ex, neo4j)
- session.add(ex)
- session.commit()
- return ExerciseImportSummary(
- total=len(exercises) + len(errors),
- created=len(exercises),
- skipped=len(errors),
- errors=errors,
- )
- @router.post("/exercises/import/preview", response_model=ExerciseImportPreviewResponse)
- async def preview_exercises_import(
- file: UploadFile = File(...),
- knowledge_code: str | None = None,
- neo4j: Neo4jClient = Depends(get_neo4j_client),
- _=Depends(require_role(UserRole.teacher)),
- ):
- filename = (file.filename or "").lower()
- raw = await file.read()
- if not raw:
- raise HTTPException(status_code=400, detail="上传文件为空")
- try:
- exercises, errors = _parse_upload_rows(raw, filename, knowledge_code)
- except UnicodeDecodeError as exc:
- raise HTTPException(status_code=400, detail="文件编码错误,请使用 UTF-8") from exc
- except json.JSONDecodeError as exc:
- raise HTTPException(status_code=400, detail=f"JSON 解析失败: {exc.msg}") from exc
- sample = []
- for ex in exercises[:20]:
- enrich_exercise_from_neo4j(ex, neo4j)
- sample.append(
- ExerciseImportPreviewItem(
- title=ex.title,
- body=ex.body,
- knowledge_code=ex.knowledge_code,
- difficulty=ex.difficulty,
- question_type=ex.question_type,
- correct_answer=ex.correct_answer,
- resolution=ex.resolution,
- )
- )
- total = len(exercises) + len(errors)
- return ExerciseImportPreviewResponse(
- total=total,
- valid=len(exercises),
- invalid=len(errors),
- sample=sample,
- errors=errors,
- )
|