"""资料导入候选抽取用例(异步后台任务,跨 services + ai_provider)。 触发端点在校验并置 job 为 ``extracting_*`` 后即返回;实际模型调用在后台任务中 进行(独立 session),完成后落候选并置 job 为 ``reviewing`` / ``parsed``。 仅适用单进程;多 worker 部署需替换为跨进程任务队列。 """ import asyncio from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.ai_provider import ProviderManager, ProviderManagerFactory from app.core.logging import logger from app.db.session import AsyncSessionLocal from app.models.candidates import MemoryCandidate, ProfileCandidate from app.models.imports import CharacterImportJob, ImportItem from app.models.personality import PersonalityProposal from app.modules.imports.extract import ( parse_memory_candidates, parse_personality_proposals, parse_profile_candidates, ) from app.modules.imports.prompts import ( build_memory_extraction_messages, build_personality_extraction_messages, build_profile_extraction_messages, ) from app.repositories import candidates as candidates_repo from app.repositories import imports as imports_repo from app.repositories.json_utils import dumps # 后台抽取任务强引用:asyncio.create_task 仅保留弱引用,需强引用防 GC。 _running_extraction_tasks: set[asyncio.Task[None]] = set() _provider_manager: ProviderManager | None = None def _get_provider_manager() -> ProviderManager: global _provider_manager if _provider_manager is None: _provider_manager = ProviderManagerFactory.create() return _provider_manager _PROFILE_MODEL_HINT = "personality-analysis" _MEMORY_MODEL_HINT = "memory-extraction" _PERSONALITY_MODEL_HINT = "personality-analysis" async def drain_pending_extractions() -> None: """等待所有进行中的后台抽取任务完成(测试断言 / 优雅停机用)。""" if _running_extraction_tasks: await asyncio.gather(*_running_extraction_tasks, return_exceptions=True) def start_profile_extraction(job_id: str) -> None: task = asyncio.create_task(_run_profile_extraction(job_id)) _running_extraction_tasks.add(task) task.add_done_callback(_running_extraction_tasks.discard) def start_memory_extraction(job_id: str) -> None: task = asyncio.create_task(_run_memory_extraction(job_id)) _running_extraction_tasks.add(task) task.add_done_callback(_running_extraction_tasks.discard) def start_personality_extraction(job_id: str) -> None: task = asyncio.create_task(_run_personality_extraction(job_id)) _running_extraction_tasks.add(task) task.add_done_callback(_running_extraction_tasks.discard) async def _load_job_and_items( session: AsyncSession, job_id: str ) -> tuple[CharacterImportJob | None, list[ImportItem]]: job = await imports_repo.get_job(session, job_id) if job is None: return None, [] result = await session.execute( select(ImportItem) .where(ImportItem.import_job_id == job_id) .order_by(ImportItem.sequence.asc()) ) return job, list(result.scalars().all()) async def _run_profile_extraction(job_id: str) -> None: async with AsyncSessionLocal() as session: try: job, items = await _load_job_and_items(session, job_id) if job is None: return system, user = build_profile_extraction_messages(_pairs(items)) raw = await _call_model(system, user, _PROFILE_MODEL_HINT, job.character_id) candidates = [ ProfileCandidate( character_id=job.character_id, import_job_id=job.id, field=draft.field_name, value=draft.value, confidence=draft.confidence, reason=draft.reason, status="pending", evidence_ids_json=dumps(_evidence_ids(items, draft.evidence)), ) for draft in parse_profile_candidates(raw) ] await candidates_repo.add_profile_candidates(session, candidates) job.status = "reviewing" if candidates else "parsed" await session.commit() except Exception as exc: await _fail_job(session, job_id, exc) async def _run_memory_extraction(job_id: str) -> None: async with AsyncSessionLocal() as session: try: job, items = await _load_job_and_items(session, job_id) if job is None: return system, user = build_memory_extraction_messages(_pairs(items)) raw = await _call_model(system, user, _MEMORY_MODEL_HINT, job.character_id) candidates = [ MemoryCandidate( character_id=job.character_id, import_job_id=job.id, type=draft.type, title=draft.title, content=draft.content, confidence=0.6, importance=draft.importance, sensitivity=draft.sensitivity, reason=draft.reason, status="pending", evidence_ids_json=dumps(_evidence_ids(items, draft.evidence)), ) for draft in parse_memory_candidates(raw) ] await candidates_repo.add_memory_candidates(session, candidates) job.status = "reviewing" if candidates else "parsed" await session.commit() except Exception as exc: await _fail_job(session, job_id, exc) async def _run_personality_extraction(job_id: str) -> None: async with AsyncSessionLocal() as session: try: job, items = await _load_job_and_items(session, job_id) if job is None: return system, user = build_personality_extraction_messages(_pairs(items), "") raw = await _call_model(system, user, _PERSONALITY_MODEL_HINT, job.character_id) proposals = [ PersonalityProposal( character_id=job.character_id, fields_json=dumps({draft.field_name: draft.change}), reason=draft.reason or "资料导入抽取的人格完善建议", status="pending", ) for draft in parse_personality_proposals(raw) ] session.add_all(proposals) await session.flush() job.status = "reviewing" if proposals else "parsed" await session.commit() except Exception as exc: await _fail_job(session, job_id, exc) def _pairs(items: list[ImportItem]) -> list[tuple[str, str]]: return [(item.role, item.content) for item in items] def _evidence_ids(items: list[ImportItem], indices: list[int]) -> list[str]: return [items[i].id for i in indices if 0 <= i < len(items)] async def _call_model(system: str, user: str, model_hint: str, _character_id: str) -> str: manager = _get_provider_manager() messages = manager.to_message_list([system], [("user", user)]) response = await manager.chat(messages, model_hint=model_hint) return response.content async def _fail_job(session: AsyncSession, job_id: str, exc: Exception) -> None: logger.error(f"导入候选抽取失败 job={job_id}: {exc}") try: job = await imports_repo.get_job(session, job_id) if job is not None: job.status = "failed" job.error_code = "extraction_failed" job.error_message = str(exc)[:500] await session.commit() except Exception: # noqa: BLE001 — 失败处理本身不能再抛 pass