This commit is contained in:
Felix
2026-01-05 11:50:38 +08:00
parent 229174ac5e
commit da958ac8a9
12 changed files with 404 additions and 227 deletions

View File

@@ -84,6 +84,12 @@ async def upload_complete(body: UploadCompleteRequest, request: Request) -> Resp
@router.get("/temp_url/{file_id}", summary="获取临时下载URL", dependencies=[DependsJwtAuth])
async def get_temp_url(file_id: int, request: Request) -> ResponseSchemaModel[dict]:
user_id = getattr(request, 'user', None).id if hasattr(request, 'user') and request.user else None
# 优先尝试从请求链接里获取 ReferrerId 作为 user_id
referrer_id = request.query_params.get('r') or request.query_params.get('ReferrerId')
if referrer_id:
user_id = referrer_id
else:
user_id = getattr(request, 'user', None).id if hasattr(request, 'user') and request.user else None
url = await file_service.get_presigned_download_url(file_id, user_id)
return response_base.success(data={"url": url})

View File

@@ -4,9 +4,12 @@ from fastapi_limiter.depends import RateLimiter
from uuid import uuid4
from backend.app.admin.schema.token import GetWxLoginToken
from backend.app.admin.schema.wx import WxLoginRequest, TokenResponse, UserInfo, UpdateUserSettingsRequest, GetUserSettingsResponse, DictLevel
from backend.app.admin.schema.wx import WxLoginRequest, TokenResponse, UserInfo, UpdateUserSettingsRequest, GetUserSettingsResponse, DictLevel, UpdateAvatarRequest
from backend.app.admin.service.wx_service import wx_service
from backend.app.admin.service.file_service import file_service
from backend.app.admin.crud.wx_user_crud import wx_user_dao
from backend.database.db import async_db_session
from backend.common.response.response_schema import response_base, ResponseSchemaModel
from backend.common.security.jwt import wx_openid_authentication, create_access_token, create_refresh_token, DependsJwtAuth
from backend.app.admin.schema.token import BasicWxUserInfo, WxSettings, PointsBrief
@@ -54,19 +57,20 @@ async def wechat_login(
openid=wx_result.get("openid"),
session_key=wx_result.get("session_key"),
encrypted_data=wx_request.encrypted_data,
iv=wx_request.iv
iv=wx_request.iv,
referrer_id=wx_request.referrer_id,
)
return response_base.success(data=result)
@router.get("/user", summary="获取当前用户(云托管识别)")
async def get_current_user(request: Request, response: Response) -> ResponseSchemaModel[GetWxLoginToken]:
async def get_current_user(request: Request, response: Response, referrer_id: str = None) -> ResponseSchemaModel[GetWxLoginToken]:
openid = request.headers.get("x-wx-openid") or request.headers.get("X-WX-OPENID")
unionid = request.headers.get("x-wx-unionid") or request.headers.get("X-WX-UNIONID")
if not openid:
raise HTTPException(status_code=401, detail="未识别到云托管身份")
user = await wx_openid_authentication(openid, unionid or "")
user = await wx_openid_authentication(openid, unionid or "", referrer_id)
access = await create_access_token(user_id=user.id, multi_login=True)
refresh = await create_refresh_token(access.session_uuid, user.id, True)
@@ -93,11 +97,11 @@ async def get_current_user(request: Request, response: Response) -> ResponseSche
)
settings_obj = WxSettings(dict_level=dict_level)
pts = await points_service.get_user_points(user.id)
points_brief = PointsBrief(
balance=(max(0, (pts.balance or 0) - (pts.frozen_balance or 0)) if pts else 0),
expired_time=pts.expired_time.isoformat() if pts and getattr(pts, "expired_time", None) else None,
)
pts = await points_service.get_user_points(user.id)
points_brief = PointsBrief(
balance=(max(0, (pts.balance or 0) - (pts.frozen_balance or 0)) if pts else 0),
expired_time=pts.expired_time.isoformat() if pts and getattr(pts, "expired_time", None) else None,
)
data = GetWxLoginToken(
access_token=access.access_token,
@@ -112,6 +116,44 @@ async def get_current_user(request: Request, response: Response) -> ResponseSche
)
return response_base.success(data=data)
@router.put("/user/avatar", summary="更新当前用户头像", dependencies=[DependsJwtAuth])
async def update_user_avatar(request: Request, obj: UpdateAvatarRequest) -> ResponseSchemaModel[dict]:
user_id = request.user.id
# 验证文件并获取/刷新下载URL
url = await file_service.get_presigned_download_url(obj.avatar_file_id, user_id)
async with async_db_session.begin() as db:
user = await wx_user_dao.get(db, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
profile = dict(user.profile) if user.profile else {}
profile['avatar_file_id'] = obj.avatar_file_id
await wx_user_dao.update_user_profile(db, user_id, profile)
return response_base.success(data={"url": url})
@router.get("/user/avatar", summary="获取当前用户头像", dependencies=[DependsJwtAuth])
async def get_user_avatar(request: Request) -> ResponseSchemaModel[dict]:
user_id = request.user.id
async with async_db_session.begin() as db:
user = await wx_user_dao.get(db, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
profile = user.profile or {}
avatar_file_id = profile.get('avatar_file_id')
if not avatar_file_id:
return response_base.success(data={"url": None})
try:
# 获取/刷新下载URL
url = await file_service.get_presigned_download_url(int(avatar_file_id), user_id)
return response_base.success(data={"url": url})
except Exception:
return response_base.success(data={"url": None})
# @router.put('/agree-terms', summary='同意服务条款时间', dependencies=[DependsJwtAuth])
# async def agree_terms(request: Request) -> ResponseSchemaModel[dict]:

View File

@@ -26,6 +26,7 @@ class WxLoginRequest(BaseModel):
appid: Optional[str] = Field(None, description="微信 appid")
encrypted_data: Optional[str] = Field(None, description="加密的用户数据")
iv: Optional[str] = Field(None, description="加密算法的初始向量")
referrer_id: Optional[str] = Field(None, description="推荐人ID")
class TokenResponse(BaseModel):
@@ -67,5 +68,9 @@ class UpdateUserSettingsRequest(UserSettings):
pass
class UpdateAvatarRequest(BaseModel):
avatar_file_id: int = Field(..., description="文件ID")
class GetUserSettingsResponse(UserSettings):
pass

View File

@@ -27,7 +27,8 @@ class WxAuthService:
request: Request, response: Response,
openid: str, session_key: str,
encrypted_data: str = None,
iv: str = None
iv: str = None,
referrer_id: str = None
) -> GetWxLoginToken:
"""
处理用户登录逻辑:
@@ -42,14 +43,18 @@ class WxAuthService:
# 查找或创建用户
user = await wx_user_dao.get_by_openid(db, openid)
if not user:
profile = {
'dict_level': DictLevel.LEVEL1.value,
'dict_category': DictCategory.GENERAL.value,
'agree_terms': datetime.now().isoformat(),
}
if referrer_id:
profile['referrer_id'] = referrer_id
user = WxUser(
openid=openid,
session_key=session_key,
profile={
'dict_level': DictLevel.LEVEL1.value,
'dict_category': DictCategory.GENERAL.value,
'agree_terms': datetime.now().isoformat(),
},
profile=profile,
)
await wx_user_dao.add(db, user)
await db.flush()

View File

@@ -131,6 +131,31 @@ async def get_image(
)
return response_base.success(data=result)
@router.get("/{id}/file_id", summary="获取图片File ID", dependencies=[DependsJwtAuth])
async def get_image_file_id(
request: Request,
id: int
) -> ResponseSchemaModel[dict]:
"""
获取图片的File ID
路径参数:
- id: 图片ID
返回:
- id: 图片ID
- file_id: 文件ID
"""
image = await image_service.find_image(id)
if not image:
raise HTTPException(status_code=404, detail="Image not found")
return response_base.success(data={
"id": str(image.id),
"file_id": str(image.file_id)
})
# @router.get("/log")
# def log(request: Request, background_tasks: BackgroundTasks) -> ResponseSchemaModel[dict]:
# audit_log = CreateAuditLogParam(

View File

@@ -40,6 +40,7 @@ async def submit_attempt(request: Request, question_id: int, obj: CreateAttemptR
cloze_options=obj.cloze_options,
file_id=obj.file_id,
session_id=obj.session_id,
is_trial=obj.is_trial,
)
return response_base.success(data=QuestionLatestResultResponse(**res))

View File

@@ -78,7 +78,23 @@ class QaQuestionAttemptCRUD(CRUDPlus[QaQuestionAttempt]):
result = await db.execute(stmt)
return result.scalars().first()
async def get_latest_completed_by_user_question(self, db: AsyncSession, user_id: int, question_id: int) -> Optional[QaQuestionAttempt]:
async def get_latest_valid_by_user_question(self, db: AsyncSession, user_id: int, question_id: int, exclude_trial: bool = False) -> Optional[QaQuestionAttempt]:
# Fetch top 10 to allow filtering in application layer (avoiding JSON query compatibility issues)
stmt = (
select(QaQuestionAttempt)
.where(and_(QaQuestionAttempt.user_id == user_id, QaQuestionAttempt.question_id == question_id))
.order_by(QaQuestionAttempt.id.desc())
.limit(10)
)
result = await db.execute(stmt)
attempts = result.scalars().all()
for a in attempts:
if exclude_trial and (a.ext or {}).get('is_trial'):
continue
return a
return None
async def get_latest_completed_by_user_question(self, db: AsyncSession, user_id: int, question_id: int, exclude_trial: bool = False) -> Optional[QaQuestionAttempt]:
stmt = (
select(QaQuestionAttempt)
.where(
@@ -89,10 +105,18 @@ class QaQuestionAttemptCRUD(CRUDPlus[QaQuestionAttempt]):
)
)
.order_by(QaQuestionAttempt.id.desc())
.limit(1)
.limit(10)
)
result = await db.execute(stmt)
return result.scalars().first()
attempts = result.scalars().all()
if not exclude_trial:
return attempts[0] if attempts else None
for a in attempts:
if (a.ext or {}).get('is_trial'):
continue
return a
return None
qa_exercise_dao = QaExerciseCRUD(QaExercise)

View File

@@ -45,6 +45,7 @@ class CreateAttemptRequest(SchemaBase):
cloze_options: Optional[List[str]] = None
file_id: Optional[int] = None
session_id: Optional[int] = None
is_trial: bool = False
class CreateAttemptTaskResponse(SchemaBase):

View File

@@ -23,6 +23,7 @@ from backend.common.const import EXERCISE_TYPE_CHOICE, EXERCISE_TYPE_CLOZE, EXER
from backend.app.admin.schema.wx import DictLevel
from backend.app.ai.service.image_task_service import TaskProcessor, image_task_service
from backend.app.ai.model.image_task import ImageProcessingTask
from backend.app.ai.model.qa import QaQuestion
class QaExerciseProcessor(TaskProcessor):
async def process(self, db: AsyncSession, task: ImageProcessingTask) -> Tuple[Dict[str, Any], Dict[str, Any]]:
@@ -242,11 +243,137 @@ class QaService:
}
return ret
async def submit_attempt(self, question_id: int, exercise_id: int, user_id: int, mode: str, selected_options: Optional[List[str]] = None, input_text: Optional[str] = None, cloze_options: Optional[List[str]] = None, file_id: Optional[int] = None, session_id: Optional[int] = None) -> Dict[str, Any]:
def _evaluate_choice(self, q: QaQuestion, selected_options: List[str]) -> Tuple[Dict[str, Any], str, List[str]]:
ext = q.ext or {}
raw_correct = ext.get('correct_options') or []
raw_incorrect = ext.get('incorrect_options') or []
def _norm(v):
try:
return str(v).strip().lower()
except Exception:
return str(v)
correct_set = set(_norm(o.get('content') if isinstance(o, dict) else o) for o in raw_correct)
incorrect_map = {}
for o in raw_incorrect:
c = _norm(o.get('content') if isinstance(o, dict) else o)
if isinstance(o, dict):
incorrect_map[c] = {
'content': o.get('content'),
'error_type': o.get('error_type'),
'error_reason': o.get('error_reason')
}
else:
incorrect_map[c] = {'content': o, 'error_type': None, 'error_reason': None}
selected_list = list(selected_options or [])
selected = set(_norm(s) for s in selected_list)
if not selected:
is_correct = 'incorrect'
result_text = '完全错误'
evaluation = {'type': 'choice', 'result': result_text, 'detail': 'no selection', 'selected': {'correct': [], 'incorrect': []}, 'missing_correct': [o.get('content') if isinstance(o, dict) else o for o in raw_correct]}
else:
selected_correct = []
for o in raw_correct:
c = _norm(o.get('content') if isinstance(o, dict) else o)
if c in selected:
selected_correct.append(o.get('content') if isinstance(o, dict) else o)
selected_incorrect = []
for s in selected_list:
ns = _norm(s)
if ns not in correct_set:
detail = incorrect_map.get(ns)
if detail:
selected_incorrect.append(detail)
else:
selected_incorrect.append({'content': s, 'error_type': 'unknown', 'error_reason': None})
missing_correct = []
for o in raw_correct:
c = _norm(o.get('content') if isinstance(o, dict) else o)
if c not in selected:
missing_correct.append(o.get('content') if isinstance(o, dict) else o)
if selected == correct_set and not selected_incorrect:
is_correct = 'correct'
result_text = '完全匹配'
evaluation = {'type': 'choice', 'result': result_text, 'detail': is_correct, 'selected': {'correct': selected_correct, 'incorrect': []}, 'missing_correct': []}
elif selected_correct:
is_correct = 'partial'
result_text = '部分匹配'
evaluation = {'type': 'choice', 'result': result_text, 'detail': is_correct, 'selected': {'correct': selected_correct, 'incorrect': selected_incorrect}, 'missing_correct': missing_correct}
else:
is_correct = 'incorrect'
result_text = '完全错误'
evaluation = {'type': 'choice', 'result': result_text, 'detail': is_correct, 'selected': {'correct': [], 'incorrect': selected_incorrect}, 'missing_correct': [o.get('content') if isinstance(o, dict) else o for o in raw_correct]}
return evaluation, is_correct, selected_list
def _evaluate_cloze(self, q: QaQuestion, cloze_options: List[str]) -> Tuple[Dict[str, Any], str, List[str]]:
ext = q.ext or {}
cloze = ext.get('cloze') or {}
correct_word = cloze.get('correct_word')
# Support multiple selections: treat as correct if any selected matches a correct answer
selection_list = [s for s in cloze_options if isinstance(s, str) and s.strip()]
def _norm(v):
try:
return str(v).strip().lower()
except Exception:
return str(v)
# correct answers may be a single string or a list
correct_candidates = []
if isinstance(correct_word, list):
correct_candidates = [cw for cw in correct_word if isinstance(cw, str) and cw.strip()]
elif isinstance(correct_word, str) and correct_word.strip():
correct_candidates = [correct_word]
correct_set = set(_norm(cw) for cw in correct_candidates)
user_correct = []
user_incorrect = []
for s in selection_list:
if _norm(s) in correct_set:
user_correct.append(s)
else:
user_incorrect.append({'content': s, 'error_type': None, 'error_reason': None})
if user_correct and not user_incorrect:
is_correct = 'correct'
result_text = '完全匹配'
evaluation = {'type': 'cloze', 'result': result_text, 'detail': is_correct, 'selected': {'correct': user_correct, 'incorrect': []}, 'missing_correct': []}
elif user_correct:
is_correct = 'partial'
result_text = '部分匹配'
evaluation = {'type': 'cloze', 'result': result_text, 'detail': is_correct, 'selected': {'correct': user_correct, 'incorrect': user_incorrect}, 'missing_correct': []}
else:
is_correct = 'incorrect'
result_text = '完全错误'
evaluation = {'type': 'cloze', 'result': result_text, 'detail': is_correct, 'selected': {'correct': [], 'incorrect': user_incorrect}, 'missing_correct': [cw for cw in correct_candidates]}
return evaluation, is_correct, selection_list
async def submit_attempt(self, question_id: int, exercise_id: int, user_id: int, mode: str, selected_options: Optional[List[str]] = None, input_text: Optional[str] = None, cloze_options: Optional[List[str]] = None, file_id: Optional[int] = None, session_id: Optional[int] = None, is_trial: bool = False) -> Dict[str, Any]:
async with async_db_session.begin() as db:
q = await qa_question_dao.get(db, question_id)
if not q or q.exercise_id != exercise_id:
raise errors.NotFoundError(msg='Question not found')
# Optimization: If trial mode and synchronous evaluation (Choice/Cloze), skip DB persistence
if is_trial:
if mode == EXERCISE_TYPE_CHOICE:
evaluation, _, selected_list = self._evaluate_choice(q, selected_options)
return {
'session_id': None,
'type': 'choice',
'choice': {
'options': selected_list,
'evaluation': evaluation
}
}
elif mode == EXERCISE_TYPE_CLOZE:
evaluation, _, selection_list = self._evaluate_cloze(q, cloze_options)
return {
'session_id': None,
'type': 'cloze',
'cloze': {
'input': selection_list,
'evaluation': evaluation
}
}
recording_id = None
attempt = await qa_attempt_dao.get_latest_by_user_question(db, user_id=user_id, question_id=question_id)
if attempt:
@@ -258,6 +385,10 @@ class QaService:
ext0 = attempt.ext or {}
if session_id:
ext0['session_id'] = session_id
if is_trial:
ext0['is_trial'] = True
elif 'is_trial' in ext0:
del ext0['is_trial']
attempt.ext = ext0
await db.flush()
else:
@@ -272,38 +403,39 @@ class QaService:
'input_text': input_text if mode == EXERCISE_TYPE_FREE_TEXT else None,
'status': 'pending',
'evaluation': None,
'ext': None,
'ext': {'is_trial': True} if is_trial else None,
})
s = await qa_session_dao.get_latest_by_user_exercise(db, user_id, exercise_id)
if s and s.exercise_id == exercise_id:
prog = dict(s.progress or {})
attempts = list(prog.get('attempts') or [])
replaced = False
for idx, a in enumerate(attempts):
if a.get('question_id') == question_id and a.get('mode') == mode:
attempts[idx] = {
if not is_trial:
s = await qa_session_dao.get_latest_by_user_exercise(db, user_id, exercise_id)
if s and s.exercise_id == exercise_id:
prog = dict(s.progress or {})
attempts = list(prog.get('attempts') or [])
replaced = False
for idx, a in enumerate(attempts):
if a.get('question_id') == question_id and a.get('mode') == mode:
attempts[idx] = {
'attempt_id': attempt.id,
'question_id': str(question_id),
'mode': mode,
'created_at': datetime.now().isoformat(),
'is_correct': a.get('is_correct'),
}
replaced = True
break
if not replaced:
attempts.append({
'attempt_id': attempt.id,
'question_id': str(question_id),
'mode': mode,
'created_at': datetime.now().isoformat(),
'is_correct': a.get('is_correct'),
}
replaced = True
break
if not replaced:
attempts.append({
'attempt_id': attempt.id,
'question_id': str(question_id),
'mode': mode,
'created_at': datetime.now().isoformat(),
'is_correct': None,
})
prog['answered'] = int(prog.get('answered') or 0) + 1
prog['attempts'] = attempts
s.progress = prog
attempt.ext = {**(attempt.ext or {}), 'session_id': s.id}
await db.flush()
'is_correct': None,
})
prog['answered'] = int(prog.get('answered') or 0) + 1
prog['attempts'] = attempts
s.progress = prog
attempt.ext = {**(attempt.ext or {}), 'session_id': s.id}
await db.flush()
if mode == EXERCISE_TYPE_FREE_TEXT:
attempt.ext = {**(attempt.ext or {}), 'type': 'free_text', 'free_text': {'text': attempt.input_text or '', 'evaluation': None}}
@@ -330,165 +462,89 @@ class QaService:
}
# Synchronous evaluation for choice/cloze
if mode == EXERCISE_TYPE_CHOICE:
ext = q.ext or {}
raw_correct = ext.get('correct_options') or []
raw_incorrect = ext.get('incorrect_options') or []
def _norm(v):
try:
return str(v).strip().lower()
except Exception:
return str(v)
correct_set = set(_norm(o.get('content') if isinstance(o, dict) else o) for o in raw_correct)
incorrect_map = {}
for o in raw_incorrect:
c = _norm(o.get('content') if isinstance(o, dict) else o)
if isinstance(o, dict):
incorrect_map[c] = {
'content': o.get('content'),
'error_type': o.get('error_type'),
'error_reason': o.get('error_reason')
}
else:
incorrect_map[c] = {'content': o, 'error_type': None, 'error_reason': None}
selected_list = list(attempt.choice_options or [])
selected = set(_norm(s) for s in selected_list)
if not selected:
is_correct = 'incorrect'
result_text = '完全错误'
evaluation = {'type': 'choice', 'result': result_text, 'detail': 'no selection', 'selected': {'correct': [], 'incorrect': []}, 'missing_correct': [o.get('content') if isinstance(o, dict) else o for o in raw_correct]}
# update ext with choice details
attempt.ext = {**(attempt.ext or {}), 'type': 'choice', 'choice': {'options': selected_list, 'evaluation': evaluation}}
await db.flush()
merged_eval = dict(attempt.evaluation or {})
merged_eval['choice'] = {'options': selected_list, 'evaluation': evaluation}
await qa_attempt_dao.update_status(db, attempt.id, 'completed', merged_eval)
else:
selected_correct = []
for o in raw_correct:
c = _norm(o.get('content') if isinstance(o, dict) else o)
if c in selected:
selected_correct.append(o.get('content') if isinstance(o, dict) else o)
selected_incorrect = []
for s in selected_list:
ns = _norm(s)
if ns not in correct_set:
detail = incorrect_map.get(ns)
if detail:
selected_incorrect.append(detail)
else:
selected_incorrect.append({'content': s, 'error_type': 'unknown', 'error_reason': None})
missing_correct = []
for o in raw_correct:
c = _norm(o.get('content') if isinstance(o, dict) else o)
if c not in selected:
missing_correct.append(o.get('content') if isinstance(o, dict) else o)
if selected == correct_set and not selected_incorrect:
is_correct = 'correct'
result_text = '完全匹配'
evaluation = {'type': 'choice', 'result': result_text, 'detail': is_correct, 'selected': {'correct': selected_correct, 'incorrect': []}, 'missing_correct': []}
elif selected_correct:
is_correct = 'partial'
result_text = '部分匹配'
evaluation = {'type': 'choice', 'result': result_text, 'detail': is_correct, 'selected': {'correct': selected_correct, 'incorrect': selected_incorrect}, 'missing_correct': missing_correct}
else:
is_correct = 'incorrect'
result_text = '完全错误'
evaluation = {'type': 'choice', 'result': result_text, 'detail': is_correct, 'selected': {'correct': [], 'incorrect': selected_incorrect}, 'missing_correct': [o.get('content') if isinstance(o, dict) else o for o in raw_correct]}
# update ext with choice details
attempt.ext = {**(attempt.ext or {}), 'type': 'choice', 'choice': {'options': selected_list, 'evaluation': evaluation}}
await db.flush()
merged_eval = dict(attempt.evaluation or {})
merged_eval['choice'] = {'options': selected_list, 'evaluation': evaluation}
await qa_attempt_dao.update_status(db, attempt.id, 'completed', merged_eval)
s = await qa_session_dao.get_latest_by_user_exercise(db, user_id, exercise_id)
if s and s.exercise_id == attempt.exercise_id:
prog = dict(s.progress or {})
attempts = list(prog.get('attempts') or [])
prev = None
for a in attempts:
if a.get('attempt_id') == attempt.id:
prev = a.get('is_correct')
a['is_correct'] = is_correct
break
prev_correct = 1 if prev == 'correct' else 0
new_correct = 1 if is_correct == 'correct' else 0
correct_inc = new_correct - prev_correct
prog['attempts'] = attempts
prog['correct'] = int(prog.get('correct') or 0) + correct_inc
s.progress = prog
await db.flush()
await db.commit()
# return latest result structure
return await self.get_question_evaluation(question_id, user_id)
if mode == EXERCISE_TYPE_CLOZE:
ext = q.ext or {}
cloze = ext.get('cloze') or {}
correct_word = cloze.get('correct_word')
distractors = cloze.get('distractor_words') or []
# Support multiple selections: treat as correct if any selected matches a correct answer
selection_list = cloze_options or ([attempt.cloze_options] if attempt.cloze_options else ([attempt.input_text] if attempt.input_text else []))
selection_list = [s for s in selection_list if isinstance(s, str) and s.strip()]
user_text_first = (selection_list[0] if selection_list else '').strip()
def _norm(v):
try:
return str(v).strip().lower()
except Exception:
return str(v)
# correct answers may be a single string or a list
correct_candidates = []
if isinstance(correct_word, list):
correct_candidates = [cw for cw in correct_word if isinstance(cw, str) and cw.strip()]
elif isinstance(correct_word, str) and correct_word.strip():
correct_candidates = [correct_word]
correct_set = set(_norm(cw) for cw in correct_candidates)
user_correct = []
user_incorrect = []
for s in selection_list:
if _norm(s) in correct_set:
user_correct.append(s)
else:
user_incorrect.append({'content': s, 'error_type': None, 'error_reason': None})
if user_correct and not user_incorrect:
is_correct = 'correct'
result_text = '完全匹配'
evaluation = {'type': 'cloze', 'result': result_text, 'detail': is_correct, 'selected': {'correct': user_correct, 'incorrect': []}, 'missing_correct': []}
elif user_correct:
is_correct = 'partial'
result_text = '部分匹配'
evaluation = {'type': 'cloze', 'result': result_text, 'detail': is_correct, 'selected': {'correct': user_correct, 'incorrect': user_incorrect}, 'missing_correct': []}
else:
is_correct = 'incorrect'
result_text = '完全错误'
mc = correct_candidates if correct_candidates else []
evaluation = {'type': 'cloze', 'result': result_text, 'detail': is_correct, 'selected': {'correct': [], 'incorrect': user_incorrect}, 'missing_correct': mc}
# update ext with cloze details
attempt.ext = {**(attempt.ext or {}), 'type': 'cloze', 'cloze': {'input': attempt.cloze_options or user_text_first, 'evaluation': evaluation}}
evaluation, is_correct, selected_list = self._evaluate_choice(q, attempt.choice_options)
# update ext with choice details
attempt.ext = {**(attempt.ext or {}), 'type': 'choice', 'choice': {'options': selected_list, 'evaluation': evaluation}}
await db.flush()
merged_eval = dict(attempt.evaluation or {})
merged_eval['cloze'] = {'input': attempt.cloze_options or user_text_first, 'evaluation': evaluation}
merged_eval['choice'] = {'options': selected_list, 'evaluation': evaluation}
await qa_attempt_dao.update_status(db, attempt.id, 'completed', merged_eval)
s = await qa_session_dao.get_latest_by_user_exercise(db, user_id, exercise_id)
if s and s.exercise_id == attempt.exercise_id:
prog = dict(s.progress or {})
attempts = list(prog.get('attempts') or [])
prev = None
for a in attempts:
if a.get('attempt_id') == attempt.id:
prev = a.get('is_correct')
a['is_correct'] = is_correct
break
prev_correct = 1 if prev == 'correct' else 0
new_correct = 1 if is_correct == 'correct' else 0
correct_inc = new_correct - prev_correct
prog['attempts'] = attempts
prog['correct'] = int(prog.get('correct') or 0) + correct_inc
s.progress = prog
await db.flush()
if not is_trial:
s = await qa_session_dao.get_latest_by_user_exercise(db, user_id, exercise_id)
if s and s.exercise_id == attempt.exercise_id:
prog = dict(s.progress or {})
attempts = list(prog.get('attempts') or [])
prev = None
for a in attempts:
if a.get('attempt_id') == attempt.id:
prev = a.get('is_correct')
a['is_correct'] = is_correct
break
prev_correct = 1 if prev == 'correct' else 0
new_correct = 1 if is_correct == 'correct' else 0
correct_inc = new_correct - prev_correct
prog['attempts'] = attempts
prog['correct'] = int(prog.get('correct') or 0) + correct_inc
s.progress = prog
await db.flush()
await db.commit()
# return latest result structure
return await self.get_question_evaluation(question_id, user_id)
session_id_val = (attempt.ext or {}).get('session_id')
return {
'session_id': str(session_id_val) if session_id_val is not None else None,
'type': 'choice',
'choice': {
'options': selected_list,
'evaluation': evaluation
}
}
if mode == EXERCISE_TYPE_CLOZE:
c_opts = attempt.cloze_options or []
if not c_opts and attempt.input_text:
c_opts = [attempt.input_text]
if cloze_options:
c_opts = cloze_options
evaluation, is_correct, selection_list = self._evaluate_cloze(q, c_opts)
# update ext with cloze details
attempt.ext = {**(attempt.ext or {}), 'type': 'cloze', 'cloze': {'input': selection_list, 'evaluation': evaluation}}
await db.flush()
merged_eval = dict(attempt.evaluation or {})
merged_eval['cloze'] = {'input': selection_list, 'evaluation': evaluation}
await qa_attempt_dao.update_status(db, attempt.id, 'completed', merged_eval)
if not is_trial:
s = await qa_session_dao.get_latest_by_user_exercise(db, user_id, exercise_id)
if s and s.exercise_id == attempt.exercise_id:
prog = dict(s.progress or {})
attempts = list(prog.get('attempts') or [])
prev = None
for a in attempts:
if a.get('attempt_id') == attempt.id:
prev = a.get('is_correct')
a['is_correct'] = is_correct
break
prev_correct = 1 if prev == 'correct' else 0
new_correct = 1 if is_correct == 'correct' else 0
correct_inc = new_correct - prev_correct
prog['attempts'] = attempts
prog['correct'] = int(prog.get('correct') or 0) + correct_inc
s.progress = prog
await db.flush()
await db.commit()
# return latest result structure
session_id_val = (attempt.ext or {}).get('session_id')
return {
'session_id': str(session_id_val) if session_id_val is not None else None,
'type': 'cloze',
'cloze': {
'input': selection_list,
'evaluation': evaluation
}
}
async def _process_attempt_evaluation(self, task_id: int, user_id: int):
async with background_db_session() as db:
@@ -501,6 +557,9 @@ class QaService:
await image_task_dao.update_task_status(db, task_id, ImageTaskStatus.FAILED, error_message='Attempt not found')
await db.commit()
return
is_trial = (attempt.ext or {}).get('is_trial', False)
# Only async evaluation for free_text/audio attempts
q = await qa_question_dao.get(db, attempt.question_id)
user_text = attempt.input_text or ''
@@ -527,23 +586,25 @@ class QaService:
merged_eval['free_text'] = {'text': attempt.input_text or '', 'evaluation': evaluation}
await qa_attempt_dao.update_status(db, attempt.id, 'completed', merged_eval)
await image_task_dao.update_task_status(db, task_id, ImageTaskStatus.COMPLETED, result={'mode': 'free_text', 'token_usage': res.get('token_usage') or {}})
s = await qa_session_dao.get_latest_by_user_exercise(db, user_id, attempt.exercise_id)
if s and s.exercise_id == attempt.exercise_id:
prog = dict(s.progress or {})
attempts = list(prog.get('attempts') or [])
prev = None
for a in attempts:
if a.get('attempt_id') == attempt.id:
prev = a.get('is_correct')
a['is_correct'] = parsed.get('is_correct')
break
prev_correct = 1 if prev == 'correct' else 0
new_correct = 1 if parsed.get('is_correct') == 'correct' else 0
correct_inc = new_correct - prev_correct
prog['attempts'] = attempts
prog['correct'] = int(prog.get('correct') or 0) + correct_inc
s.progress = prog
await db.flush()
if not is_trial:
s = await qa_session_dao.get_latest_by_user_exercise(db, user_id, attempt.exercise_id)
if s and s.exercise_id == attempt.exercise_id:
prog = dict(s.progress or {})
attempts = list(prog.get('attempts') or [])
prev = None
for a in attempts:
if a.get('attempt_id') == attempt.id:
prev = a.get('is_correct')
a['is_correct'] = parsed.get('is_correct')
break
prev_correct = 1 if prev == 'correct' else 0
new_correct = 1 if parsed.get('is_correct') == 'correct' else 0
correct_inc = new_correct - prev_correct
prog['attempts'] = attempts
prog['correct'] = int(prog.get('correct') or 0) + correct_inc
s.progress = prog
await db.flush()
await db.commit()
async def _call_llm_chat(self, prompt: str, image_id: int, user_id: int, chat_type: str) -> Dict[str, Any]:
@@ -572,9 +633,10 @@ class QaService:
async def get_question_evaluation(self, question_id: int, user_id: int) -> Dict[str, Any]:
async with async_db_session() as db:
latest = await qa_attempt_dao.get_latest_completed_by_user_question(db, user_id=user_id, question_id=question_id)
# Exclude trial attempts by default so they don't pollute normal mode history
latest = await qa_attempt_dao.get_latest_completed_by_user_question(db, user_id=user_id, question_id=question_id, exclude_trial=True)
if not latest:
latest = await qa_attempt_dao.get_latest_by_user_question(db, user_id=user_id, question_id=question_id)
latest = await qa_attempt_dao.get_latest_valid_by_user_question(db, user_id=user_id, question_id=question_id, exclude_trial=True)
if not latest:
return {}
evalution = latest.evaluation or {}

View File

@@ -83,7 +83,7 @@ class SentenceService:
)
if mode == SENTENCE_TYPE_SCENE_SENTENCE:
base = (
"你是英语教育场景的专业助手,需基于给定的图片场景信息和基础内容,扩展生成适配英语进阶学习者的[场景句型]结构化内容,所有内容需贴合场景、功能导向,无语义重复,且符合日常沟通逻辑\n"
"你是英语教育场景的专业助手,需基于给定的图片场景信息和基础内容,扩展生成适配英语进阶学习者的[场景句型]结构化内容,所有内容需贴合场景、功能导向,无语义重复,简洁清晰,准确务实,且符合外国人日常口语沟通习惯\n"
"输入信息如下JSON\n"
f"{json.dumps(payload, ensure_ascii=False)}\n"
"输出要求:\n"

View File

@@ -32,7 +32,7 @@ from backend.utils.timezone import timezone
from backend.common.log import log as logger
async def wx_openid_authentication(openid: str, unionid: str) -> GetWxUserInfoWithRelationDetail:
async def wx_openid_authentication(openid: str, unionid: str, referrer_id: str = None) -> GetWxUserInfoWithRelationDetail:
from backend.app.admin.crud.wx_user_crud import wx_user_dao
async with async_db_session.begin() as db:
user = None
@@ -41,14 +41,18 @@ async def wx_openid_authentication(openid: str, unionid: str) -> GetWxUserInfoWi
user = await wx_user_dao.get_by_openid(db, openid)
if not user:
session_key = snowflake.generate()
profile = {
'dict_level': DictLevel.LEVEL1.value,
'dict_category': DictCategory.GENERAL.value
}
if referrer_id:
profile['referrer_id'] = referrer_id
user = WxUser(
openid=openid,
unionid=unionid,
session_key=session_key,
profile={
'dict_level': DictLevel.LEVEL1.value,
'dict_category': DictCategory.GENERAL.value
},
profile=profile,
)
await wx_user_dao.add(db, user)
await db.flush()
@@ -86,7 +90,8 @@ class CustomHTTPBearer(HTTPBearer):
await redis_client.delete(f'wx_openid_token:{wx_openid}')
# If no cached token or invalid token, authenticate the user
user = await wx_openid_authentication(wx_openid, wx_unionid)
referrer_id = request.query_params.get('referrer_id') or request.headers.get('x-referrer-id')
user = await wx_openid_authentication(wx_openid, wx_unionid, referrer_id)
if user:
# Create a new token using unified storage
access = await create_access_token(user_id=user.id, multi_login=True)

View File

@@ -62,7 +62,8 @@ class JwtAuthMiddleware(AuthenticationBackend):
if not wx_unionid:
wx_unionid = request.headers.get('X-WX-UNIONID')
if wx_openid:
user = await wx_openid_authentication(wx_openid, wx_unionid)
referrer_id = request.query_params.get('referrer_id') or request.headers.get('x-referrer-id')
user = await wx_openid_authentication(wx_openid, wx_unionid, referrer_id)
if user:
return AuthCredentials(['authenticated']), user