初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""数据访问层。"""
+60
View File
@@ -0,0 +1,60 @@
"""审计事件数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.safety import AuditEvent
async def add_audit_event(session: AsyncSession, event: AuditEvent) -> AuditEvent:
session.add(event)
await session.flush()
return event
async def list_audit_events(
session: AsyncSession,
*,
pivot: str | None = None,
character_id: str | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[AuditEvent], int]:
"""起居注时间河:按 created_at 倒序,可选按枢/人物过滤,返回 (条目, 总数)。"""
conditions = []
if pivot:
conditions.append(AuditEvent.pivot == pivot)
if character_id:
conditions.append(AuditEvent.character_id == character_id)
total_result = await session.execute(
select(func.count()).select_from(AuditEvent).where(*conditions)
)
total = int(total_result.scalar_one())
result = await session.execute(
select(AuditEvent)
.where(*conditions)
.order_by(AuditEvent.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
async def list_audit_events_by_trace(session: AsyncSession, trace_id: str) -> list[AuditEvent]:
"""链路追踪:按 trace_id 查询审计事件,按 created_at 升序(与运行事件归并排序)。"""
result = await session.execute(
select(AuditEvent)
.where(AuditEvent.trace_id == trace_id)
.order_by(AuditEvent.created_at.asc())
)
return list(result.scalars().all())
async def summarize_by_trace(session: AsyncSession):
"""GROUP BY trace_id:(trace_id, 计数, 最近时间),仅 trace_id 非空。供链路追踪列表聚合。"""
result = await session.execute(
select(AuditEvent.trace_id, func.count(), func.max(AuditEvent.created_at))
.where(AuditEvent.trace_id.is_not(None))
.group_by(AuditEvent.trace_id)
)
return [(row[0], int(row[1]), row[2]) for row in result.all()]
+50
View File
@@ -0,0 +1,50 @@
"""大周天运行记录数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.big_cycle import BigCycle
async def add_cycle(session: AsyncSession, cycle: BigCycle) -> BigCycle:
session.add(cycle)
await session.flush()
return cycle
async def get_cycle(session: AsyncSession, cycle_id: str) -> BigCycle | None:
return await session.get(BigCycle, cycle_id)
async def get_last_completed(session: AsyncSession, character_id: str) -> BigCycle | None:
result = await session.execute(
select(BigCycle)
.where(
BigCycle.character_id == character_id,
BigCycle.completed_at.is_not(None),
)
.order_by(BigCycle.completed_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def list_cycles(
session: AsyncSession,
character_id: str,
*,
page: int = 1,
page_size: int = 20,
) -> tuple[list[BigCycle], int]:
conditions = [BigCycle.character_id == character_id]
total = (
await session.execute(select(func.count()).select_from(BigCycle).where(*conditions))
).scalar_one()
items_result = await session.execute(
select(BigCycle)
.where(*conditions)
.order_by(BigCycle.created_at.desc())
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
)
return list(items_result.scalars().all()), int(total or 0)
+70
View File
@@ -0,0 +1,70 @@
"""候选数据访问(api-spec §8;档案 / 记忆候选)。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.candidates import MemoryCandidate, ProfileCandidate
async def add_profile_candidates(session: AsyncSession, candidates: list[ProfileCandidate]) -> None:
session.add_all(candidates)
await session.flush()
async def add_memory_candidates(session: AsyncSession, candidates: list[MemoryCandidate]) -> None:
session.add_all(candidates)
await session.flush()
async def list_profile_candidates(
session: AsyncSession,
character_id: str,
*,
import_job_id: str | None = None,
status: str | None = None,
) -> list[ProfileCandidate]:
conditions = [ProfileCandidate.character_id == character_id]
if import_job_id is not None:
conditions.append(ProfileCandidate.import_job_id == import_job_id)
if status is not None:
conditions.append(ProfileCandidate.status == status)
result = await session.execute(
select(ProfileCandidate).where(*conditions).order_by(ProfileCandidate.created_at.desc())
)
return list(result.scalars().all())
async def list_memory_candidates(
session: AsyncSession,
character_id: str,
*,
import_job_id: str | None = None,
cycle_id: str | None = None,
status: str | None = None,
source: str | None = None,
) -> list[MemoryCandidate]:
conditions = [MemoryCandidate.character_id == character_id]
if import_job_id is not None:
conditions.append(MemoryCandidate.import_job_id == import_job_id)
if cycle_id is not None:
conditions.append(MemoryCandidate.cycle_id == cycle_id)
if status is not None:
conditions.append(MemoryCandidate.status == status)
if source == "cycle":
conditions.append(MemoryCandidate.import_job_id.is_(None))
elif source == "import":
conditions.append(MemoryCandidate.import_job_id.is_not(None))
result = await session.execute(
select(MemoryCandidate).where(*conditions).order_by(MemoryCandidate.created_at.desc())
)
return list(result.scalars().all())
async def get_profile_candidate(
session: AsyncSession, candidate_id: str
) -> ProfileCandidate | None:
return await session.get(ProfileCandidate, candidate_id)
async def get_memory_candidate(session: AsyncSession, candidate_id: str) -> MemoryCandidate | None:
return await session.get(MemoryCandidate, candidate_id)
+129
View File
@@ -0,0 +1,129 @@
"""人物数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character import Character
from app.models.memory import Memory
async def get_active_character(session: AsyncSession, owner_scope: str) -> Character | None:
result = await session.execute(
select(Character)
.where(
Character.owner_scope == owner_scope,
Character.status.in_(["active", "initialized"]),
Character.is_system_template.is_(False),
)
.order_by(Character.created_at.asc())
.limit(1)
)
return result.scalar_one_or_none()
async def get_character(session: AsyncSession, character_id: str) -> Character | None:
return await session.get(Character, character_id)
async def get_system_template_by_key(session: AsyncSession, template_key: str) -> Character | None:
result = await session.execute(
select(Character)
.where(
Character.is_system_template.is_(True),
Character.template_key == template_key,
)
.order_by(Character.created_at.asc())
.limit(1)
)
return result.scalar_one_or_none()
async def get_character_by_source_template(
session: AsyncSession, source_template_id: str, owner_scope: str
) -> Character | None:
"""按来源模板 ID 查找已存在的用户人物,用于启动时跳过重复创建。"""
result = await session.execute(
select(Character)
.where(
Character.source_template_id == source_template_id,
Character.owner_scope == owner_scope,
Character.is_system_template.is_(False),
)
.order_by(Character.created_at.asc())
.limit(1)
)
return result.scalar_one_or_none()
async def add_character(session: AsyncSession, character: Character) -> Character:
session.add(character)
await session.flush()
return character
async def list_characters(
session: AsyncSession,
owner_scope: str,
*,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[Character], int]:
"""列出某归属范围下的非模板人物,按创建时间倒序分页。"""
conditions = [
Character.owner_scope == owner_scope,
Character.is_system_template.is_(False),
]
if status is not None:
conditions.append(Character.status == status)
total = (
await session.execute(select(func.count()).select_from(Character).where(*conditions))
).scalar_one()
items_result = await session.execute(
select(Character)
.where(*conditions)
.order_by(Character.created_at.desc())
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
)
items = list(items_result.scalars().all())
return items, int(total or 0)
async def count_memories(session: AsyncSession, character_id: str) -> int:
"""统计人物关联的未删除记忆数量(列表项 memory_count)。"""
total = (
await session.execute(
select(func.count())
.select_from(Memory)
.where(Memory.character_id == character_id, Memory.deleted_at.is_(None))
)
).scalar_one()
return int(total or 0)
async def list_active_in_scope(
session: AsyncSession, owner_scope: str, *, except_id: str
) -> list[Character]:
"""列出同归属范围下处于激活/已初始化态的其他人物(A1:激活互斥用)。"""
result = await session.execute(
select(Character).where(
Character.owner_scope == owner_scope,
Character.is_system_template.is_(False),
Character.status.in_(["active", "initialized"]),
Character.id != except_id,
)
)
return list(result.scalars().all())
async def list_system_templates(session: AsyncSession) -> list[Character]:
"""列出所有系统模板(is_system_template=True),按 template_key 排序。"""
result = await session.execute(
select(Character)
.where(Character.is_system_template.is_(True))
.order_by(Character.template_key.asc())
)
return list(result.scalars().all())
+200
View File
@@ -0,0 +1,200 @@
"""聊天数据访问。"""
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.models.chat import ChatMessage, ChatSession
async def get_session(
session: AsyncSession, session_id: str, *, owner_scope: str | None = None
) -> ChatSession | None:
stmt = select(ChatSession).where(ChatSession.id == session_id)
if owner_scope is not None:
stmt = stmt.where(ChatSession.owner_scope == owner_scope)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def list_recent_messages(
session: AsyncSession, session_id: str, *, limit: int = 12
) -> list[ChatMessage]:
result = await session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == session_id, ChatMessage.deleted_at.is_(None))
.order_by(ChatMessage.created_at.desc())
.limit(limit)
)
return list(reversed(result.scalars().all()))
async def list_sessions(
session: AsyncSession,
character_id: str,
*,
status: str | None = None,
owner_scope: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[ChatSession]:
conditions = [ChatSession.character_id == character_id, ChatSession.status != "deleted"]
if status is not None:
conditions.append(ChatSession.status == status)
if owner_scope is not None:
conditions.append(ChatSession.owner_scope == owner_scope)
result = await session.execute(
select(ChatSession)
.where(*conditions)
.order_by(ChatSession.updated_at.desc())
.offset(offset)
.limit(limit)
)
return list(result.scalars().all())
async def count_sessions(
session: AsyncSession,
character_id: str,
*,
status: str | None = None,
owner_scope: str | None = None,
) -> int:
conditions = [ChatSession.character_id == character_id, ChatSession.status != "deleted"]
if status is not None:
conditions.append(ChatSession.status == status)
if owner_scope is not None:
conditions.append(ChatSession.owner_scope == owner_scope)
result = await session.execute(select(func.count()).select_from(ChatSession).where(*conditions))
return result.scalar_one()
async def list_messages(
session: AsyncSession, session_id: str, *, limit: int = 100, offset: int = 0
) -> list[ChatMessage]:
result = await session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == session_id, ChatMessage.deleted_at.is_(None))
.order_by(ChatMessage.created_at.asc())
.offset(offset)
.limit(limit)
)
return list(result.scalars().all())
async def count_messages(session: AsyncSession, session_id: str) -> int:
result = await session.execute(
select(func.count())
.select_from(ChatMessage)
.where(
ChatMessage.session_id == session_id,
ChatMessage.deleted_at.is_(None),
)
)
return result.scalar_one()
async def count_messages_by_sessions(
session: AsyncSession, session_ids: list[str]
) -> dict[str, int]:
"""批量统计多个会话的未删除消息数(GROUP BY 聚合,避免列表查询 N+1)。"""
if not session_ids:
return {}
result = await session.execute(
select(ChatMessage.session_id, func.count())
.where(
ChatMessage.session_id.in_(session_ids),
ChatMessage.deleted_at.is_(None),
)
.group_by(ChatMessage.session_id)
)
return {str(sid): int(cnt) for sid, cnt in result.all()}
async def last_message_previews_by_sessions(
session: AsyncSession, session_ids: list[str]
) -> dict[str, str]:
"""批量取每个会话最后一条未删除消息的内容(截断由调用方处理)。
用 row_number() 窗口函数取每个 session_id 的最新一条,兼容 SQLite(3.25+)
与 PostgreSQL,避免逐会话查询(N+1)。无消息的会话不在结果中。
"""
if not session_ids:
return {}
latest = (
select(
ChatMessage.session_id,
ChatMessage.content,
func.row_number()
.over(
partition_by=ChatMessage.session_id,
order_by=ChatMessage.created_at.desc(),
)
.label("rn"),
).where(
ChatMessage.session_id.in_(session_ids),
ChatMessage.deleted_at.is_(None),
)
).subquery()
result = await session.execute(
select(latest.c.session_id, latest.c.content).where(latest.c.rn == 1)
)
return {str(sid): (content or "") for sid, content in result.all()}
async def count_messages_by_role(session: AsyncSession, session_id: str, *, role: str) -> int:
"""统计会话内指定角色的未删除消息数(用于判断首轮对话)。"""
result = await session.execute(
select(func.count())
.select_from(ChatMessage)
.where(
ChatMessage.session_id == session_id,
ChatMessage.role == role,
ChatMessage.deleted_at.is_(None),
)
)
return result.scalar_one()
async def update_session_title(session: AsyncSession, session_id: str, title: str) -> None:
"""更新会话标题;仅修改托管对象,提交时机由调用方控制。"""
chat_session = await session.get(ChatSession, session_id)
if chat_session is not None:
chat_session.title = title
async def soft_delete_session_messages(session: AsyncSession, session_id: str) -> None:
"""软删除会话内全部未删除消息;提交时机由调用方控制。"""
from app.core.time import utc_now
await session.execute(
update(ChatMessage)
.where(
ChatMessage.session_id == session_id,
ChatMessage.deleted_at.is_(None),
)
.values(deleted_at=utc_now())
)
async def get_message(session: AsyncSession, message_id: str) -> ChatMessage | None:
"""按 ID 获取单条消息。"""
return await session.get(ChatMessage, message_id)
async def set_message_active_version(
session: AsyncSession,
message_id: str,
version_idx: int,
versions_json: str,
) -> None:
"""更新消息的活跃版本索引和 versions_json。"""
from sqlalchemy import update
result = await session.execute(
update(ChatMessage)
.where(ChatMessage.id == message_id)
.values(active_version_idx=version_idx, versions_json=versions_json)
)
if result.rowcount == 0:
raise AppError("not_found", "消息不存在。")
+97
View File
@@ -0,0 +1,97 @@
"""内驱力数据访问(好奇问题 + 探索性次生目标)。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
async def add_curiosities(session: AsyncSession, curiosities: list[CuriosityQuestion]) -> None:
session.add_all(curiosities)
await session.flush()
async def get_curiosity(session: AsyncSession, curiosity_id: str) -> CuriosityQuestion | None:
return await session.get(CuriosityQuestion, curiosity_id)
async def list_curiosities(
session: AsyncSession,
character_id: str,
*,
source: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[CuriosityQuestion], int]:
conditions = [CuriosityQuestion.character_id == character_id]
if source is not None:
conditions.append(CuriosityQuestion.source == source)
total = (
await session.execute(
select(func.count()).select_from(CuriosityQuestion).where(*conditions)
)
).scalar_one()
items = await session.execute(
select(CuriosityQuestion)
.where(*conditions)
.order_by(CuriosityQuestion.created_at.desc())
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
)
return list(items.scalars().all()), int(total or 0)
async def add_subgoals(session: AsyncSession, subgoals: list[ExploratorySubgoal]) -> None:
session.add_all(subgoals)
await session.flush()
async def get_subgoal(session: AsyncSession, subgoal_id: str) -> ExploratorySubgoal | None:
return await session.get(ExploratorySubgoal, subgoal_id)
async def list_subgoals(
session: AsyncSession,
character_id: str,
*,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[ExploratorySubgoal], int]:
conditions = [ExploratorySubgoal.character_id == character_id]
if status is not None:
conditions.append(ExploratorySubgoal.status == status)
total = (
await session.execute(
select(func.count()).select_from(ExploratorySubgoal).where(*conditions)
)
).scalar_one()
items = await session.execute(
select(ExploratorySubgoal)
.where(*conditions)
.order_by(ExploratorySubgoal.created_at.desc())
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
)
return list(items.scalars().all()), int(total or 0)
async def list_approved_subgoals(
session: AsyncSession,
character_id: str,
limit: int = 5,
) -> list[ExploratorySubgoal]:
"""列出已批准(approved)的次生目标,按 created_at 升序排列。
供大周天自主探索执行阶段拾取。
"""
result = await session.execute(
select(ExploratorySubgoal)
.where(
ExploratorySubgoal.character_id == character_id,
ExploratorySubgoal.status == "approved",
)
.order_by(ExploratorySubgoal.created_at.asc())
.limit(limit)
)
return list(result.scalars().all())
+44
View File
@@ -0,0 +1,44 @@
"""演化候选数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.evolution import EvolutionCandidate
async def add_candidates(session: AsyncSession, candidates: list[EvolutionCandidate]) -> None:
session.add_all(candidates)
await session.flush()
async def get_candidate(session: AsyncSession, candidate_id: str) -> EvolutionCandidate | None:
return await session.get(EvolutionCandidate, candidate_id)
async def list_candidates(
session: AsyncSession,
character_id: str,
*,
status: str | None = None,
candidate_type: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[EvolutionCandidate], int]:
conditions = [EvolutionCandidate.character_id == character_id]
if status is not None:
conditions.append(EvolutionCandidate.status == status)
if candidate_type is not None:
conditions.append(EvolutionCandidate.type == candidate_type)
total = (
await session.execute(
select(func.count()).select_from(EvolutionCandidate).where(*conditions)
)
).scalar_one()
items_result = await session.execute(
select(EvolutionCandidate)
.where(*conditions)
.order_by(EvolutionCandidate.created_at.desc())
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
)
return list(items_result.scalars().all()), int(total or 0)
+180
View File
@@ -0,0 +1,180 @@
"""司命台治理对象数据访问(api-spec §19.4)。
目标 / 感知通道 / 世界实体 / 计划 / 天工阁角色的 CRUD 数据访问;
均为本地单用户全局对象,不挂 character_id。
"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import utc_now
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
ToolPolicy,
WorldEntity,
)
# —— 太一枢 · 目标 ——
async def list_goals(session: AsyncSession) -> list[Goal]:
result = await session.execute(
select(Goal).order_by(Goal.priority.desc(), Goal.updated_at.desc())
)
return list(result.scalars().all())
async def get_goal(session: AsyncSession, goal_id: str) -> Goal | None:
return await session.get(Goal, goal_id)
async def add_goal(session: AsyncSession, goal: Goal) -> Goal:
session.add(goal)
await session.flush()
return goal
async def list_goals_by_character(
session: AsyncSession,
character_id: str,
status_filter: str | None = None,
) -> list[Goal]:
"""按 character_id 获取目标列表;可选的 status 过滤。"""
stmt = select(Goal).where(Goal.character_id == character_id)
if status_filter:
stmt = stmt.where(Goal.status == status_filter)
stmt = stmt.order_by(Goal.priority.desc(), Goal.updated_at.desc())
result = await session.execute(stmt)
return list(result.scalars().all())
async def update_goal_progress(
session: AsyncSession,
goal_id: str,
progress: float,
) -> Goal | None:
"""更新目标的 progress 与 updated_at 字段。"""
goal = await session.get(Goal, goal_id)
if goal is None:
return None
goal.progress = progress
goal.updated_at = utc_now()
await session.flush()
return goal
async def list_active_session_goal(session: AsyncSession, character_id: str) -> Goal | None:
"""查找指定人物活跃的 session 级目标(各人物至多一个活跃 session 目标)。"""
result = await session.execute(
select(Goal)
.where(
Goal.character_id == character_id,
Goal.level == "session",
Goal.status == "active",
)
.limit(1)
)
return result.scalar_one_or_none()
# —— 烛照枢 · 感知通道 ——
async def list_perception_channels(session: AsyncSession) -> list[PerceptionChannel]:
result = await session.execute(select(PerceptionChannel).order_by(PerceptionChannel.channel))
return list(result.scalars().all())
async def get_perception_channel(session: AsyncSession, channel: str) -> PerceptionChannel | None:
return await session.get(PerceptionChannel, channel)
async def add_perception_channel(
session: AsyncSession, channel: PerceptionChannel
) -> PerceptionChannel:
session.add(channel)
await session.flush()
return channel
# —— 河图枢 · 世界实体 ——
async def list_world_entities(session: AsyncSession) -> list[WorldEntity]:
result = await session.execute(select(WorldEntity).order_by(WorldEntity.updated_at.desc()))
return list(result.scalars().all())
async def get_world_entity(session: AsyncSession, entity_id: str) -> WorldEntity | None:
return await session.get(WorldEntity, entity_id)
async def add_world_entity(session: AsyncSession, entity: WorldEntity) -> WorldEntity:
session.add(entity)
await session.flush()
return entity
# —— 洛书枢 · 计划 ——
async def list_plans(session: AsyncSession) -> list[Plan]:
result = await session.execute(select(Plan).order_by(Plan.updated_at.desc()))
return list(result.scalars().all())
async def get_plan(session: AsyncSession, plan_id: str) -> Plan | None:
return await session.get(Plan, plan_id)
async def add_plan(session: AsyncSession, plan: Plan) -> Plan:
session.add(plan)
await session.flush()
return plan
# —— 天工阁枢 · 角色在岗 ——
async def list_agent_roster(session: AsyncSession) -> list[AgentRosterEntry]:
result = await session.execute(select(AgentRosterEntry).order_by(AgentRosterEntry.role))
return list(result.scalars().all())
async def get_agent(session: AsyncSession, role: str) -> AgentRosterEntry | None:
return await session.get(AgentRosterEntry, role)
async def add_agent(session: AsyncSession, agent: AgentRosterEntry) -> AgentRosterEntry:
session.add(agent)
await session.flush()
return agent
# —— 百工枢 · 工具策略(§15/§16 共享)——
async def list_tool_policies(session: AsyncSession) -> list[ToolPolicy]:
result = await session.execute(select(ToolPolicy).order_by(ToolPolicy.name))
return list(result.scalars().all())
async def get_tool_policy(session: AsyncSession, name: str) -> ToolPolicy | None:
return await session.get(ToolPolicy, name)
async def add_tool_policy(session: AsyncSession, policy: ToolPolicy) -> ToolPolicy:
session.add(policy)
await session.flush()
return policy
# —— 计数(供九枢健康度聚合)——
async def count(session: AsyncSession, model: type) -> int:
result = await session.execute(select(func.count()).select_from(model))
return int(result.scalar_one())
+33
View File
@@ -0,0 +1,33 @@
"""幂等键数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.idempotency import IdempotencyKey
async def get_by_scope_key(session: AsyncSession, *, scope: str, key: str) -> IdempotencyKey | None:
result = await session.execute(
select(IdempotencyKey).where(IdempotencyKey.scope == scope, IdempotencyKey.key == key)
)
return result.scalar_one_or_none()
async def list_by_trace(session: AsyncSession, trace_id: str) -> list[IdempotencyKey]:
"""链路追踪:按 trace_id 查询幂等键(请求级),按 created_at 升序。"""
result = await session.execute(
select(IdempotencyKey)
.where(IdempotencyKey.trace_id == trace_id)
.order_by(IdempotencyKey.created_at.asc())
)
return list(result.scalars().all())
async def summarize_by_trace(session: AsyncSession):
"""GROUP BY trace_id:(trace_id, 计数, 最近时间),仅 trace_id 非空。供链路追踪列表聚合。"""
result = await session.execute(
select(IdempotencyKey.trace_id, func.count(), func.max(IdempotencyKey.created_at))
.where(IdempotencyKey.trace_id.is_not(None))
.group_by(IdempotencyKey.trace_id)
)
return [(row[0], int(row[1]), row[2]) for row in result.all()]
+65
View File
@@ -0,0 +1,65 @@
"""资料导入数据访问(api-spec §8)。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imports import CharacterImportJob, ImportItem
async def get_job(session: AsyncSession, job_id: str) -> CharacterImportJob | None:
return await session.get(CharacterImportJob, job_id)
async def list_jobs(
session: AsyncSession,
character_id: str,
*,
page: int = 1,
page_size: int = 20,
) -> tuple[list[CharacterImportJob], int]:
conditions = [CharacterImportJob.character_id == character_id]
total = (
await session.execute(
select(func.count()).select_from(CharacterImportJob).where(*conditions)
)
).scalar_one()
items_result = await session.execute(
select(CharacterImportJob)
.where(*conditions)
.order_by(CharacterImportJob.created_at.desc())
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
)
return list(items_result.scalars().all()), int(total or 0)
async def add_job(session: AsyncSession, job: CharacterImportJob) -> CharacterImportJob:
session.add(job)
await session.flush()
return job
async def list_items(
session: AsyncSession,
job_id: str,
*,
page: int = 1,
page_size: int = 20,
) -> tuple[list[ImportItem], int]:
conditions = [ImportItem.import_job_id == job_id]
total = (
await session.execute(select(func.count()).select_from(ImportItem).where(*conditions))
).scalar_one()
items_result = await session.execute(
select(ImportItem)
.where(*conditions)
.order_by(ImportItem.sequence.asc())
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
)
return list(items_result.scalars().all()), int(total or 0)
async def add_items(session: AsyncSession, items: list[ImportItem]) -> None:
session.add_all(items)
await session.flush()
+22
View File
@@ -0,0 +1,22 @@
"""JSON 文本字段转换工具。"""
import json
from typing import Any
def dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def loads_dict(value: str | None) -> dict[str, Any]:
if not value:
return {}
data = json.loads(value)
return data if isinstance(data, dict) else {}
def loads_list(value: str | None) -> list[str]:
if not value:
return []
data = json.loads(value)
return data if isinstance(data, list) else []
+81
View File
@@ -0,0 +1,81 @@
"""世谱数据访问。"""
from __future__ import annotations
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.lorebook import Lorebook, LorebookEntry
async def list_active_books(
session: AsyncSession,
character_id: str,
owner_scope: str,
) -> list[Lorebook]:
"""查角色的 active 世谱:character 作用域(按人物隔离)+ global 作用域(跨人物共享)。"""
result = await session.execute(
select(Lorebook).where(
Lorebook.status == "active",
Lorebook.owner_scope == owner_scope,
or_(Lorebook.character_id == character_id, Lorebook.scope == "global"),
)
)
return list(result.scalars().all())
async def list_entries_for_books(
session: AsyncSession,
lorebook_ids: list[str],
) -> list[LorebookEntry]:
"""批量查多本书的启用条目,按 insertion_order 排序。"""
if not lorebook_ids:
return []
result = await session.execute(
select(LorebookEntry)
.where(
LorebookEntry.lorebook_id.in_(lorebook_ids),
LorebookEntry.enabled.is_(True),
)
.order_by(LorebookEntry.insertion_order)
)
return list(result.scalars().all())
async def get_book(session: AsyncSession, lorebook_id: str) -> Lorebook | None:
return await session.get(Lorebook, lorebook_id)
async def list_books(
session: AsyncSession,
character_id: str | None,
owner_scope: str,
*,
scope: str | None = None,
) -> list[Lorebook]:
"""列出 active 世谱(archived 不返回)。"""
conditions = [
Lorebook.status == "active",
Lorebook.owner_scope == owner_scope,
]
if character_id is not None:
conditions.append(or_(Lorebook.character_id == character_id, Lorebook.scope == "global"))
if scope is not None:
conditions.append(Lorebook.scope == scope)
result = await session.execute(
select(Lorebook).where(*conditions).order_by(Lorebook.created_at.desc())
)
return list(result.scalars().all())
async def list_book_entries(session: AsyncSession, lorebook_id: str) -> list[LorebookEntry]:
result = await session.execute(
select(LorebookEntry)
.where(LorebookEntry.lorebook_id == lorebook_id)
.order_by(LorebookEntry.insertion_order)
)
return list(result.scalars().all())
async def get_entry(session: AsyncSession, entry_id: str) -> LorebookEntry | None:
return await session.get(LorebookEntry, entry_id)
+32
View File
@@ -0,0 +1,32 @@
"""MCP server 数据访问。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.mcp_servers import McpServer
async def add_server(session: AsyncSession, server: McpServer) -> None:
session.add(server)
await session.flush()
async def get_server(session: AsyncSession, server_id: str) -> McpServer | None:
return await session.get(McpServer, server_id)
async def get_server_by_name(session: AsyncSession, name: str) -> McpServer | None:
result = await session.execute(select(McpServer).where(McpServer.name == name))
return result.scalar_one_or_none()
async def list_servers(session: AsyncSession, *, status: str | None = None) -> list[McpServer]:
conditions = []
if status is not None:
conditions.append(McpServer.status == status)
stmt = select(McpServer)
if conditions:
stmt = stmt.where(*conditions)
stmt = stmt.order_by(McpServer.created_at.desc())
result = await session.execute(stmt)
return list(result.scalars().all())
+73
View File
@@ -0,0 +1,73 @@
"""记忆数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.memory import Memory
async def get_memory(session: AsyncSession, memory_id: str) -> Memory | None:
return await session.get(Memory, memory_id)
async def list_memories(
session: AsyncSession,
character_id: str,
*,
memory_type: str | None = None,
source: str | None = None,
page: int,
page_size: int,
active_only: bool = True,
) -> tuple[list[Memory], int]:
conditions = [Memory.character_id == character_id]
if active_only:
conditions.append(Memory.status == "active")
conditions.append(Memory.deleted_at.is_(None))
if memory_type is not None:
conditions.append(Memory.type == memory_type)
if source is not None:
conditions.append(Memory.source == source)
total_result = await session.execute(
select(func.count()).select_from(Memory).where(*conditions)
)
total = int(total_result.scalar_one())
result = await session.execute(
select(Memory)
.where(*conditions)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
async def list_injectable_memories(
session: AsyncSession, character_id: str, *, limit: int
) -> list[Memory]:
result = await session.execute(
select(Memory)
.where(
Memory.character_id == character_id,
Memory.status == "active",
Memory.deleted_at.is_(None),
)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def get_memories_by_ids(
session: AsyncSession, memory_ids: list[str]
) -> list[Memory]:
"""按 ID 列表批量获取记忆(按重要性降序排序,而非 ID 传入顺序)。"""
result = await session.execute(
select(Memory).where(
Memory.id.in_(memory_ids),
Memory.status == "active",
Memory.deleted_at.is_(None),
)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
)
return list(result.scalars().all())
@@ -0,0 +1,47 @@
"""模型网关 endpoint 覆盖数据访问(api-spec §4.4)。
按 (logical_model_name, endpoint_name) 唯一;upsert 手动 find+update,兼容 SQLite。
字段 None 表示不覆盖该字段(回退 yaml/env)。
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.governance import ModelEndpointOverride
async def list_overrides(session: AsyncSession) -> list[ModelEndpointOverride]:
result = await session.execute(select(ModelEndpointOverride))
return list(result.scalars().all())
async def get_override(
session: AsyncSession,
logical_model_name: str,
endpoint_name: str,
) -> ModelEndpointOverride | None:
result = await session.execute(
select(ModelEndpointOverride).where(
ModelEndpointOverride.logical_model_name == logical_model_name,
ModelEndpointOverride.endpoint_name == endpoint_name,
)
)
return result.scalars().first()
async def upsert_override(
session: AsyncSession,
override: ModelEndpointOverride,
) -> ModelEndpointOverride:
"""存在则更新非 None 字段,否则新建。返回最终实体(未 commit)。"""
existing = await get_override(session, override.logical_model_name, override.endpoint_name)
if existing is not None:
existing.base_url = override.base_url
existing.api_key = override.api_key
existing.status = override.status
existing.weight = override.weight
existing.priority = override.priority
return existing
session.add(override)
await session.flush()
return override
+84
View File
@@ -0,0 +1,84 @@
"""人格数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.personality import PersonalityProposal, PersonalityVersion
async def get_personality(session: AsyncSession, personality_id: str) -> PersonalityVersion | None:
return await session.get(PersonalityVersion, personality_id)
async def get_latest_personality(
session: AsyncSession, character_id: str
) -> PersonalityVersion | None:
result = await session.execute(
select(PersonalityVersion)
.where(PersonalityVersion.character_id == character_id)
.order_by(PersonalityVersion.version.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def list_personality_versions(
session: AsyncSession,
character_id: str,
*,
page: int = 1,
page_size: int = 20,
) -> tuple[list[PersonalityVersion], int]:
"""按版本号倒序分页返回某人物的人格版本历史,返回 (条目, 总数)。"""
cond = PersonalityVersion.character_id == character_id
total = int(
(
await session.execute(select(func.count()).select_from(PersonalityVersion).where(cond))
).scalar_one()
)
result = await session.execute(
select(PersonalityVersion)
.where(cond)
.order_by(PersonalityVersion.version.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
async def add_proposal(session: AsyncSession, proposal: PersonalityProposal) -> PersonalityProposal:
session.add(proposal)
await session.flush()
return proposal
async def get_proposal(session: AsyncSession, proposal_id: str) -> PersonalityProposal | None:
return await session.get(PersonalityProposal, proposal_id)
async def list_proposals(
session: AsyncSession,
*,
character_id: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[PersonalityProposal], int]:
"""按创建时间倒序分页返回人格修改建议;可选按人物/状态过滤,返回 (条目, 总数)。"""
conds = []
if character_id is not None:
conds.append(PersonalityProposal.character_id == character_id)
if status is not None:
conds.append(PersonalityProposal.status == status)
count_stmt = select(func.count()).select_from(PersonalityProposal)
list_stmt = select(PersonalityProposal)
if conds:
count_stmt = count_stmt.where(*conds)
list_stmt = list_stmt.where(*conds)
total = int((await session.execute(count_stmt)).scalar_one())
result = await session.execute(
list_stmt.order_by(PersonalityProposal.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
@@ -0,0 +1,31 @@
"""灵核情绪状态数据访问(设计 §11.4)。"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.personality_emotion import CharacterEmotionState
async def get_state(session: AsyncSession, character_id: str) -> CharacterEmotionState | None:
"""读取某角色当前情绪状态(单行 per character)。"""
return await session.get(CharacterEmotionState, character_id)
async def upsert_state(
session: AsyncSession, state: CharacterEmotionState
) -> CharacterEmotionState:
"""写入情绪状态(存在则更新字段,不存在则插入)。
跨方言(SQLite + Postgres)ORM 层 upsert,不依赖 ON CONFLICT。
``updated_at`` 由 SQLAlchemy ``onupdate`` 自动刷新。
"""
existing = await session.get(CharacterEmotionState, state.character_id)
if existing is not None:
existing.mood = state.mood
existing.intensity = state.intensity
existing.baseline_mood = state.baseline_mood
existing.baseline_intensity = state.baseline_intensity
existing.last_trigger_event_id = state.last_trigger_event_id
return existing
session.add(state)
await session.flush()
return state
+24
View File
@@ -0,0 +1,24 @@
"""生成偏好数据访问(模型网关 §4.3)。
本地单用户单行,按 owner_scope 固定;无则由 service 层返回默认值。
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.governance import GenerationPreferences
async def get_preferences(session: AsyncSession, owner_scope: str) -> GenerationPreferences | None:
result = await session.execute(
select(GenerationPreferences).where(GenerationPreferences.owner_scope == owner_scope)
)
return result.scalars().first()
async def add_preferences(
session: AsyncSession, prefs: GenerationPreferences
) -> GenerationPreferences:
session.add(prefs)
await session.flush()
return prefs
+137
View File
@@ -0,0 +1,137 @@
"""运行时数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.runtime import Event, Run
async def get_run(session: AsyncSession, run_id: str) -> Run | None:
return await session.get(Run, run_id)
async def list_runs(
session: AsyncSession,
*,
character_id: str | None = None,
session_id: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[Run], int]:
"""按创建时间倒序分页返回 Run;可选按人物/会话/状态过滤,返回 (条目, 总数)。"""
conds = []
if character_id is not None:
conds.append(Run.character_id == character_id)
if session_id is not None:
conds.append(Run.session_id == session_id)
if status is not None:
conds.append(Run.status == status)
count_stmt = select(func.count()).select_from(Run)
list_stmt = select(Run)
if conds:
count_stmt = count_stmt.where(*conds)
list_stmt = list_stmt.where(*conds)
total = int((await session.execute(count_stmt)).scalar_one())
result = await session.execute(
list_stmt.order_by(Run.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
return list(result.scalars().all()), total
async def count_events(session: AsyncSession, run_id: str) -> int:
result = await session.execute(
select(func.count()).select_from(Event).where(Event.run_id == run_id)
)
return int(result.scalar_one())
async def next_event_sequence(session: AsyncSession, run_id: str) -> int:
result = await session.execute(select(func.max(Event.sequence)).where(Event.run_id == run_id))
current = result.scalar_one_or_none()
return int(current or 0) + 1
async def get_event(session: AsyncSession, event_id: str) -> Event | None:
return await session.get(Event, event_id)
async def has_active_runs(
session: AsyncSession,
character_id: str,
) -> bool:
"""检查指定角色是否有活跃的进行中 run(大周天任务中断防护用)。"""
result = await session.execute(
select(func.count()).select_from(Run).where(
Run.character_id == character_id,
Run.status == "running",
)
)
return int(result.scalar_one()) > 0
async def list_events_by_run(
session: AsyncSession,
run_id: str,
*,
visibility: str | None = None,
limit: int = 200,
offset: int = 0,
) -> tuple[list[Event], int]:
"""按 run_id 查询事件,按 sequence 升序;可选 visibility 过滤。返回 (条目, 总数)。"""
conditions = [Event.run_id == run_id]
if visibility is not None:
conditions.append(Event.visibility == visibility)
total = int(
(
await session.execute(select(func.count()).select_from(Event).where(*conditions))
).scalar_one()
)
result = await session.execute(
select(Event).where(*conditions).order_by(Event.sequence.asc()).offset(offset).limit(limit)
)
return list(result.scalars().all()), total
async def list_public_events_after(
session: AsyncSession, run_id: str, *, after_sequence: int = 0, limit: int = 100
) -> list[Event]:
result = await session.execute(
select(Event)
.where(
Event.run_id == run_id,
Event.visibility == "public",
Event.sequence > after_sequence,
)
.order_by(Event.sequence.asc())
.limit(limit)
)
return list(result.scalars().all())
async def list_events_by_trace(session: AsyncSession, trace_id: str) -> list[Event]:
"""链路追踪:按 trace_id 查询运行事件,按 created_at 升序(与审计/幂等归并排序)。"""
result = await session.execute(
select(Event).where(Event.trace_id == trace_id).order_by(Event.created_at.asc())
)
return list(result.scalars().all())
async def summarize_by_trace(session: AsyncSession):
"""GROUP BY trace_id:(trace_id, 计数, 最近时间),仅 trace_id 非空。供链路追踪列表聚合。"""
result = await session.execute(
select(Event.trace_id, func.count(), func.max(Event.created_at))
.where(Event.trace_id.is_not(None))
.group_by(Event.trace_id)
)
return [(row[0], int(row[1]), row[2]) for row in result.all()]
async def list_session_ids_by_runs(session: AsyncSession, run_ids: list[str]) -> list[str]:
"""按 run_id 集合查询关联的 chat session_id 去重列表(链路追踪:run → 对话)。"""
if not run_ids:
return []
result = await session.execute(
select(Run.session_id).where(Run.id.in_(run_ids), Run.session_id.is_not(None))
)
return sorted({row[0] for row in result.all() if row[0]})
+32
View File
@@ -0,0 +1,32 @@
"""Skill 数据访问。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.skills import Skill
async def add_skill(session: AsyncSession, skill: Skill) -> None:
session.add(skill)
await session.flush()
async def get_skill(session: AsyncSession, skill_id: str) -> Skill | None:
return await session.get(Skill, skill_id)
async def get_skill_by_name(session: AsyncSession, name: str) -> Skill | None:
result = await session.execute(select(Skill).where(Skill.name == name))
return result.scalar_one_or_none()
async def list_skills(session: AsyncSession, *, status: str | None = None) -> list[Skill]:
conditions = []
if status is not None:
conditions.append(Skill.status == status)
stmt = select(Skill)
if conditions:
stmt = stmt.where(*conditions)
stmt = stmt.order_by(Skill.created_at.desc())
result = await session.execute(stmt)
return list(result.scalars().all())
@@ -0,0 +1,49 @@
"""时态纪念日数据访问(辰正 §5.3)。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.time_anniversary import TimeAnniversary
async def create_anniversary(
session: AsyncSession, anniversary: TimeAnniversary
) -> TimeAnniversary:
session.add(anniversary)
await session.flush()
await session.refresh(anniversary)
return anniversary
async def get_anniversary(session: AsyncSession, anniversary_id: str) -> TimeAnniversary | None:
return await session.get(TimeAnniversary, anniversary_id)
async def list_anniversaries(session: AsyncSession, character_id: str) -> list[TimeAnniversary]:
result = await session.execute(
select(TimeAnniversary)
.where(TimeAnniversary.character_id == character_id)
.order_by(TimeAnniversary.date.asc())
)
return list(result.scalars().all())
async def update_anniversary(
session: AsyncSession, anniversary_id: str, **fields: object
) -> TimeAnniversary | None:
anniversary = await session.get(TimeAnniversary, anniversary_id)
if anniversary is None:
return None
for key, value in fields.items():
setattr(anniversary, key, value)
await session.flush()
return anniversary
async def delete_anniversary(session: AsyncSession, anniversary_id: str) -> bool:
anniversary = await session.get(TimeAnniversary, anniversary_id)
if anniversary is None:
return False
await session.delete(anniversary)
await session.flush()
return True
+104
View File
@@ -0,0 +1,104 @@
"""世界模型运行时数据访问(api-spec §12,按人物隔离)。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.world import CharacterWorldEntity, WorldRelation
async def list_entities(
session: AsyncSession,
character_id: str,
*,
entity_type: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[CharacterWorldEntity], int]:
conditions = [CharacterWorldEntity.character_id == character_id]
if entity_type is not None:
conditions.append(CharacterWorldEntity.type == entity_type)
if status is not None:
conditions.append(CharacterWorldEntity.status == status)
total = (
await session.execute(
select(func.count()).select_from(CharacterWorldEntity).where(*conditions)
)
).scalar_one()
items_result = await session.execute(
select(CharacterWorldEntity)
.where(*conditions)
.order_by(CharacterWorldEntity.updated_at.desc())
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
)
return list(items_result.scalars().all()), int(total or 0)
async def get_entity(session: AsyncSession, entity_id: str) -> CharacterWorldEntity | None:
return await session.get(CharacterWorldEntity, entity_id)
async def add_entity(session: AsyncSession, entity: CharacterWorldEntity) -> CharacterWorldEntity:
session.add(entity)
await session.flush()
return entity
async def list_relations(
session: AsyncSession,
character_id: str,
*,
page: int = 1,
page_size: int = 20,
) -> tuple[list[WorldRelation], int]:
conditions = [WorldRelation.character_id == character_id]
total = (
await session.execute(select(func.count()).select_from(WorldRelation).where(*conditions))
).scalar_one()
items_result = await session.execute(
select(WorldRelation)
.where(*conditions)
.order_by(WorldRelation.updated_at.desc())
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
)
return list(items_result.scalars().all()), int(total or 0)
async def get_relation(session: AsyncSession, relation_id: str) -> WorldRelation | None:
return await session.get(WorldRelation, relation_id)
async def add_relation(session: AsyncSession, relation: WorldRelation) -> WorldRelation:
session.add(relation)
await session.flush()
return relation
async def delete_relation(session: AsyncSession, relation_id: str) -> None:
"""删除世界关系。"""
relation = await session.get(WorldRelation, relation_id)
if relation:
await session.delete(relation)
await session.flush()
async def list_all_entities(session: AsyncSession, character_id: str) -> list[CharacterWorldEntity]:
"""获取人物的所有世界实体(无分页)。"""
result = await session.execute(
select(CharacterWorldEntity)
.where(CharacterWorldEntity.character_id == character_id)
.order_by(CharacterWorldEntity.updated_at.desc())
)
return list(result.scalars().all())
async def list_all_relations(session: AsyncSession, character_id: str) -> list[WorldRelation]:
"""获取人物的所有关系(无分页)。"""
result = await session.execute(
select(WorldRelation)
.where(WorldRelation.character_id == character_id)
.order_by(WorldRelation.updated_at.desc())
)
return list(result.scalars().all())