M3a:息壤三级记忆域——归真触发提取管线/白名单明忆/重忆确认/正影版本链/删除墓碑/双通道召回注入,退役旧记忆-抽取-反思-大周天-演化管线(迁移 004,验收 7 用例)

This commit is contained in:
chenxin committed 2026-08-27 09:16:32 +08:00
1 parent 2a640a901b
commit 9c98c2f85e
69 files changed
+1193 -6033

No files matched your search

+2 -2
View File
@@ -21,7 +21,7 @@ from app.models.governance import (
WorldEntity,
)
from app.models.idempotency import IdempotencyKey
from app.models.memory import Memory
from app.models.xirang import Memory
from app.models.runtime import Event, Run
from app.models.safety import AuditEvent
from app.modules.safety.confirmation import ConfirmationStore, PendingConfirmation
@@ -77,7 +77,7 @@ async def get_pivots_health(
plans_n = await governance_repo.count(session, Plan)
perception_n = await governance_repo.count(session, PerceptionChannel)
agents_n = await governance_repo.count(session, AgentRosterEntry)
memories_n = await governance_repo.count(session, Memory)
memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
character = await character_repo.get_active_character(session, owner_scope)
has_personality = bool(character and character.current_personality_version_id)
tool_enabled = settings.enable_tool_runtime and tool_runtime is not None
-205
View File
@@ -1,205 +0,0 @@
"""演化候选派生与审核服务。
applier 注册表按 ``type`` 派生到下游(本期 personality→人格版本、procedural→
程序记忆,其余返回 None 降级)。审核闭环 approve/reject 复用派生能力,自管
session 并记审计(见 [大周天演化候选落地设计]
../docs/domain/core-domains.md §6-§7)。
"""
from collections.abc import Awaitable, Callable
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.evolution import EvolutionCandidate
from app.models.memory import Memory
from app.repositories import evolution as evolution_repo
from app.repositories import personality as personality_repo
from app.repositories.json_utils import loads_dict, loads_list
from app.schemas.common import Page
from app.schemas.evolution import EvolutionCandidateResponse
from app.services.audit import record_audit_event
from app.services.personality import derive_personality_version
Applier = Callable[..., Awaitable[str]]
async def apply_evolution_candidate(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str = "system"
) -> str | None:
"""应用演化候选到下游;返回派生资源 ID,无 applier 时返回 None(降级)。"""
applier = _APPLIERS.get(candidate.type)
if applier is None:
return None
return await applier(session, candidate, actor=actor)
async def _apply_personality(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str
) -> str:
base = await personality_repo.get_latest_personality(session, candidate.character_id)
if base is None:
raise AppError("invalid_request", "人物尚无人格版本,无法应用演化候选。")
overrides = loads_dict(candidate.delta_json)
new_version = derive_personality_version(
base, overrides, reason=f"evolution:{candidate.id}", created_by="big_cycle"
)
session.add(new_version)
await session.flush()
character = await session.get(Character, candidate.character_id)
if character is None:
raise AppError("not_found", "人物不存在。")
character.current_personality_version_id = new_version.id
await _record_apply(
session, candidate, f"type=personality version={new_version.id}", actor=actor
)
return new_version.id
async def _apply_procedural(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str
) -> str:
delta = loads_dict(candidate.delta_json)
memory = Memory(
character_id=candidate.character_id,
type="procedural",
title=str(delta.get("title") or candidate.title),
content=str(delta.get("content") or candidate.proposal),
source="big_cycle",
importance=candidate.confidence,
)
session.add(memory)
await session.flush()
await _record_apply(session, candidate, f"type=procedural memory={memory.id}", actor=actor)
return memory.id
async def list_evolution_candidates(
*,
character_id: str,
status: str | None = None,
candidate_type: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[EvolutionCandidateResponse]:
async with AsyncSessionLocal() as session:
items, total = await evolution_repo.list_candidates(
session,
character_id,
status=status,
candidate_type=candidate_type,
page=page,
page_size=page_size,
)
return Page(
items=[_to_response(item) for item in items],
page=page,
page_size=page_size,
total=total,
)
async def approve_evolution_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await evolution_repo.get_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "演化候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
target_id = await apply_evolution_candidate(session, candidate, actor="user")
if target_id is None:
raise AppError("not_implemented", "该类型派生待后续阶段。")
candidate.status = "applied"
candidate.resulting_target_id = target_id
candidate.reviewed_at = utc_now()
candidate.reviewed_by = "user"
await session.commit()
await session.refresh(candidate)
return _to_response(candidate)
async def reject_evolution_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await evolution_repo.get_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "演化候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
candidate.status = "rejected"
candidate.reviewed_at = utc_now()
candidate.reviewed_by = "user"
await record_audit_event(
session,
action="evolution.candidate.rejected",
actor="user",
risk_level=candidate.level,
decision="denied",
character_id=candidate.character_id,
input_summary=candidate.proposal[:200],
resource_type="evolution_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return _to_response(candidate)
def _to_response(candidate: EvolutionCandidate) -> EvolutionCandidateResponse:
return EvolutionCandidateResponse(
id=candidate.id,
character_id=candidate.character_id,
cycle_id=candidate.cycle_id,
type=candidate.type,
source=candidate.source,
level=candidate.level,
scope=candidate.scope,
title=candidate.title,
proposal=candidate.proposal,
delta=loads_dict(candidate.delta_json),
evidence_ids=loads_list(candidate.evidence_ids_json),
confidence=candidate.confidence,
benefit_score=candidate.benefit_score,
risk_score=candidate.risk_score,
drift_score=candidate.drift_score,
auto_apply_allowed=candidate.auto_apply_allowed,
status=candidate.status,
resulting_target_id=candidate.resulting_target_id,
error_message=candidate.error_message,
reviewed_at=candidate.reviewed_at,
reviewed_by=candidate.reviewed_by,
created_at=candidate.created_at,
)
async def _record_apply(
session: AsyncSession,
candidate: EvolutionCandidate,
output_summary: str,
*,
actor: str,
) -> None:
await record_audit_event(
session,
action="evolution.candidate.applied",
actor=actor,
risk_level=candidate.level,
decision="auto",
character_id=candidate.character_id,
input_summary=candidate.proposal[:200],
output_summary=output_summary,
resource_type="evolution_candidate",
resource_id=candidate.id,
)
_APPLIERS: dict[str, Applier] = {
"personality": _apply_personality,
"procedural": _apply_procedural,
}
-164
View File
@@ -1,164 +0,0 @@
"""大周天抽取候选审核服务(记忆候选 + 世界实体候选)。
记忆候选 approve 派生为 ``memories``(source=big_cycle);世界实体候选 approve
→ confirmed 进入世界模型。审核仅 pending 可操作(见 [大周天记忆/世界抽取落地设计]
../docs/domain/core-domains.md §5)。
"""
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import candidates as candidates_repo
from app.repositories import world as world_repo
from app.schemas.candidates import MemoryCandidateResponse
from app.schemas.common import Page
from app.schemas.world import WorldEntityResponse
from app.services.audit import record_audit_event
from app.services.serializers import (
character_world_entity_to_response,
memory_candidate_to_response,
)
async def list_memory_proposals(
*,
character_id: str,
source: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[MemoryCandidateResponse]:
async with AsyncSessionLocal() as session:
all_items = await candidates_repo.list_memory_candidates(
session, character_id, source=source, status=status
)
start = max(page - 1, 0) * page_size
items = all_items[start : start + page_size]
return Page(
items=[memory_candidate_to_response(i) for i in items],
page=page,
page_size=page_size,
total=len(all_items),
)
async def approve_memory_proposal(
candidate_id: str,
) -> MemoryCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await candidates_repo.get_memory_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "记忆候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
session.add(
Memory(
character_id=candidate.character_id,
type=candidate.type,
title=candidate.title,
content=candidate.content,
source="big_cycle",
confidence=candidate.confidence,
importance=candidate.importance,
sensitivity=candidate.sensitivity,
evidence_ids_json=candidate.evidence_ids_json,
status="active",
)
)
candidate.status = "applied"
candidate.reviewed_at = utc_now()
await record_audit_event(
session,
action="extraction.memory.applied",
actor="user",
character_id=candidate.character_id,
input_summary=candidate.title[:200],
resource_type="memory_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return memory_candidate_to_response(candidate)
async def reject_memory_proposal(
candidate_id: str,
) -> MemoryCandidateResponse:
return await _reject_memory(candidate_id)
async def list_world_entities_pending(
*,
character_id: str,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[WorldEntityResponse]:
async with AsyncSessionLocal() as session:
items, total = await world_repo.list_entities(
session, character_id, status=status, page=page, page_size=page_size
)
return Page(
items=[character_world_entity_to_response(i) for i in items],
page=page,
page_size=page_size,
total=total,
)
async def approve_world_entity(entity_id: str) -> WorldEntityResponse:
return await _review_world_entity(entity_id, "confirmed", decision="auto")
async def reject_world_entity(entity_id: str) -> WorldEntityResponse:
return await _review_world_entity(entity_id, "rejected", decision="denied")
async def _reject_memory(candidate_id: str) -> MemoryCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await candidates_repo.get_memory_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "记忆候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
candidate.status = "rejected"
candidate.reviewed_at = utc_now()
await record_audit_event(
session,
action="extraction.memory.rejected",
actor="user",
decision="denied",
character_id=candidate.character_id,
input_summary=candidate.title[:200],
resource_type="memory_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return memory_candidate_to_response(candidate)
async def _review_world_entity(
entity_id: str, target_status: str, *, decision: str
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
entity = await world_repo.get_entity(session, entity_id)
if entity is None:
raise AppError("not_found", "世界实体不存在。")
if entity.status != "pending":
raise AppError("conflict", f"实体当前状态为 {entity.status},不可审核。")
entity.status = target_status
await record_audit_event(
session,
action=f"extraction.world.{target_status}",
actor="user",
decision=decision,
character_id=entity.character_id,
input_summary=entity.name[:200],
resource_type="character_world_entity",
resource_id=entity.id,
)
await session.commit()
await session.refresh(entity)
return character_world_entity_to_response(entity)
+5 -22
View File
@@ -15,7 +15,6 @@ from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.imports import CharacterImportJob, ImportItem
from app.models.memory import Memory
from app.modules.imports import parse_import
from app.repositories import candidates as candidates_repo
from app.repositories import imports as imports_repo
@@ -325,29 +324,13 @@ async def _trigger_extraction(
kind: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
"""校验 job → 置 extracting_* → 启动后台抽取 → 返回 job 当前状态。"""
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
job = await _get_owned_job(session, character.id, import_id)
if job.status not in _EXTRACTABLE_STATUSES:
raise AppError("conflict", f"任务状态 {job.status} 不可触发抽取。")
job.status = status
await session.commit()
from app.application.imports import (
start_memory_extraction,
start_personality_extraction,
start_profile_extraction,
"""抽取触发已随旧管线退役:记忆候选由息壤归真管线生成(M3),画像/人格抽取随 M6。"""
raise AppError(
"not_implemented",
"该抽取入口已退役:记忆候选经镜树归真生成(息壤 M3);画像/人格抽取随 M6 灵核重建。",
status_code=410,
)
starters = {
"profile": start_profile_extraction,
"memory": start_memory_extraction,
"personality": start_personality_extraction,
}
starters[kind](import_id)
return await _reload_job(import_id)
async def list_profile_candidates(
character_id: str,
-304
View File
@@ -1,304 +0,0 @@
"""记忆服务。
集成 ChromaDB 向量存储:
- create_memory / 大/小周天自动创建的 Memory 写入后异步生成 embedding 存向量
- retrieve_memories 支持向量相似度检索,失败时回退 SQL 排序检索
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.core.logging import logger
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.modules.memory.context import MemoryRetrievalResult
from app.repositories import memory as memory_repo
from app.repositories.json_utils import dumps
from app.schemas.common import Page
from app.schemas.memory import MemoryCreateRequest, MemoryResponse, MemoryUpdateRequest
from app.services.audit import record_audit_event
from app.services.characters import get_active_character_model
from app.services.serializers import memory_to_response
def _get_embedding_service(manager):
"""尝试创建 EmbeddingService,失败时返回 None。"""
try:
models = manager.model_registry.list_by_capability("embedding")
if not models:
return None
from app.ai_provider.embedding import EmbeddingService
return EmbeddingService(manager)
except Exception: # noqa: BLE001
return None
async def _embed_and_store(memory: Memory) -> None:
"""异步后台任务:为记忆生成 embedding 并存入向量库。
embedding 服务和 ChromaDB 均不可用时静默跳过——不影响主流程。
"""
text = memory.content or memory.title or ""
if not text:
return
try:
from app.ai_provider import ProviderManagerFactory
manager = ProviderManagerFactory.create()
service = _get_embedding_service(manager)
if service is None:
return
import asyncio as _asyncio
response = await _asyncio.wait_for(
service.embed(text),
timeout=10.0,
)
if not response.data or not response.data[0].embedding:
return
embedding = response.data[0].embedding
from app.integrations.vector_store import get_vector_store
store = get_vector_store()
if store is None:
return
store.add_memory(
character_id=memory.character_id,
memory_id=memory.id,
text=text,
embedding=embedding,
metadata={
"type": memory.type,
"source": memory.source,
"title": memory.title or "",
},
)
except Exception: # noqa: BLE001
logger.warning(
"memory_embed_failed",
memory_id=memory.id,
character_id=memory.character_id,
)
async def embed_memory_async(memory: Memory) -> None:
"""公开接口:为单条记忆生成 embedding 并存入向量库(供大/小周天后台任务调用)。"""
await _embed_and_store(memory)
# ── CRUD ──────────────────────────────────────────────────────────────────
async def list_memory_page(
*,
page: int = 1,
page_size: int = 20,
memory_type: str | None = None,
source: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> Page[MemoryResponse]:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
safe_page_size = min(max(page_size, 1), 100)
safe_page = max(page, 1)
items, total = await memory_repo.list_memories(
session,
character.id,
memory_type=memory_type,
source=source,
page=safe_page,
page_size=safe_page_size,
)
return Page(
items=[memory_to_response(item) for item in items],
page=safe_page,
page_size=safe_page_size,
total=total,
)
async def create_memory(
session: AsyncSession,
payload: MemoryCreateRequest,
*,
owner_scope: str = settings.default_owner_scope,
) -> MemoryResponse:
character = await get_active_character_model(session, owner_scope)
character_id = payload.character_id or character.id
if character_id != character.id:
raise AppError("not_found", "人物不存在。")
memory = Memory(
character_id=character_id,
type=payload.type,
title=payload.title,
content=payload.content,
source=payload.source,
confidence=payload.confidence,
importance=payload.importance,
sensitivity=payload.sensitivity,
evidence_ids_json=dumps(payload.evidence_ids),
status="active",
)
session.add(memory)
await session.flush()
# 异步 embedding
if settings.vector_db_enabled:
import asyncio
asyncio.ensure_future(_embed_and_store(memory))
await record_audit_event(
session,
action="memory.created",
character_id=character_id,
actor="user",
decision="auto",
input_summary=payload.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def update_memory(
memory_id: str, payload: MemoryUpdateRequest, *, owner_scope: str = settings.default_owner_scope
) -> MemoryResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
memory = await memory_repo.get_memory(session, memory_id)
if memory is None or memory.character_id != character.id or memory.deleted_at is not None:
raise AppError("not_found", "记忆不存在。")
updates = payload.model_dump(exclude_unset=True)
evidence_ids = updates.pop("evidence_ids", None)
for key, value in updates.items():
setattr(memory, key, value)
if evidence_ids is not None:
memory.evidence_ids_json = dumps(evidence_ids)
memory.updated_at = utc_now()
# 如果内容变了,异步更新向量
if ("content" in updates or "title" in updates) and settings.vector_db_enabled:
import asyncio
asyncio.ensure_future(_embed_and_store(memory))
await record_audit_event(
session,
action="memory.updated",
character_id=character.id,
actor="user",
decision="auto",
input_summary=memory.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def delete_memory(
memory_id: str, *, owner_scope: str = settings.default_owner_scope
) -> MemoryResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
memory = await memory_repo.get_memory(session, memory_id)
if memory is None or memory.character_id != character.id or memory.deleted_at is not None:
raise AppError("not_found", "记忆不存在。")
memory.status = "deleted"
memory.deleted_at = utc_now()
memory.updated_at = utc_now()
# 同步删除向量
if settings.vector_db_enabled:
try:
from app.integrations.vector_store import get_vector_store
store = get_vector_store()
if store is not None:
store.delete_memory(character.id, memory_id)
except Exception: # noqa: BLE001
pass
await record_audit_event(
session,
action="memory.deleted",
character_id=character.id,
actor="user",
decision="auto",
input_summary=memory.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def memory_context(
session: AsyncSession,
character_id: str,
*,
limit: int,
_owner_scope: str = settings.default_owner_scope,
) -> str:
"""获取记忆上下文文本(供叩心枢开场、辰正等场景使用)。"""
memories = await memory_repo.list_injectable_memories(session, character_id, limit=limit)
if not memories:
return ""
lines = [f"- {item.title}: {item.content}" for item in memories]
return "相关记忆:\n" + "\n".join(lines)
async def retrieve_memories(
session: AsyncSession,
character_id: str,
*,
limit: int,
_owner_scope: str = settings.default_owner_scope,
) -> MemoryRetrievalResult:
"""检索可注入上下文的相关记忆。
优先尝试向量相似度检索,失败时回退 SQL 排序检索。
"""
# 尝试向量检索(5s 超时,避免 embedding endpoint 不可达时阻塞消息流)
if settings.vector_db_enabled:
try:
from app.ai_provider import ProviderManagerFactory
manager = ProviderManagerFactory.create()
models = manager.model_registry.list_by_capability("embedding")
if models:
import asyncio as _asyncio
from app.ai_provider.embedding import EmbeddingService
from app.integrations.vector_store import get_vector_store
query_text = f"检索 {character_id} 的最新相关记忆"
service = EmbeddingService(manager)
response = await _asyncio.wait_for(
service.embed(query_text),
timeout=5.0,
)
if response.data and response.data[0].embedding:
store = get_vector_store()
if store is not None:
vector_results = store.search(
character_id,
response.data[0].embedding,
limit=limit,
)
if vector_results:
ids = [r["id"] for r in vector_results]
memories = await memory_repo.get_memories_by_ids(session, ids)
if memories:
return MemoryRetrievalResult(memories=memories)
except Exception: # noqa: BLE001
logger.warning("vector_retrieval_failed,回退 SQL 检索")
# 回退 SQL
memories = await memory_repo.list_injectable_memories(session, character_id, limit=limit)
return MemoryRetrievalResult(memories=memories)
+12 -40
View File
@@ -16,7 +16,6 @@ from app.models.governance import (
WorldEntity,
)
from app.models.imports import CharacterImportJob, ImportItem
from app.models.memory import Memory
from app.models.personality import PersonalityProposal, PersonalityVersion
from app.models.runtime import Event, Run
from app.models.safety import AuditEvent
@@ -33,7 +32,6 @@ from app.schemas.admin import (
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
from app.schemas.imports import ImportItemResponse, ImportJobResponse
from app.schemas.memory import MemoryResponse
from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse
from app.schemas.preferences import GenerationPreferencesResponse
from app.schemas.runtime import EventResponse, RunResponse
@@ -123,26 +121,6 @@ def proposal_to_response(proposal: PersonalityProposal) -> PersonalityProposalRe
)
def memory_to_response(memory: Memory) -> MemoryResponse:
return MemoryResponse(
id=memory.id,
character_id=memory.character_id,
type=memory.type,
title=memory.title,
content=memory.content,
source=memory.source,
confidence=memory.confidence,
importance=memory.importance,
sensitivity=memory.sensitivity,
evidence_ids=loads_list(memory.evidence_ids_json),
status=memory.status,
created_at=to_iso(memory.created_at) or "",
updated_at=to_iso(memory.updated_at) or "",
deleted_at=to_iso(memory.deleted_at),
expires_at=to_iso(memory.expires_at),
)
def run_to_response(run: Run) -> RunResponse:
return RunResponse(
id=run.id,
@@ -401,21 +379,15 @@ def profile_candidate_to_response(candidate: ProfileCandidate) -> ProfileCandida
)
def memory_candidate_to_response(candidate: MemoryCandidate) -> MemoryCandidateResponse:
return MemoryCandidateResponse(
id=candidate.id,
character_id=candidate.character_id,
import_job_id=candidate.import_job_id,
cycle_id=candidate.cycle_id,
type=candidate.type,
title=candidate.title,
content=candidate.content,
confidence=candidate.confidence,
importance=candidate.importance,
sensitivity=candidate.sensitivity,
evidence_ids=loads_list(candidate.evidence_ids_json),
status=candidate.status,
reason=candidate.reason,
reviewed_at=to_iso(candidate.reviewed_at),
created_at=to_iso(candidate.created_at) or "",
)
def memory_candidate_to_response(candidate): # noqa: ANN001 — 旧导入候选(M6 退役)
from app.schemas.candidates import MemoryCandidateResponse
return MemoryCandidateResponse.model_validate(candidate, from_attributes=True)
def profile_candidate_to_response(candidate): # noqa: ANN001 — 旧导入候选(M6 退役)
from app.schemas.candidates import ProfileCandidateResponse
return ProfileCandidateResponse.model_validate(candidate, from_attributes=True)