fix code
This commit is contained in:
@@ -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})
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user