M2b:删除旧 chat 体系——线性会话/叩心/情绪/纪念日模型与页面退役(迁移 003 双向验证),runs 去会话外键,测试全量修剪

This commit is contained in:
chenxin committed 2026-08-21 23:43:31 +08:00
1 parent 5f36e3785c
commit ad9c9e942a
22 files changed
+424 -427

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
# 后端开发
## 技术栈
Python 3.12 + FastAPI + SQLAlchemy + Alembic + loguru + httpx + chromadb
uv (用于虚拟环境和依赖管理)
## 开发命令
启动命令之前确保目前在backend目录下,并且已经激活了虚拟环境。默认本地开发使用 SQLite(aiosqlite)文件数据库,Redis、ChromaDB、Neo4j 暂不启用
```bash
uv venv -p 3.12 # 创建虚拟环境
source .venv/bin/activate # 激活虚拟环境
uv add <pkg> # 添加依赖
uv remove <pkg> # 移除依赖
uv run python -m app.main # 按 .env 中 BACKEND_HOST/BACKEND_PORT 启动服务
uv run uvicorn app.main:app --reload --port 8000 # 手动指定端口启动服务(命令行 --port 会覆盖 .env)
# 数据库迁移
uv run alembic revision --autogenerate --rev-id=001 -m "描述" # 生成迁移
uv run alembic upgrade head # 执行迁移
uv run alembic downgrade -1 # 回滚一个版本
uv run alembic current # 查看当前版本
# 测试
uv run pytest # 运行测试
```
## 代码规范
在__init__文件中可以将模块内的类、函数等导出,方便其他模块导入使用
遵循 PEP 8 代码风格指南和更加pythonic的开发方式
遵循单一职责原则,每个文件、类、函数等都要只干一件事
使用 loguru 进行日志记录,避免使用 logging 模块
使用 Alembic 进行数据库迁移,禁止手动修改数据库表结构,文件命名以迁移版本号开头,例如 001_initial.py;迁移必须优先兼容 SQLite,涉及 PostgreSQL 专用 DDL 时必须提供 SQLite 分支或显式跳过
使用alembic init alembic命令初始化迁移环境,使用命令生成相关的迁移文件,不能直接生成迁移文件
使用 httpx 进行异步 HTTP 请求,禁止在路由函数内直接使用同步数据库会话或 httpx 客户端
所有的测试代码都写在tests目录下,测试文件命名以test_开头,测试函数命名以test_开头
所有敏感信息(如密钥、Token、数据库密码)必须通过环境变量或配置文件管理,禁止硬编码到代码中
### 分层硬规矩
- `modules/` 禁止 import `repositories` / `db` / `integrations` / `httpx` / `sqlalchemy`——一旦需要 IO,立即上移到 `services` 或 `application`。
- 路由函数禁止直接 `Depends(get_session)` 或写业务逻辑——改为注入 usecase / service。
- `repositories/` 返回 ORM,禁止把 Query / Session 暴露到上层。
- 模型能力(对话/多模态/嵌入/重排)一律进 `ai_provider/`;存储/检索/外部接入进 `integrations/`。
- 配置一律进 `core/config/`,业务目录不持有 `settings.py` / `config.py`。
- `.env` 只放项目根目录,`Settings` 以绝对路径 `_env_file` 初始化,不使用 `python-dotenv`。
## 代码审查
开发完成后必须使用以下命令进行代码检查:
```bash
uv run ruff check # Lint 检查
uv run ruff check --fix # 自动修复部分代码风格问题
uv run pre-commit run --all-files # 可选:完整运行 CI 级检查(包括类型检查)
```
确保没有任何 lint 错误后再提交。
## 绝对禁止事项
直接修改 pyproject.toml 文件添加或者移除依赖(必须使用 uv add/remove 命令)
在路由函数内直接使用同步数据库会话(必须通过 Depends 注入)
在路由函数内直接 async with httpx.AsyncClient()
使用 logging 模块(统一用 loguru)
硬编码密钥、Token、数据库密码到代码
手动创建/修改数据库表结构(必须使用 Alembic 迁移)
在 `modules/` 中 import `repositories` / `db` / `integrations` / `ai_provider` / `httpx` / `sqlalchemy`(领域层禁止 IO,需要 IO 时上移到 services/application)
在业务目录(`integrations` 等)中持有 `settings.py` / `config.py`(配置统一进 `core/config/`)
使用 `python-dotenv`(`.env` 由 `core/config` 以绝对路径 `_env_file` 加载)
@@ -0,0 +1,114 @@
"""退役旧会话模型:删除 chat/情绪/纪念日表与 runs 旧会话外键
Revision ID: 003
Revises: 002
Create Date: 2026-08-21 23:17:17.055812
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '003'
down_revision: Union[str, Sequence[str], None] = '002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 顺序约束:先去掉 runs.session_id(batch 反射 runs 外键时 chat_sessions 须仍存在),
# 再删除旧会话相关表;SQLite 下 batch 自动处理索引与无名外键。
op.drop_index('ix_runs_session_id', table_name='runs')
with op.batch_alter_table('runs') as batch_op:
batch_op.drop_column('session_id')
op.drop_table('character_emotion_states')
op.drop_index(op.f('ix_chat_sessions_character_id'), table_name='chat_sessions')
op.drop_index(op.f('ix_chat_sessions_owner_scope'), table_name='chat_sessions')
op.drop_index(op.f('ix_chat_sessions_status'), table_name='chat_sessions')
op.drop_table('chat_sessions')
op.drop_index(op.f('ix_chat_messages_character_id'), table_name='chat_messages')
op.drop_index(op.f('ix_chat_messages_run_id'), table_name='chat_messages')
op.drop_index(op.f('ix_chat_messages_session_id'), table_name='chat_messages')
op.drop_table('chat_messages')
op.drop_index(op.f('ix_time_anniversaries_character_id'), table_name='time_anniversaries')
op.drop_table('time_anniversaries')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# 先重建 chat 表,再经 batch 恢复 runs.session_id 列与外键(SQLite 安全)
op.create_table('time_anniversaries',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('name', sa.VARCHAR(length=200), nullable=False),
sa.Column('calendar', sa.VARCHAR(length=20), nullable=False),
sa.Column('date', sa.VARCHAR(length=20), nullable=False),
sa.Column('recurs_yearly', sa.BOOLEAN(), nullable=False),
sa.Column('kind', sa.VARCHAR(length=40), nullable=False),
sa.Column('subject_type', sa.VARCHAR(length=40), nullable=False),
sa.Column('source', sa.VARCHAR(length=40), nullable=False),
sa.Column('metadata_json', sa.TEXT(), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_time_anniversaries_character_id'), 'time_anniversaries', ['character_id'], unique=False)
op.create_table('chat_messages',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('session_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('role', sa.VARCHAR(length=40), nullable=False),
sa.Column('content', sa.TEXT(), nullable=False),
sa.Column('modality', sa.VARCHAR(length=40), nullable=False),
sa.Column('run_id', sa.VARCHAR(length=80), nullable=True),
sa.Column('metadata_json', sa.TEXT(), nullable=False),
sa.Column('sensitivity', sa.VARCHAR(length=40), nullable=False),
sa.Column('versions_json', sa.TEXT(), nullable=False),
sa.Column('active_version_idx', sa.INTEGER(), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('deleted_at', sa.DATETIME(), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ),
sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_messages_session_id'), 'chat_messages', ['session_id'], unique=False)
op.create_index(op.f('ix_chat_messages_run_id'), 'chat_messages', ['run_id'], unique=False)
op.create_index(op.f('ix_chat_messages_character_id'), 'chat_messages', ['character_id'], unique=False)
op.create_table('chat_sessions',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('title', sa.VARCHAR(length=200), nullable=True),
sa.Column('status', sa.VARCHAR(length=40), nullable=False),
sa.Column('owner_scope', sa.VARCHAR(length=80), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_sessions_status'), 'chat_sessions', ['status'], unique=False)
op.create_index(op.f('ix_chat_sessions_owner_scope'), 'chat_sessions', ['owner_scope'], unique=False)
op.create_index(op.f('ix_chat_sessions_character_id'), 'chat_sessions', ['character_id'], unique=False)
op.create_table('character_emotion_states',
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
sa.Column('mood', sa.VARCHAR(length=40), nullable=False),
sa.Column('intensity', sa.FLOAT(), nullable=False),
sa.Column('baseline_mood', sa.VARCHAR(length=40), nullable=False),
sa.Column('baseline_intensity', sa.FLOAT(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.Column('last_trigger_event_id', sa.VARCHAR(length=80), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('character_id')
)
with op.batch_alter_table('runs') as batch_op:
batch_op.add_column(sa.Column('session_id', sa.String(length=80), nullable=True))
batch_op.create_foreign_key('fk_runs_session_id', 'chat_sessions', ['session_id'], ['id'])
batch_op.create_index('ix_runs_session_id', ['session_id'])
# ### end Alembic commands ###
-4
View File
@@ -9,7 +9,6 @@ from app.api.v1.endpoints import (
avatar, avatar,
character_import, character_import,
characters, characters,
chat,
drive, drive,
events, events,
evolution, evolution,
@@ -30,7 +29,6 @@ from app.api.v1.endpoints import (
sandbox_files, sandbox_files,
settings, settings,
skills, skills,
time,
tools, tools,
world, world,
) )
@@ -40,7 +38,6 @@ api_router.include_router(health.router)
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(characters.router) api_router.include_router(characters.router)
api_router.include_router(character_import.router) api_router.include_router(character_import.router)
api_router.include_router(chat.router)
api_router.include_router(runs.router) api_router.include_router(runs.router)
api_router.include_router(kernel_runs.router) api_router.include_router(kernel_runs.router)
api_router.include_router(embryo_drafts.router) api_router.include_router(embryo_drafts.router)
@@ -66,4 +63,3 @@ api_router.include_router(goals.router)
api_router.include_router(admin.router) api_router.include_router(admin.router)
api_router.include_router(audio.router) api_router.include_router(audio.router)
api_router.include_router(avatar.router) api_router.include_router(avatar.router)
api_router.include_router(time.router)
-2
View File
@@ -38,7 +38,6 @@ router = APIRouter(prefix="/runs", tags=["runs"])
@router.get("", response_model=Page[RunResponse]) @router.get("", response_model=Page[RunResponse])
async def list_runs( async def list_runs(
character_id: str | None = Query(default=None, description="按人物过滤。"), character_id: str | None = Query(default=None, description="按人物过滤。"),
session_id: str | None = Query(default=None, description="按会话过滤。"),
status: str | None = Query(default=None, description="按状态过滤。"), status: str | None = Query(default=None, description="按状态过滤。"),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
@@ -46,7 +45,6 @@ async def list_runs(
"""Run 列表(按创建时间倒序);归档页运行时间河数据源。""" """Run 列表(按创建时间倒序);归档页运行时间河数据源。"""
return await list_runs_response( return await list_runs_response(
character_id=character_id, character_id=character_id,
session_id=session_id,
status=status, status=status,
page=page, page=page,
page_size=page_size, page_size=page_size,
-10
View File
@@ -30,12 +30,10 @@ from app.core.logging import (
from app.db.init_data import seed_default_data from app.db.init_data import seed_default_data
from app.db.session import AsyncSessionLocal, close_engine from app.db.session import AsyncSessionLocal, close_engine
from app.integrations.sandbox.client import SandboxClient from app.integrations.sandbox.client import SandboxClient
from app.modules.knocking.pivot import KnockingPivot
from app.modules.safety.confirmation import InMemoryConfirmationStore from app.modules.safety.confirmation import InMemoryConfirmationStore
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.services.dispatcher import Dispatcher from app.services.dispatcher import Dispatcher
from app.services.event_store import DbEventStore from app.services.event_store import DbEventStore
from app.services.personality_emotion import handle_time_event
from app.services.safety import load_governor_from_db from app.services.safety import load_governor_from_db
# 模块级初始化日志系统:import 本模块即完成 loguru 配置并接管标准 logging, # 模块级初始化日志系统:import 本模块即完成 loguru 配置并接管标准 logging,
@@ -117,12 +115,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 一炁进程内订阅分发器:订阅 EventStore 全量事件,按 glob pattern 分发给注册的 handler。 # 一炁进程内订阅分发器:订阅 EventStore 全量事件,按 glob pattern 分发给注册的 handler。
# 各枢核心就绪后在 start() 前 register;lifespan 管理 start/stop。 # 各枢核心就绪后在 start() 前 register;lifespan 管理 start/stop。
_app.state.dispatcher = Dispatcher(_app.state.event_store) _app.state.dispatcher = Dispatcher(_app.state.event_store)
# 灵核情绪状态机:订阅辰正 time.* 事件驱动 EmotionState(设计 §11,首个真实 handler)
_app.state.dispatcher.register("time.*", handle_time_event)
await _app.state.dispatcher.start() await _app.state.dispatcher.start()
# 叩心枢主动对话守护进程:后台常驻循环,在 dispatcher 就绪后启动
_app.state.knocking_pivot = KnockingPivot(_app.state.event_store)
await _app.state.knocking_pivot.start()
_app.state.confirmation_store = InMemoryConfirmationStore() _app.state.confirmation_store = InMemoryConfirmationStore()
if settings.use_db_confirmation_store: if settings.use_db_confirmation_store:
from app.modules.safety.confirmation import DbConfirmationStore from app.modules.safety.confirmation import DbConfirmationStore
@@ -177,9 +170,6 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
yield yield
# 叩心枢先停,再停 dispatcher
if getattr(_app.state, "knocking_pivot", None) is not None:
await _app.state.knocking_pivot.stop()
# Dispatcher 先停(取消 pump/consumer task + unsubscribe all_queue),再关 http/engine # Dispatcher 先停(取消 pump/consumer task + unsubscribe all_queue),再关 http/engine
if getattr(_app.state, "dispatcher", None) is not None: if getattr(_app.state, "dispatcher", None) is not None:
await _app.state.dispatcher.stop() await _app.state.dispatcher.stop()
-7
View File
@@ -3,7 +3,6 @@
from app.models.big_cycle import BigCycle from app.models.big_cycle import BigCycle
from app.models.candidates import MemoryCandidate, ProfileCandidate from app.models.candidates import MemoryCandidate, ProfileCandidate
from app.models.character import Character from app.models.character import Character
from app.models.chat import ChatMessage, ChatSession
from app.models.drive import CuriosityQuestion, ExploratorySubgoal from app.models.drive import CuriosityQuestion, ExploratorySubgoal
from app.models.evolution import EvolutionCandidate from app.models.evolution import EvolutionCandidate
from app.models.governance import ( from app.models.governance import (
@@ -28,12 +27,10 @@ from app.models.mirror_tree import (
) )
from app.models.memory import Memory, MemoryLink from app.models.memory import Memory, MemoryLink
from app.models.personality import PersonalityProposal, PersonalityVersion from app.models.personality import PersonalityProposal, PersonalityVersion
from app.models.personality_emotion import CharacterEmotionState
from app.models.rbac import Permission, Role, RolePermission, UserRole from app.models.rbac import Permission, Role, RolePermission, UserRole
from app.models.runtime import Event, Run from app.models.runtime import Event, Run
from app.models.safety import AuditEvent, PendingConfirmationRecord from app.models.safety import AuditEvent, PendingConfirmationRecord
from app.models.skills import Skill from app.models.skills import Skill
from app.models.time_anniversary import TimeAnniversary
from app.models.user import User from app.models.user import User
from app.models.world import CharacterWorldEntity, WorldRelation from app.models.world import CharacterWorldEntity, WorldRelation
@@ -42,11 +39,8 @@ __all__ = [
"AuditEvent", "AuditEvent",
"BigCycle", "BigCycle",
"Character", "Character",
"CharacterEmotionState",
"CharacterImportJob", "CharacterImportJob",
"CharacterWorldEntity", "CharacterWorldEntity",
"ChatMessage",
"ChatSession",
"CuriosityQuestion", "CuriosityQuestion",
"Event", "Event",
"EvolutionCandidate", "EvolutionCandidate",
@@ -72,7 +66,6 @@ __all__ = [
"RolePermission", "RolePermission",
"Run", "Run",
"Skill", "Skill",
"TimeAnniversary",
"User", "User",
"UserRole", "UserRole",
"WorldEntity", "WorldEntity",
+2 -4
View File
@@ -14,13 +14,11 @@ class Run(Base):
__tablename__ = "runs" __tablename__ = "runs"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_run_id) id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_run_id)
# rewrite/1.0:照心试演(kind=rehearsal)无正式人物,character_id 允许为空 # rewrite/1.0:照心试演(kind=rehearsal)无正式人物,character_id 允许为空;
# 旧 chat_sessions 外键随线性会话模型退役移除
character_id: Mapped[str | None] = mapped_column( character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), index=True, nullable=True String(80), ForeignKey("characters.id"), index=True, nullable=True
) )
session_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("chat_sessions.id"), index=True
)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued", index=True) status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued", index=True)
# run.kind:foreground / background_task / awakening / rehearsal(契约词典 §8.2) # run.kind:foreground / background_task / awakening / rehearsal(契约词典 §8.2)
kind: Mapped[str] = mapped_column( kind: Mapped[str] = mapped_column(
+1 -12
View File
@@ -14,17 +14,14 @@ async def list_runs(
session: AsyncSession, session: AsyncSession,
*, *,
character_id: str | None = None, character_id: str | None = None,
session_id: str | None = None,
status: str | None = None, status: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
) -> tuple[list[Run], int]: ) -> tuple[list[Run], int]:
"""按创建时间倒序分页返回 Run;可选按人物/会话/状态过滤,返回 (条目, 总数)。""" """按创建时间倒序分页返回 Run;可选按人物/状态过滤,返回 (条目, 总数)。"""
conds = [] conds = []
if character_id is not None: if character_id is not None:
conds.append(Run.character_id == character_id) 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: if status is not None:
conds.append(Run.status == status) conds.append(Run.status == status)
count_stmt = select(func.count()).select_from(Run) count_stmt = select(func.count()).select_from(Run)
@@ -127,11 +124,3 @@ async def summarize_by_trace(session: AsyncSession):
return [(row[0], int(row[1]), row[2]) for row in result.all()] 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]})
-1
View File
@@ -69,7 +69,6 @@ class RunResponse(BaseModel):
character_id: str | None = Field( character_id: str | None = Field(
default=None, description="所属人物 ID;照心试演(kind=rehearsal)无正式人物。" default=None, description="所属人物 ID;照心试演(kind=rehearsal)无正式人物。"
) )
session_id: str | None = Field(default=None, description="关联会话 ID。")
status: str = Field( status: str = Field(
description="运行状态:queued / running / completed / failed / cancelled / interrupted。" description="运行状态:queued / running / completed / failed / cancelled / interrupted。"
) )
+2 -13
View File
@@ -262,14 +262,11 @@ async def get_trace(trace_id: str) -> TraceDetail:
*[audit.character_id for audit in audits if audit.character_id], *[audit.character_id for audit in audits if audit.character_id],
} }
) )
session_ids = (
sorted(await runtime_repo.list_session_ids_by_runs(session, run_ids)) if run_ids else []
)
return TraceDetail( return TraceDetail(
trace_id=trace_id, trace_id=trace_id,
character_ids=character_ids, character_ids=character_ids,
run_ids=run_ids, run_ids=run_ids,
session_ids=session_ids, session_ids=[],
items=items, items=items,
) )
@@ -342,15 +339,7 @@ async def list_traces(*, limit: int = 50) -> TraceListResponse:
).all(): ).all():
_entry(tid)["character_ids"].add(cid) _entry(tid)["character_ids"].add(cid)
# 对话归属(events.run_id → runs.session_id) # 对话归属随线性会话模型退役;镜树归属随 M2c 司命台重设计接入(tree_id 聚合)
for tid, sid in (
await session.execute(
select(Event.trace_id, Run.session_id)
.join(Run, Event.run_id == Run.id)
.where(Event.trace_id.is_not(None), Run.session_id.is_not(None))
)
).all():
_entry(tid)["session_ids"].add(sid)
total = len(merged) total = len(merged)
ordered = sorted(merged.values(), key=lambda e: e["last_seen"], reverse=True) ordered = sorted(merged.values(), key=lambda e: e["last_seen"], reverse=True)
+5 -41
View File
@@ -10,7 +10,6 @@ from app.core.time import utc_now
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.character import Character from app.models.character import Character
from app.models.personality import PersonalityVersion from app.models.personality import PersonalityVersion
from app.models.time_anniversary import TimeAnniversary
from app.modules.character import ( from app.modules.character import (
MODEL_POLICY_CREATE_DEFAULTS, MODEL_POLICY_CREATE_DEFAULTS,
SYSTEM_TEMPLATE_OWNER_SCOPE, SYSTEM_TEMPLATE_OWNER_SCOPE,
@@ -19,7 +18,6 @@ from app.modules.character import (
) )
from app.repositories import characters as character_repo from app.repositories import characters as character_repo
from app.repositories import personality as personality_repo from app.repositories import personality as personality_repo
from app.repositories import time_anniversaries as anniversary_repo
from app.repositories.json_utils import dumps, loads_dict from app.repositories.json_utils import dumps, loads_dict
from app.schemas.character import ( from app.schemas.character import (
ActivateCharacterResponse, ActivateCharacterResponse,
@@ -232,7 +230,7 @@ async def create_character_from_template(
session: AsyncSession, template: Character, owner_scope: str session: AsyncSession, template: Character, owner_scope: str
) -> Character: ) -> Character:
# 模板的 birthday 仅在兜底(空/缺失)时不继承;显式设置的有效生日原样保留, # 模板的 birthday 仅在兜底(空/缺失)时不继承;显式设置的有效生日原样保留,
# 由 _ensure_default_birthday 写入子角色的 profile 与 time_anniversaries 表。 # 由 _ensure_default_birthday 写入子角色的 profile。
profile = loads_dict(template.profile_json) profile = loads_dict(template.profile_json)
birthday = profile.get("birthday") birthday = profile.get("birthday")
if not (isinstance(birthday, str) and birthday.strip()): if not (isinstance(birthday, str) and birthday.strip()):
@@ -388,51 +386,17 @@ async def _audit_character(session: AsyncSession, character: Character, action:
async def _ensure_default_birthday(session: AsyncSession, character: Character) -> None: async def _ensure_default_birthday(session: AsyncSession, character: Character) -> None:
"""birthday 缺失时用 created_at 兜底:写 profile.birthday 并建一条生日纪念日。 """birthday 缺失时用 created_at 兜底:仅写 profile.birthday。
- 仅在 profile.birthday 为空/缺失时触发默认兜底;用户或模板已设则不覆盖。 纪念日表(time_anniversaries)随旧时间模型退役删除;辰正(M8 重实现)落地后
- 无论 birthday 是否已设,只要角色尚无 birthday 纪念日,就建一条。 生日提醒改由时态感知按 profile.birthday 计算,不再预写行。
- profile.birthday 与 TimeAnniversary.date 取 birthday 值(显式)或 created_at 的 ISO 日期。
- 须在 character flush 后调用(依赖 id 与 created_at);同一事务内随外层 commit 落库。
""" """
profile = loads_dict(character.profile_json) profile = loads_dict(character.profile_json)
birthday = profile.get("birthday") birthday = profile.get("birthday")
if isinstance(birthday, str) and birthday.strip(): if isinstance(birthday, str) and birthday.strip():
# birthday 已设但可能无对应纪念日(如从模板继承),仅 ISO 格式生日补建
existing = await anniversary_repo.list_anniversaries(session, character.id)
if not any(a.kind == "birthday" for a in existing):
try:
datetime.strptime(birthday, "%Y-%m-%d")
except (ValueError, TypeError):
pass
else:
anniversary = TimeAnniversary(
character_id=character.id,
name=f"{character.display_name or character.name}·生辰",
calendar="solar",
date=birthday,
recurs_yearly=True,
kind="birthday",
subject_type="character_self",
source="auto",
)
await anniversary_repo.create_anniversary(session, anniversary)
return return
birthday_iso = character.created_at.strftime("%Y-%m-%d") profile["birthday"] = character.created_at.strftime("%Y-%m-%d")
profile["birthday"] = birthday_iso
character.profile_json = dumps(profile) character.profile_json = dumps(profile)
anniversary = TimeAnniversary(
character_id=character.id,
name=f"{character.display_name or character.name}·生辰",
calendar="solar",
date=birthday_iso,
recurs_yearly=True,
kind="birthday",
subject_type="character_self",
source="auto",
)
await anniversary_repo.create_anniversary(session, anniversary)
async def create_character( async def create_character(
session: AsyncSession, session: AsyncSession,
+1 -5
View File
@@ -28,14 +28,12 @@ async def create_run(
session: AsyncSession, session: AsyncSession,
*, *,
character_id: str, character_id: str,
session_id: str | None,
model_hint: str, model_hint: str,
goal: str = "回复用户消息", goal: str = "回复用户消息",
origin: str = "chat", origin: str = "chat",
) -> Run: ) -> Run:
run = Run( run = Run(
character_id=character_id, character_id=character_id,
session_id=session_id,
status="running", status="running",
goal=goal, goal=goal,
origin=origin, origin=origin,
@@ -175,17 +173,15 @@ async def get_run_response(run_id: str) -> RunResponse:
async def list_runs_response( async def list_runs_response(
*, *,
character_id: str | None = None, character_id: str | None = None,
session_id: str | None = None,
status: str | None = None, status: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
) -> Page[RunResponse]: ) -> Page[RunResponse]:
"""Run 列表(按创建时间倒序);可选按人物/会话/状态过滤。归档页运行时间河数据源。""" """Run 列表(按创建时间倒序);可选按人物/状态过滤。归档页运行时间河数据源。"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
items, total = await runtime_repo.list_runs( items, total = await runtime_repo.list_runs(
session, session,
character_id=character_id, character_id=character_id,
session_id=session_id,
status=status, status=status,
page=page, page=page,
page_size=page_size, page_size=page_size,
-39
View File
@@ -6,7 +6,6 @@ from typing import Any
from app.core.time import to_iso, utc_now from app.core.time import to_iso, utc_now
from app.models.candidates import MemoryCandidate, ProfileCandidate from app.models.candidates import MemoryCandidate, ProfileCandidate
from app.models.character import Character from app.models.character import Character
from app.models.chat import ChatMessage, ChatSession
from app.models.governance import ( from app.models.governance import (
AgentRosterEntry, AgentRosterEntry,
GenerationPreferences, GenerationPreferences,
@@ -33,7 +32,6 @@ from app.schemas.admin import (
) )
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
from app.schemas.character import CharacterResponse, CharacterSummaryResponse from app.schemas.character import CharacterResponse, CharacterSummaryResponse
from app.schemas.chat import ChatMessageResponse, ChatSessionResponse
from app.schemas.imports import ImportItemResponse, ImportJobResponse from app.schemas.imports import ImportItemResponse, ImportJobResponse
from app.schemas.memory import MemoryResponse from app.schemas.memory import MemoryResponse
from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse
@@ -125,42 +123,6 @@ def proposal_to_response(proposal: PersonalityProposal) -> PersonalityProposalRe
) )
def chat_session_to_response(
chat_session: ChatSession,
*,
message_count: int = 0,
last_message_preview: str | None = None,
) -> ChatSessionResponse:
return ChatSessionResponse(
session_id=chat_session.id,
character_id=chat_session.character_id,
title=chat_session.title,
status=chat_session.status,
created_at=to_iso(chat_session.created_at) or "",
updated_at=to_iso(chat_session.updated_at) or "",
message_count=message_count,
last_message_preview=last_message_preview,
)
def chat_message_to_response(message: ChatMessage) -> ChatMessageResponse:
return ChatMessageResponse(
message_id=message.id,
session_id=message.session_id,
character_id=message.character_id,
role=message.role,
content=message.content,
modality=message.modality,
run_id=message.run_id,
metadata=loads_dict(message.metadata_json),
sensitivity=message.sensitivity,
versions=loads_list(message.versions_json),
active_version_idx=message.active_version_idx,
created_at=to_iso(message.created_at) or "",
deleted_at=to_iso(message.deleted_at),
)
def memory_to_response(memory: Memory) -> MemoryResponse: def memory_to_response(memory: Memory) -> MemoryResponse:
return MemoryResponse( return MemoryResponse(
id=memory.id, id=memory.id,
@@ -185,7 +147,6 @@ def run_to_response(run: Run) -> RunResponse:
return RunResponse( return RunResponse(
id=run.id, id=run.id,
character_id=run.character_id, character_id=run.character_id,
session_id=run.session_id,
status=run.status, status=run.status,
kind=run.kind, kind=run.kind,
goal=run.goal, goal=run.goal,
-10
View File
@@ -74,16 +74,6 @@ async def reset_database() -> AsyncGenerator[None, None]:
from app.services.ai_provider_admin import set_endpoint_overrides_cache from app.services.ai_provider_admin import set_endpoint_overrides_cache
set_endpoint_overrides_cache([]) set_endpoint_overrides_cache([])
# 清理可能被前序 TestClient 启动的 KnockingPivot 后台任务,避免写锁冲突
from app.main import app as _app
pivot = getattr(_app.state, "knocking_pivot", None)
if pivot is not None:
try: # noqa: SIM105 — pivot.stop() 是协程,contextlib.suppress 不适用
await pivot.stop()
except Exception:
pass
_app.state.__dict__.pop("knocking_pivot", None)
yield yield
_assert_test_database_url(str(_engine.url)) _assert_test_database_url(str(_engine.url))
async with _engine.begin() as conn: async with _engine.begin() as conn:
@@ -1,55 +1,39 @@
"""人物服务测试:默认生日兜底(创建时未设 birthday → 用 created_at 兜底)。""" """人物服务测试:默认生日兜底(创建时未设 birthday → 用 created_at 兜底)。
纪念日表(time_anniversaries)随旧时间模型退役删除,本文件只验证 profile 兜底;
辰正(M8 重实现)落地后生日提醒改按 profile.birthday 计算。
"""
import pytest import pytest
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.character import Character from app.models.character import Character
from app.repositories import time_anniversaries as anni_repo
from app.repositories.json_utils import loads_dict from app.repositories.json_utils import loads_dict
from app.schemas.character import CreateCharacterRequest from app.schemas.character import CreateCharacterRequest
from app.services.characters import create_character, create_character_from_template from app.services.characters import create_character, create_character_from_template
def _birthday_anniversaries(items: list) -> list:
return [a for a in items if a.kind == "birthday"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_character_without_birthday_applies_default(): async def test_create_character_without_birthday_applies_default():
"""未传 birthday → profile.birthday 与纪念日均取 created_at 的 ISO 日期。""" """未传 birthday → profile.birthday 取 created_at 的 ISO 日期。"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
resp = await create_character(session, CreateCharacterRequest(name="lingxi")) resp = await create_character(session, CreateCharacterRequest(name="lingxi"))
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
character = await session.get(Character, resp.id) character = await session.get(Character, resp.id)
birthday_iso = character.created_at.strftime("%Y-%m-%d") birthday_iso = character.created_at.strftime("%Y-%m-%d")
assert resp.profile["birthday"] == birthday_iso assert resp.profile["birthday"] == birthday_iso
birthday_annivs = _birthday_anniversaries(anniversaries)
assert len(birthday_annivs) == 1
a = birthday_annivs[0]
assert a.date == birthday_iso
assert a.calendar == "solar"
assert a.recurs_yearly is True
assert a.kind == "birthday"
assert a.subject_type == "character_self"
assert a.source == "auto"
# display_name 为 None → 回落到 name
assert a.name == "lingxi·生辰"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_character_with_birthday_keeps_user_value(): async def test_create_character_with_birthday_keeps_user_value():
"""用户传了 birthday(即便为自由文本)→ 保持用户值,且不自动建生日纪念日。""" """用户传了 birthday(即便为自由文本)→ 保持用户值。"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
resp = await create_character( resp = await create_character(
session, session,
CreateCharacterRequest(name="jingwu", profile={"birthday": "霜降之夜"}), CreateCharacterRequest(name="jingwu", profile={"birthday": "霜降之夜"}),
) )
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
assert resp.profile["birthday"] == "霜降之夜" assert resp.profile["birthday"] == "霜降之夜"
assert _birthday_anniversaries(anniversaries) == []
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -58,75 +42,53 @@ async def test_create_character_with_blank_birthday_applies_default():
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
resp = await create_character( resp = await create_character(
session, session,
CreateCharacterRequest(name="chenyu", profile={"birthday": " "}), CreateCharacterRequest(name="yuelan", profile={"birthday": " "}),
) )
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
character = await session.get(Character, resp.id)
birthday_iso = character.created_at.strftime("%Y-%m-%d") async with AsyncSessionLocal() as session:
assert resp.profile["birthday"] == birthday_iso character = await session.get(Character, resp.id)
assert len(_birthday_anniversaries(anniversaries)) == 1 assert resp.profile["birthday"] == character.created_at.strftime("%Y-%m-%d")
def _template(key: str, profile_json: str) -> Character:
return Character(
name=key,
display_name="模板",
status="active",
language="zh-CN",
profile_json=profile_json,
model_policy_json="{}",
privacy_level="normal",
owner_scope="system",
is_system_template=True,
source_template_id=None,
template_key=key,
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_character_from_template_without_birthday_uses_own_created_at(): async def test_create_character_from_template_without_birthday_uses_own_created_at():
"""模板 profile 无 birthday → 子角色用自己的 created_at 兜底。""" """模板 profile 无 birthday → 子角色用自己的 created_at 兜底。"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
template = Character( template = _template("test_tpl", '{"background": "测试模板"}')
name="tpl",
display_name="模板",
status="active",
language="zh-CN",
profile_json='{"background": "测试模板"}', # 无 birthday
model_policy_json="{}",
privacy_level="normal",
owner_scope="system",
is_system_template=True,
source_template_id=None,
template_key="test_tpl",
)
session.add(template) session.add(template)
await session.flush() await session.flush()
character = await create_character_from_template(session, template, "local-user") character = await create_character_from_template(session, template, "local-user")
profile = loads_dict(character.profile_json) profile = loads_dict(character.profile_json)
anniversaries = await anni_repo.list_anniversaries(session, character.id)
birthday_iso = character.created_at.strftime("%Y-%m-%d") assert profile["birthday"] == character.created_at.strftime("%Y-%m-%d")
assert profile["birthday"] == birthday_iso
birthday_annivs = _birthday_anniversaries(anniversaries)
assert len(birthday_annivs) == 1
assert birthday_annivs[0].date == birthday_iso
# display_name 优先于 name
assert birthday_annivs[0].name == "模板·生辰"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_character_from_template_inherits_explicit_birthday(): async def test_create_character_from_template_inherits_explicit_birthday():
"""模板 profile 有显式 birthday → 子角色继承该值,不兜底。""" """模板 profile 有显式 birthday → 子角色继承该值,不兜底。"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
template = Character( template = _template("test_tpl2", '{"birthday": "2020-01-01"}')
name="tpl2",
display_name="模板二",
status="active",
language="zh-CN",
profile_json='{"birthday": "2020-01-01"}',
model_policy_json="{}",
privacy_level="normal",
owner_scope="system",
is_system_template=True,
source_template_id=None,
template_key="test_tpl2",
)
session.add(template) session.add(template)
await session.flush() await session.flush()
character = await create_character_from_template(session, template, "local-user") character = await create_character_from_template(session, template, "local-user")
profile = loads_dict(character.profile_json) profile = loads_dict(character.profile_json)
anniversaries = await anni_repo.list_anniversaries(session, character.id)
assert profile["birthday"] == "2020-01-01" # 继承模板的显式 birthday assert profile["birthday"] == "2020-01-01"
birthday_annivs = _birthday_anniversaries(anniversaries)
assert len(birthday_annivs) == 1
assert birthday_annivs[0].date == "2020-01-01"
assert birthday_annivs[0].name == "模板二·生辰"
+2 -3
View File
@@ -26,7 +26,6 @@ async def _seed_trace_data() -> None:
Run( Run(
id="run_001", id="run_001",
character_id="char_test", character_id="char_test",
session_id="sess_test",
status="completed", status="completed",
origin="chat", origin="chat",
created_at=BASE_TIME, created_at=BASE_TIME,
@@ -105,7 +104,7 @@ async def test_get_trace_aggregates_and_sorts() -> None:
assert detail.trace_id == TRACE_ID assert detail.trace_id == TRACE_ID
assert detail.run_ids == ["run_001"] assert detail.run_ids == ["run_001"]
assert detail.character_ids == ["char_test"] assert detail.character_ids == ["char_test"]
assert detail.session_ids == ["sess_test"] assert detail.session_ids == [] # 会话归属随线性会话退役
# 五条记录按 created_at 升序归并 # 五条记录按 created_at 升序归并
assert [item.kind for item in detail.items] == [ assert [item.kind for item in detail.items] == [
"event", "event",
@@ -180,7 +179,7 @@ async def test_list_traces_aggregates_and_sorts_recent_first() -> None:
assert s.audit_count == 2 assert s.audit_count == 2
assert s.idempotency_count == 1 assert s.idempotency_count == 1
assert s.character_ids == ["char_test"] assert s.character_ids == ["char_test"]
assert s.session_ids == ["sess_test"] assert s.session_ids == [] # 会话归属随线性会话退役
# 按 last_seen 倒序 # 按 last_seen 倒序
assert all( assert all(
resp.items[i].last_seen >= resp.items[i + 1].last_seen for i in range(len(resp.items) - 1) resp.items[i].last_seen >= resp.items[i + 1].last_seen for i in range(len(resp.items) - 1)
-173
View File
@@ -5,10 +5,8 @@ from collections.abc import AsyncGenerator
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from app.ai_provider.types.chat import ChatChunk
from app.api.v1.deps import require_admin from app.api.v1.deps import require_admin
from app.application.agent.runtime import AgentTurnResult from app.application.agent.runtime import AgentTurnResult
from app.application.chat.send_message import drain_pending_streams
from app.main import app from app.main import app
@@ -16,11 +14,6 @@ from app.main import app
async def client( async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str] monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]: ) -> AsyncGenerator[AsyncClient, None]:
async def fake_run_agent_turn(*, on_chunk, **_kwargs: object):
await on_chunk(ChatChunk(delta_content="你好,我是测试模型回复。"))
return AgentTurnResult(content="你好,我是测试模型回复。", reasoning="")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient( async with AsyncClient(
transport=transport, transport=transport,
@@ -41,136 +34,6 @@ async def test_health_and_defaults(client: AsyncClient) -> None:
assert personality.status_code == 200 assert personality.status_code == 200
@pytest.mark.asyncio
async def test_chat_run_and_memory_crud(client: AsyncClient) -> None:
created = await client.post("/api/v1/chat/sessions", json={"title": "测试"})
assert created.status_code == 200
session_id = created.json()["session_id"]
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text"},
)
assert sent.status_code == 200
run_id = sent.json()["run_id"]
await drain_pending_streams()
run = await client.get(f"/api/v1/runs/{run_id}")
assert run.status_code == 200
assert run.json()["status"] == "completed"
memory = await client.post(
"/api/v1/memory",
json={"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"},
)
assert memory.status_code == 200
memory_id = memory.json()["id"]
listed = await client.get("/api/v1/memory")
assert listed.status_code == 200
assert listed.json()["total"] == 1
updated = await client.patch(f"/api/v1/memory/{memory_id}", json={"importance": 0.9})
assert updated.status_code == 200
deleted = await client.delete(f"/api/v1/memory/{memory_id}")
assert deleted.status_code == 200
assert deleted.json()["status"] == "deleted"
@pytest.mark.asyncio
async def test_chat_session_and_messages_query_api(client: AsyncClient) -> None:
created = await client.post("/api/v1/chat/sessions", json={"title": "查询测试"})
assert created.status_code == 200
created_body = created.json()
session_id = created_body["session_id"]
listed = await client.get("/api/v1/chat/sessions")
assert listed.status_code == 200
listed_body = listed.json()
assert listed_body["total"] >= 1
assert any(item["session_id"] == session_id for item in listed_body["items"])
fetched = await client.get(f"/api/v1/chat/sessions/{session_id}")
assert fetched.status_code == 200
fetched_body = fetched.json()
assert fetched_body["session_id"] == session_id
assert fetched_body["title"] == "查询测试"
assert fetched_body["status"] == "active"
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "查询消息", "modality": "text"},
)
assert sent.status_code == 200
# 流式化后 send_message 立即返回,assistant_message_id 此刻为空,需等待后台任务定稿。
await drain_pending_streams()
messages = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
assert messages.status_code == 200
messages_body = messages.json()
assert messages_body["total"] == 2
assert [item["role"] for item in messages_body["items"]] == ["user", "assistant"]
assert messages_body["items"][0]["content"] == "查询消息"
assert messages_body["items"][1]["message_id"].startswith("message_")
missing = await client.get("/api/v1/chat/sessions/session_missing")
assert missing.status_code == 404
assert missing.json()["error"]["code"] == "not_found"
@pytest.mark.asyncio
async def test_chat_session_update_and_delete(client: AsyncClient) -> None:
created = await client.post("/api/v1/chat/sessions", json={"title": "待删除"})
assert created.status_code == 200
session_id = created.json()["session_id"]
# 发两条消息制造关联数据(覆盖 messages 软删路径)
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "占位消息", "modality": "text"},
)
assert sent.status_code == 200
await drain_pending_streams()
messages_before = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
assert messages_before.status_code == 200
assert messages_before.json()["total"] == 2
# PATCH 重命名
renamed = await client.patch(f"/api/v1/chat/sessions/{session_id}", json={"title": "重命名后"})
assert renamed.status_code == 200
assert renamed.json()["title"] == "重命名后"
assert renamed.json()["status"] == "active"
# PATCH 归档
archived = await client.patch(
f"/api/v1/chat/sessions/{session_id}", json={"status": "archived"}
)
assert archived.status_code == 200
assert archived.json()["status"] == "archived"
# PATCH 禁止直接置 deleted
bad = await client.patch(f"/api/v1/chat/sessions/{session_id}", json={"status": "deleted"})
assert bad.status_code == 400
# DELETE 软删除 → 204
deleted = await client.delete(f"/api/v1/chat/sessions/{session_id}")
assert deleted.status_code == 204
# 删除后 GET 该会话 404
gone = await client.get(f"/api/v1/chat/sessions/{session_id}")
assert gone.status_code == 404
# 列表不再包含
listed = await client.get("/api/v1/chat/sessions")
assert listed.status_code == 200
assert not any(item["session_id"] == session_id for item in listed.json()["items"])
# 消息也被软删:总条数为 0
messages_after = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
assert messages_after.status_code == 404 # 会话已 deleted,查询应拒绝
# 重复删除仍返回 404
repeat = await client.delete(f"/api/v1/chat/sessions/{session_id}")
assert repeat.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_personality_propose_approve_reject_and_rollback(client: AsyncClient) -> None: async def test_personality_propose_approve_reject_and_rollback(client: AsyncClient) -> None:
# 初始当前人格为 v1 # 初始当前人格为 v1
@@ -252,42 +115,6 @@ async def test_personality_propose_approve_reject_and_rollback(client: AsyncClie
app.dependency_overrides.pop(require_admin, None) app.dependency_overrides.pop(require_admin, None)
@pytest.mark.asyncio
async def test_message_version_selection(client: AsyncClient) -> None:
"""选择/激活消息版本——覆盖 services/chat.select_message_version 路径。"""
created = await client.post("/api/v1/chat/sessions", json={"title": "版本测试"})
assert created.status_code == 200
session_id = created.json()["session_id"]
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "原始内容", "modality": "text"},
)
assert sent.status_code == 200
await drain_pending_streams()
messages = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
assert messages.status_code == 200
items = messages.json()["items"]
assert len(items) == 2
assistant_msg = next(m for m in items if m["role"] == "assistant")
msg_id = assistant_msg["message_id"]
# 选择不存在的版本索引 → 400
bad = await client.patch(
f"/api/v1/chat/sessions/{session_id}/messages/{msg_id}/version",
json={"version_idx": 99},
)
assert bad.status_code == 400
# 选择索引 0(当前版本)
ok = await client.patch(
f"/api/v1/chat/sessions/{session_id}/messages/{msg_id}/version",
json={"version_idx": 0},
)
assert ok.status_code == 200
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skills_crud(client: AsyncClient) -> None: async def test_skills_crud(client: AsyncClient) -> None:
"""Skill 能力包 CRUD——覆盖 services/skills 路径。""" """Skill 能力包 CRUD——覆盖 services/skills 路径。"""
+2 -17
View File
@@ -34,23 +34,8 @@ async def test_error_contracts(client: AsyncClient) -> None:
assert missing_run.status_code == 404 assert missing_run.status_code == 404
assert_error(missing_run, "not_found") assert_error(missing_run, "not_found")
validation = await client.post("/api/v1/chat/sessions", json={"title": "x" * 300}) validation = await client.post("/api/v1/runs", json={"prompt": ""})
assert validation.status_code == 422 assert validation.status_code == 422
assert_error(validation, "validation_failed") assert_error(validation, "validation_failed")
created = await client.post("/api/v1/chat/sessions", json={"title": "错误"}) # 旧 chat 会话错误契约随线性会话模型退役;415/413 契约由镜树/媒体域随需要重建
session_id = created.json()["session_id"]
unsupported = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "image"},
)
assert unsupported.status_code == 415
assert_error(unsupported, "unsupported_media_type")
too_large = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "x" * 40000, "modality": "text"},
)
# 当前消息内容无长度上限限制,接受 200;若后续加回来再改为 assert 413
if too_large.status_code == 413:
assert_error(too_large, "payload_too_large")
+3 -3
View File
@@ -30,7 +30,7 @@ async def test_event_payload_limit(monkeypatch: pytest.MonkeyPatch) -> None:
character_id = await _admin_character_id() character_id = await _admin_character_id()
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
run = await create_run( run = await create_run(
db, character_id=character_id, session_id=None, model_hint="default-chat" db, character_id=character_id, model_hint="default-chat"
) )
with pytest.raises(AppError) as exc: with pytest.raises(AppError) as exc:
await append_event( await append_event(
@@ -49,7 +49,7 @@ async def test_run_max_events(monkeypatch: pytest.MonkeyPatch) -> None:
character_id = await _admin_character_id() character_id = await _admin_character_id()
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
run = await create_run( run = await create_run(
db, character_id=character_id, session_id=None, model_hint="default-chat" db, character_id=character_id, model_hint="default-chat"
) )
await append_event( await append_event(
db, run_id=run.id, character_id=character_id, event_type="message.delta", payload={} db, run_id=run.id, character_id=character_id, event_type="message.delta", payload={}
@@ -70,7 +70,7 @@ async def test_cancel_run() -> None:
character_id = await _admin_character_id() character_id = await _admin_character_id()
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
run = await create_run( run = await create_run(
db, character_id=character_id, session_id=None, model_hint="default-chat" db, character_id=character_id, model_hint="default-chat"
) )
await db.commit() await db.commit()
response = await cancel_run(run.id) response = await cancel_run(run.id)
+187
View File
@@ -0,0 +1,187 @@
# 前端开发规范
## 技术栈
Next.js + TypeScript + React + Tailwind CSS + Zustand/Jotai + pnpm
### 核心依赖
- **实时通信**: SSE / WebSocket
- **可视化**: Mermaid + 图谱可视化库
- **测试**: Vitest + Testing Library
- **代码质量**: ESLint (flat config) + Prettier + Husky + lint-staged
## 开发命令
```bash
pnpm install # 安装依赖
pnpm add <pkg> # 添加依赖
pnpm remove <pkg> # 移除依赖
pnpm dev # 启动开发服务器 (默认端口 3000)
pnpm build # 构建生产版本
pnpm start # 启动生产服务器
pnpm lint # 运行 ESLint 检查
pnpm type-check # TypeScript 类型检查
# 代码格式化(Prettier)
pnpm format # 格式化全部文件
pnpm format:check # 检查格式是否合规(CI / 提交钩子使用)
# 测试(Vitest)
pnpm test # 运行全部测试(单次)
pnpm test:watch # 监听模式
pnpm test:coverage # 运行测试并生成覆盖率报告
```
## 代码规范
### 页面设计
图标:统一使用 Lucide 图标,界面全程禁止使用表情符号。
设计标准:对标 Awwwards 顶级网站水准,达到 Awwwards、FWA、CSS Design Awards 每日最佳网站同等设计品质。
创意自由度:将浏览器视作交互式艺术画布,跳出传统布局框架,追求先锋视觉风格、实验性排版、流畅物理动效、极具冲击力的文字版式。
沉浸式体验:融合代码、高级渲染逻辑、图片素材,打造统一完整的精品页面,做出突破常规 UI 认知、令人惊艳的数字交互体验。
### 通用规范
- 遵循 ESLint + Prettier 配置,提交前必须通过 lint 与格式检查
- 使用 TypeScript 严格模式 (`strict: true`),禁止使用 `any` 类型
- 组件使用函数式组件 + Hooks,优先使用 Server Components,必要时使用 `'use client'`
- 文件命名: 组件使用 PascalCase (`UserProfile.tsx`),工具函数使用 camelCase (`formatDate.ts`)
- 使用 `path` 别名 (`@/*`) 导入模块,避免相对路径层级过深
- 状态管理: 全局状态使用 Zustand/Jotai,局部状态使用 React useState/useReducer
### CSS 变量引用
CSS 变量引用遵循 **三层递进规则**,优先级由上至下。
#### 规则
```text
Tailwind 映射类 → 优先使用
Tailwind 任意值 [var(--)] → 仅当变量不在 @theme 映射表中时使用
内联 style → 仅限复合值 / 动态运行时值
```
#### 具体对照
`globals.css` 的 `@theme inline {}` 块已映射的部分,直接用 Tailwind 类:
```tsx
// ✅ 正确:变量在 @theme inline {} 中有 --color-X 映射 → 用 Tailwind 类
<div className="bg-patina text-fg-dim border-rule" />
<span className="text-cinnabar" />
<div className="bg-night-bronze text-fg" />
<div className="bg-surface" />
<button className="border-gilt text-gilt" />
<div className="bg-btn-primary-bg" /> {/* Layer 2 组件 token 同理 */}
// ✅ 正确:变量未映射到 @theme inline {} → 用 Tailwind 任意值
<div className="bg-[var(--patina-wash)]" />
<span className="text-[var(--fg-faint)]" />
<div className="border-[var(--gilt-dim)]" />
<div className="bg-[var(--cinnabar-wash)]" />
```
> `bg-[var(--x)]` 是 Tailwind v4 的合法任意值语法(非 hack),属于 Tailwind 体系内,**不是** style 违规。`--patina-wash`、`--patina-dim`、`--gilt-faint`、`--gilt-dim`、`--cinnabar-wash`、`--cinnabar-dim` 当前均未直接映射,使用任意值是正确做法。
```tsx
// ❌ 禁止:变量已在 @theme inline {} 中有映射,却用任意值
// 不要用 bg-[var(--patina)] —— 应写 bg-patina(已映射)
// 不要用 text-[var(--cinnabar)] —— 应写 text-cinnabar(已映射)
// 不要用 border-[var(--rule)] —— 应写 border-rule(已映射)
// 不要用 text-[var(--fg-dim)] —— 应写 text-fg-dim(已映射)
// ❌ 禁止:用内联 style 表达单值 CSS 变量
// 不要写 style={{ color: "var(--patina)" }}
// 不要写 style={{ background: "var(--surface)" }}
// ✅ 正确:渐变色/复合 CSS 用内联 style(Tailwind 无法表达)
<div style={{
background: "linear-gradient(180deg, var(--patina-wash), var(--surface))",
boxShadow: "inset 0 1px 0 var(--face-in-top), var(--shadow)",
}} />
// ✅ 正确:动态运行时值用内联 style
<div style={{ color: health === "ok" ? "var(--patina)" : "var(--cinnabar)" }} />
// ✅ 正确:SVG 属性用内联 style 或 className
<circle style={{ fill: "var(--patina)", stroke: "var(--gilt)" }} />
```
#### 映射速查
当前 `@theme inline {}` 中已映射的 铜镜鉴心 变量(查阅 `globals.css` 确认完整列表):
| CSS 变量 | Tailwind 类前缀 | 示例 |
| ---------- | ---------------- | ------ |
| `--patina` | `patina` | `bg-patina` `text-patina` `border-patina` |
| `--patina-bright` | `patina-bright` | `bg-patina-bright` |
| `--gilt` | `gilt` | `bg-gilt` `text-gilt` |
| `--cinnabar` | `cinnabar` | `bg-cinnabar` `text-cinnabar` |
| `--ink` | `ink` | `bg-ink` |
| `--night-bronze` | `night-bronze` | `bg-night-bronze` |
| `--fg` | `fg` | `text-fg` |
| `--fg-dim` | `fg-dim` | `text-fg-dim` |
| `--fg-faint` | `fg-faint` | `text-fg-faint` |
| `--rule` | `rule` | `border-rule` `bg-rule` |
| `--surface` | `surface` | `bg-surface` |
| `--surface-hi` | `surface-hi` | `bg-surface-hi` |
| `--patina-wash` | `patina-wash` | `bg-patina-wash` |
| `--patina-dim` | `patina-dim` | `border-patina-dim` `bg-patina-dim` |
| `--gilt-dim` | `gilt-dim` | `border-gilt-dim` `text-gilt-dim` `bg-gilt-dim` |
| `--gilt-faint` | `gilt-faint` | `bg-gilt-faint` |
| `--cinnabar-wash` | `cinnabar-wash` | `bg-cinnabar-wash` |
| `--cinnabar-dim` | `cinnabar-dim` | `border-cinnabar-dim` |
| `--glass` | `glass` | `bg-glass` |
| `--glass-border` | `glass-border` | `border-glass-border` |
| `--overlay-hover` | `overlay-hover` | `bg-overlay-hover` |
**未映射**(用 `bg-[var(--x)]`):`--face-sheen` `--sweep-gradient` `--face-in-top` `--face-in-bot` `--shadow` `--aurora-*` `--code-*` 以及 SVG 特有的 `--raw-*`
#### 例外清单
以下变量是复合 CSS 值或动画属性,**必须**用内联 style,不能抽象为 Tailwind 类:
- 渐变/光照效果:`--face-sheen` `--sweep-gradient` `--face-in-top` `--face-in-bot`
- 阴影:`--shadow`
- 背景光晕:`--aurora-*`
- 动态 `color-mix()` 表达式
> 规则生效日:此约定写入后,新代码必须遵守。存量代码暂不回溯,待 P0/P1 功能补齐后再做 codemod 迁移。
- 统一通过 `lib/api`(`client.ts` 出口)调用后端,禁止在组件内散落 fetch
- SSE 连接封装在 `lib/sse-client.ts`,不在组件内直接实例化 EventSource
- 所有 API 调用必须有 TypeScript 类型定义和错误处理(`ApiError` 统一承载 code/trace_id)
## 可视化规范
- 图谱可视化使用专用图谱库,Mermaid 用于流程图/时序图等
- 可视化组件必须支持响应式和主题切换 (暗色/亮色)
## 代码审查
```bash
pnpm lint # ESLint 检查
pnpm type-check # TypeScript 类型检查
pnpm format:check # Prettier 格式检查
pnpm test # Vitest 测试
```
- 必须同时通过 lint、type-check 与格式检查才能提交
- Husky + lint-staged 已配置 `pre-commit` 钩子:对暂存文件自动跑 ESLint --fix + Prettier,再整体 type-check;不通过则阻止提交(临时跳过用 `git commit --no-verify`)
- 注:本仓库 git 根在父目录(monorepo),`prepare` 脚本以 `cd .. && husky frontend/.husky` 安装钩子,`core.hooksPath` 指向 `frontend/.husky/_`
## 绝对禁止事项
- 直接修改 `package.json` 或 `pnpm-lock.yaml`(依赖必须用 `pnpm add/remove`;scripts 字段可用 `pnpm pkg set`,注意带冒号的键需用方括号语法 `scripts["name"]`)
- 在客户端代码中硬编码敏感信息 (密钥、Token等)
- 使用 `any` 类型 (除非经过团队评审)
- 忽略 TypeScript 或 ESLint 错误强制提交
- 在 Server Component 中直接使用 `useState`、`useEffect` 等客户端 Hooks
- 使用 CSS-in-JS 方案 (统一使用 Tailwind CSS)
- 在 Client Component 中直接调用数据库
- 在 useEffect 中做页面初始数据请求
- 创建 Class 组件
+1 -12
View File
@@ -7,7 +7,6 @@ import { LogoButton } from "@/components/easter-egg/LogoButton";
import { import {
AdminIcon, AdminIcon,
ArchiveIcon, ArchiveIcon,
ChatIcon,
CharacterIcon, CharacterIcon,
CosmosIcon, CosmosIcon,
DriveIcon, DriveIcon,
@@ -17,7 +16,6 @@ import {
PersonalityIcon, PersonalityIcon,
RunIcon, RunIcon,
SettingsIcon, SettingsIcon,
TimeIcon,
ToolsIcon, ToolsIcon,
WorldIcon, WorldIcon,
WuxingIcon, WuxingIcon,
@@ -53,10 +51,7 @@ interface NavGroup {
const NAV_GROUPS: NavGroup[] = [ const NAV_GROUPS: NavGroup[] = [
{ {
title: "灵台", title: "灵台",
items: [ items: [{ href: "/runs", label: "轨迹", icon: <RunIcon /> }],
{ href: "/chat", label: "对话", icon: <ChatIcon /> },
{ href: "/runs", label: "轨迹", icon: <RunIcon /> },
],
}, },
{ {
title: "法身", title: "法身",
@@ -83,12 +78,6 @@ const NAV_GROUPS: NavGroup[] = [
icon: <WorldIcon />, icon: <WorldIcon />,
enabled: FEATURE_FLAGS.worldPanel, enabled: FEATURE_FLAGS.worldPanel,
}, },
{
href: "/time",
label: "时态",
icon: <TimeIcon />,
enabled: FEATURE_FLAGS.timePanel,
},
], ],
}, },
{ {
-2
View File
@@ -8,7 +8,6 @@
export * from "./client"; export * from "./client";
export * as audioApi from "./audio"; export * as audioApi from "./audio";
export * as charactersApi from "./characters"; export * as charactersApi from "./characters";
export * as chatApi from "./chat";
export * as runsApi from "./runs"; export * as runsApi from "./runs";
export * as sandboxApi from "./sandbox"; export * as sandboxApi from "./sandbox";
export * as memoryApi from "./memory"; export * as memoryApi from "./memory";
@@ -26,4 +25,3 @@ export * as worldApi from "./world";
export * as lorebooksApi from "./lorebooks"; export * as lorebooksApi from "./lorebooks";
export * as skillsApi from "./skills"; export * as skillsApi from "./skills";
export * as mcpApi from "./mcp"; export * as mcpApi from "./mcp";
export * as timeApi from "./time";