初始化提交

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 @@
"""业务服务层。"""
+369
View File
@@ -0,0 +1,369 @@
"""司命台管理聚合服务(api-spec §19.1–§19.3)。
九枢健康度由各枢运行/治理数据派生(不落长期状态,§19.1);朱批待办复用
ConfirmationStore(§19.2);起居注审计复用 audit_events(§19.3)。
"""
import json
from sqlalchemy import func, select
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.config import settings
from app.core.security import redact_text
from app.core.time import to_iso, utc_now
from app.db.session import AsyncSessionLocal
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
WorldEntity,
)
from app.models.idempotency import IdempotencyKey
from app.models.memory import Memory
from app.models.runtime import Event, Run
from app.models.safety import AuditEvent
from app.modules.safety.confirmation import ConfirmationStore, PendingConfirmation
from app.repositories import audit as audit_repo
from app.repositories import characters as character_repo
from app.repositories import governance as governance_repo
from app.repositories import idempotency as idempotency_repo
from app.repositories import runtime as runtime_repo
from app.schemas.admin import (
AuditEntry,
PendingApproval,
PivotHealthReport,
PivotHealthSummary,
TraceDetail,
TraceItem,
TraceListResponse,
TraceSummary,
)
from app.schemas.common import Page
from app.services.serializers import audit_to_response
# 九枢全集(契约字典 §5.9):枢标识、中文名、工程模块名。
_PIVOT_REGISTRY: list[tuple[str, str, str]] = [
("personality_core", "灵核枢", "Personality Core"),
("goal_orchestrator", "太一枢", "Goal Orchestrator"),
("safety_governor", "司命枢", "Safety Governor"),
("perception", "烛照枢", "Perception Layer"),
("world_model", "河图枢", "World Model"),
("planner", "洛书枢", "Planner"),
("memory", "息壤枢", "Memory System"),
("tool_runtime", "百工枢", "Tool Runtime"),
("agent_society", "天工阁枢", "Agent Society"),
]
def _summarize_input(value: dict | None) -> str:
if not value:
return ""
text = json.dumps(value, ensure_ascii=False, default=str)
return redact_text(text)[:200]
async def get_pivots_health(
*,
confirmation_store: ConfirmationStore,
tool_runtime: ToolRuntime | None,
owner_scope: str = settings.default_owner_scope,
) -> PivotHealthReport:
async with AsyncSessionLocal() as session:
pending_count = len(await confirmation_store.list_pending())
goals_n = await governance_repo.count(session, Goal)
world_n = await governance_repo.count(session, WorldEntity)
plans_n = await governance_repo.count(session, Plan)
perception_n = await governance_repo.count(session, PerceptionChannel)
agents_n = await governance_repo.count(session, AgentRosterEntry)
memories_n = await governance_repo.count(session, Memory)
character = await character_repo.get_active_character(session, owner_scope)
has_personality = bool(character and character.current_personality_version_id)
tool_enabled = settings.enable_tool_runtime and tool_runtime is not None
now = to_iso(utc_now())
raw: list[tuple[str, str, str, bool, str]] = [
(
"personality_core",
"steady" if has_personality else "void",
"人格版本已就绪" if has_personality else "人格未初始化",
),
("goal_orchestrator", "steady" if goals_n else "void", f"目标 {goals_n} 项"),
(
"safety_governor",
"cracked" if pending_count else "steady",
f"{pending_count} 项工具调用待裁定" if pending_count else "无待裁定",
),
("perception", "steady" if perception_n else "void", f"感知通道 {perception_n} 项"),
("world_model", "steady" if world_n else "void", f"世界实体 {world_n} 项"),
("planner", "steady" if plans_n else "void", f"计划 {plans_n} 项"),
("memory", "steady" if memories_n else "void", f"记忆 {memories_n} 条"),
(
"tool_runtime",
"breathing" if tool_enabled else "void",
"沙箱运行时在线" if tool_enabled else "工具运行时未启用",
),
("agent_society", "steady" if agents_n else "void", f"在岗角色 {agents_n} 位"),
]
name_map = {pivot: (name, module) for pivot, name, module in _PIVOT_REGISTRY}
pivots: list[PivotHealthSummary] = []
for pivot, health, summary in raw:
name, module = name_map[pivot]
pivots.append(
PivotHealthSummary(
pivot=pivot,
name=name,
module=module,
health=health,
summary=summary,
updated_at=now,
)
)
alerts = sum(1 for item in pivots if item.health == "cracked")
return PivotHealthReport(
overall="cracked" if alerts else "steady", alerts=alerts, pivots=pivots
)
async def get_pending_approvals(
*,
confirmation_store: ConfirmationStore,
tool_runtime: ToolRuntime | None,
) -> list[PendingApproval]:
specs = {spec.name: spec for spec in tool_runtime.list_specs()} if tool_runtime else {}
approvals: list[PendingApproval] = []
for pending in await confirmation_store.list_pending():
spec = specs.get(pending.tool)
permission_policy = str(spec.permission_policy) if spec else "ask"
possible_impact = "可能产生副作用" if (spec and spec.side_effect) else None
approvals.append(_pending_to_approval(pending, permission_policy, possible_impact))
return approvals
def _pending_to_approval(
pending: PendingConfirmation, permission_policy: str, possible_impact: str | None
) -> PendingApproval:
return PendingApproval(
confirmation_id=pending.confirmation_id,
character_id=pending.character_id,
tool=pending.tool,
risk_level=pending.risk_level,
permission_policy=permission_policy,
action_summary=f"调用工具 {pending.tool}",
input_summary=_summarize_input(pending.input),
possible_impact=possible_impact,
reason=pending.reason,
created_at=pending.created_at,
)
async def get_audit_timeline(
*,
pivot: str | None = None,
page: int = 1,
page_size: int = 50,
) -> Page[AuditEntry]:
async with AsyncSessionLocal() as session:
events, total = await audit_repo.list_audit_events(
session, pivot=pivot, page=page, page_size=page_size
)
return Page(
items=[audit_to_response(event) for event in events],
page=page,
page_size=page_size,
total=total,
)
# —— §19.5 链路追踪(按 trace_id 聚合 events / audit_events / idempotency_keys)——
def _event_to_item(event: Event) -> TraceItem:
try:
payload = json.loads(event.payload_json) if event.payload_json else {}
except (json.JSONDecodeError, TypeError):
payload = {}
return TraceItem(
kind="event",
id=event.id,
created_at=event.created_at,
source_label=f"运行事件 / {event.type}",
summary=event.type,
detail={
"run_id": event.run_id,
"source": event.source,
"type": event.type,
"payload": payload,
"sequence": event.sequence,
"parent_id": event.parent_id,
},
)
def _audit_to_item(audit: AuditEvent) -> TraceItem:
parts = [audit.decision, audit.risk_level]
if audit.reason:
parts.append(audit.reason[:60])
return TraceItem(
kind="audit",
id=audit.id,
created_at=audit.created_at,
source_label=f"安全审计 / {audit.action}",
summary=" · ".join(parts),
detail={
"pivot": audit.pivot,
"action": audit.action,
"actor": audit.actor,
"decision": audit.decision,
"risk_level": audit.risk_level,
"reason": audit.reason,
"resource_type": audit.resource_type,
"resource_id": audit.resource_id,
},
)
def _idempotency_to_item(idem: IdempotencyKey) -> TraceItem:
return TraceItem(
kind="idempotency",
id=idem.id,
created_at=idem.created_at,
source_label=f"请求幂等 / {idem.method}",
summary=f"{idem.method} {idem.path} → {idem.response_status_code}",
detail={
"method": idem.method,
"path": idem.path,
"status": idem.status,
"response_status_code": idem.response_status_code,
"scope": idem.scope,
},
)
async def get_trace(trace_id: str) -> TraceDetail:
"""按 trace_id 聚合 events / audit_events / idempotency_keys 三表为升序时间线。"""
async with AsyncSessionLocal() as session:
events = await runtime_repo.list_events_by_trace(session, trace_id)
audits = await audit_repo.list_audit_events_by_trace(session, trace_id)
idempotencies = await idempotency_repo.list_by_trace(session, trace_id)
items: list[TraceItem] = [_event_to_item(event) for event in events]
items.extend(_audit_to_item(audit) for audit in audits)
items.extend(_idempotency_to_item(idem) for idem in idempotencies)
items.sort(key=lambda item: item.created_at)
run_ids = sorted({event.run_id for event in events if event.run_id})
character_ids = sorted(
{
*[event.character_id for event in events if event.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(
trace_id=trace_id,
character_ids=character_ids,
run_ids=run_ids,
session_ids=session_ids,
items=items,
)
async def list_time_events(*, limit: int = 100, offset: int = 0) -> tuple[list[Event], int]:
"""列出辰正 time.* 事件(source='time_sense'),供起居注时间河展示(设计 §8 司命台)。"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Event)
.where(Event.source == "time_sense")
.order_by(Event.created_at.desc())
.limit(limit)
.offset(offset)
)
items = list(result.scalars().all())
count_result = await session.execute(
select(func.count()).select_from(Event).where(Event.source == "time_sense")
)
total = int(count_result.scalar_one())
return items, total
async def list_traces(*, limit: int = 50) -> TraceListResponse:
"""列出最近活动的 trace_id 概览(跨 events / audit_events / idempotency_keys 聚合),
含每条链路涉及的人物与对话归属。"""
async with AsyncSessionLocal() as session:
merged: dict[str, dict[str, object]] = {}
def _entry(tid: str) -> dict[str, object]:
return merged.setdefault(
tid,
{
"trace_id": tid,
"last_seen": None,
"event_count": 0,
"audit_count": 0,
"idempotency_count": 0,
"character_ids": set(),
"session_ids": set(),
},
)
def _absorb(stats: list[tuple[str, int, object]], count_key: str) -> None:
for tid, cnt, last in stats:
entry = _entry(tid)
entry[count_key] = cnt
if last is not None and (entry["last_seen"] is None or last > entry["last_seen"]):
entry["last_seen"] = last
_absorb(await runtime_repo.summarize_by_trace(session), "event_count")
_absorb(await audit_repo.summarize_by_trace(session), "audit_count")
_absorb(await idempotency_repo.summarize_by_trace(session), "idempotency_count")
# 人物归属(events + audits 的 character_id)
for tid, cid in (
await session.execute(
select(Event.trace_id, Event.character_id).where(
Event.trace_id.is_not(None), Event.character_id.is_not(None)
)
)
).all():
_entry(tid)["character_ids"].add(cid)
for tid, cid in (
await session.execute(
select(AuditEvent.trace_id, AuditEvent.character_id).where(
AuditEvent.trace_id.is_not(None),
AuditEvent.character_id.is_not(None),
)
)
).all():
_entry(tid)["character_ids"].add(cid)
# 对话归属(events.run_id → runs.session_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)
ordered = sorted(merged.values(), key=lambda e: e["last_seen"], reverse=True)
items = [
TraceSummary(
trace_id=str(entry["trace_id"]),
last_seen=entry["last_seen"], # type: ignore[arg-type]
character_ids=sorted(entry["character_ids"]), # type: ignore[arg-type]
session_ids=sorted(entry["session_ids"]), # type: ignore[arg-type]
event_count=int(entry["event_count"]),
audit_count=int(entry["audit_count"]),
idempotency_count=int(entry["idempotency_count"]),
)
for entry in ordered[:limit]
]
return TraceListResponse(items=items, total=total)
+216
View File
@@ -0,0 +1,216 @@
"""司命台 · 人物模板管理聚合服务(api-spec §19 扩展)。
系统模板 CRUD,所有写操作记审计并入起居注。
路由直接调用此服务层函数(函数内部自管 ``AsyncSessionLocal``)。
"""
from app.core.config import settings
from app.core.errors import AppError
from app.core.ids import new_character_id
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.repositories import characters as character_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.admin_character import (
AdminCreateTemplateRequest,
AdminTemplateSummaryResponse,
AdminUpdateTemplateRequest,
)
from app.services.audit import record_audit_event
def _template_to_response(template: Character) -> AdminTemplateSummaryResponse:
profile = loads_dict(template.profile_json)
model_policy = loads_dict(template.model_policy_json)
# 从 profile 中推断 is_default(无独立字段,保留为 False 未映射)
return AdminTemplateSummaryResponse(
key=template.template_key or "",
name=template.name,
display_name=template.display_name,
language=template.language,
privacy_level=template.privacy_level,
is_default=False,
profile=profile,
model_policy=model_policy,
created_at=template.created_at.isoformat() if template.created_at else "",
updated_at=template.updated_at.isoformat() if template.updated_at else "",
)
async def admin_list_templates() -> list[AdminTemplateSummaryResponse]:
"""列出所有系统模板(管理端完整视图)。"""
async with AsyncSessionLocal() as session:
templates = await character_repo.list_system_templates(session)
return [_template_to_response(t) for t in templates]
async def admin_get_template(key: str) -> AdminTemplateSummaryResponse:
"""获取单个系统模板。"""
async with AsyncSessionLocal() as session:
template = await character_repo.get_system_template_by_key(session, key)
if template is None:
raise AppError("not_found", f"模板 '{key}' 不存在。")
return _template_to_response(template)
async def admin_create_template(
payload: AdminCreateTemplateRequest,
) -> AdminTemplateSummaryResponse:
"""创建系统模板。"""
async with AsyncSessionLocal() as session:
# 检查是否已存在同 key 模板
existing = await character_repo.get_system_template_by_key(session, payload.key)
if existing is not None:
raise AppError("conflict", f"模板 key '{payload.key}' 已存在。")
profile = dict(payload.profile)
model_policy = dict(payload.model_policy)
model_policy.setdefault("default_chat_model_hint", settings.default_model_hint)
character = Character(
id=new_character_id(),
name=payload.name,
display_name=payload.display_name,
status="active",
language=payload.language,
profile_json=dumps(profile),
model_policy_json=dumps(model_policy),
privacy_level=payload.privacy_level,
owner_scope="system",
is_system_template=True,
source_template_id=None,
template_key=payload.key,
)
session.add(character)
await session.flush()
# birthday 兜底
await _ensure_birthday(character)
await record_audit_event(
session,
action="admin.character_template.created",
character_id=character.id,
actor="admin",
decision="auto",
input_summary=f"管理员创建系统模板 '{payload.key}'",
resource_type="character_template",
resource_id=character.id,
)
await session.commit()
await session.refresh(character)
return _template_to_response(character)
async def admin_update_template(
key: str,
payload: AdminUpdateTemplateRequest,
) -> AdminTemplateSummaryResponse:
"""更新系统模板(部分更新)。"""
async with AsyncSessionLocal() as session:
template = await character_repo.get_system_template_by_key(session, key)
if template is None:
raise AppError("not_found", f"模板 '{key}' 不存在。")
changed = False
update_fields = payload.model_dump(exclude_unset=True)
if "name" in update_fields and template.name != update_fields["name"]:
template.name = update_fields["name"]
changed = True
if "display_name" in update_fields and template.display_name != update_fields["display_name"]:
template.display_name = update_fields["display_name"]
changed = True
if "language" in update_fields and template.language != update_fields["language"]:
template.language = update_fields["language"]
changed = True
if "privacy_level" in update_fields and template.privacy_level != update_fields["privacy_level"]:
template.privacy_level = update_fields["privacy_level"]
changed = True
if "profile" in update_fields:
current_profile = loads_dict(template.profile_json)
current_profile.update(update_fields["profile"])
template.profile_json = dumps(current_profile)
changed = True
if "model_policy" in update_fields:
current_policy = loads_dict(template.model_policy_json)
current_policy.update(update_fields["model_policy"])
template.model_policy_json = dumps(current_policy)
changed = True
if changed:
await record_audit_event(
session,
action="admin.character_template.updated",
character_id=template.id,
actor="admin",
decision="auto",
input_summary=f"管理员更新系统模板 '{key}'",
resource_type="character_template",
resource_id=template.id,
)
template.updated_at = utc_now()
await session.commit()
await session.refresh(template)
return _template_to_response(template)
async def admin_delete_template(key: str) -> None:
"""删除系统模板。"""
async with AsyncSessionLocal() as session:
template = await character_repo.get_system_template_by_key(session, key)
if template is None:
raise AppError("not_found", f"模板 '{key}' 不存在。")
# 检查是否有关联的子人物
await _check_template_in_use(session, template)
await record_audit_event(
session,
action="admin.character_template.deleted",
character_id=template.id,
actor="admin",
decision="auto",
input_summary=f"管理员删除系统模板 '{key}'",
resource_type="character_template",
resource_id=template.id,
)
await session.delete(template)
await session.commit()
async def _check_template_in_use(session, template: Character) -> None:
"""检查模板是否被用户人物引用,是则阻止删除。"""
from sqlalchemy import func, select
count = await session.scalar(
select(func.count())
.select_from(Character)
.where(
Character.source_template_id == template.id,
Character.is_system_template.is_(False),
)
)
if count and count > 0:
raise AppError(
"conflict",
f"模板 '{template.template_key}' 已被 {count} 个人物引用,无法删除。"
"请先确保无用户人物引用此模板后再试。",
)
async def _ensure_birthday(character: Character) -> None:
"""为模板创建兜底 birthday(profile 中无 birthday 时用 created_at)。"""
profile = loads_dict(character.profile_json)
birthday = profile.get("birthday")
if isinstance(birthday, str) and birthday.strip():
return
birthday_iso = character.created_at.strftime("%Y-%m-%d")
profile["birthday"] = birthday_iso
character.profile_json = dumps(profile)
+108
View File
@@ -0,0 +1,108 @@
"""AI provider 运行时治理:endpoint 覆盖(DB 优先于 .env/yaml)+ 热重载。
override 写 DB → 读 DB 全量刷 spec 同步缓存 → ProviderManager 下次构建读新缓存。
api_key 明文存 DB(本地单用户边界),响应层脱敏不回值(见 schemas)。
"""
from __future__ import annotations
from typing import Any
from app.ai_provider.config.loader import load_models_config
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.governance import ModelEndpointOverride
from app.repositories import model_endpoints as overrides_repo
from app.schemas.preferences import UpdateEndpointOverrideRequest
from app.services.audit import record_audit_event
# —— endpoint 运行时覆盖(DB 优先于 .env/yaml)——
# reload_provider_manager 时(异步 service)读 DB 全量刷此同步缓存,
# load_models_config 仍可独立使用。None 字段表示不覆盖、回退 yaml/env。
_endpoint_overrides_cache: dict[tuple[str, str], dict[str, Any]] = {}
def set_endpoint_overrides_cache(overrides: list[dict[str, Any]]) -> None:
"""用 DB 全量覆盖列表刷新模块缓存(同步)。"""
global _endpoint_overrides_cache
_endpoint_overrides_cache = {
(o["logical_model_name"], o["endpoint_name"]): o for o in overrides
}
def get_endpoint_overrides_cache() -> dict[tuple[str, str], dict[str, Any]]:
"""读取当前覆盖缓存快照。"""
return dict(_endpoint_overrides_cache)
async def list_endpoint_overrides() -> list[dict[str, Any]]:
"""读 DB 全量覆盖,序列化为 dict 列表(供刷缓存 + 响应)。"""
async with AsyncSessionLocal() as session:
rows = await overrides_repo.list_overrides(session)
return [_override_to_dict(r) for r in rows]
async def reload_provider_manager() -> None:
"""读 DB 全量 → 刷 endpoint 覆盖同步缓存。"""
overrides = await list_endpoint_overrides()
set_endpoint_overrides_cache(overrides)
async def update_endpoint_override(
logical_model_name: str,
endpoint_name: str,
payload: UpdateEndpointOverrideRequest,
) -> dict[str, Any]:
"""upsert 覆盖(payload 字段全量写入,None 清除字段)+ reload + 审计。
校验 (model, endpoint) 存在于 yaml(避免覆盖幽灵 endpoint)。
"""
if not _endpoint_exists(logical_model_name, endpoint_name):
raise AppError("not_found", "endpoint 不存在于模型配置中。")
async with AsyncSessionLocal() as session:
override = ModelEndpointOverride(
logical_model_name=logical_model_name,
endpoint_name=endpoint_name,
base_url=payload.base_url,
api_key=payload.api_key,
status=payload.status,
weight=payload.weight,
priority=payload.priority,
)
saved = await overrides_repo.upsert_override(session, override)
await record_audit_event(
session,
action="model_endpoint.override_updated",
actor="user",
input_summary=f"{logical_model_name}/{endpoint_name}",
resource_type="model_endpoint_override",
resource_id=f"{logical_model_name}/{endpoint_name}",
)
await session.commit()
await session.refresh(saved)
result = _override_to_dict(saved)
await reload_provider_manager()
return result
def _endpoint_exists(logical_model_name: str, endpoint_name: str) -> bool:
"""校验 (model, endpoint) 在 yaml 配置中存在(避免覆盖幽灵 endpoint)。"""
models = load_models_config()
for model in models:
if model.name != logical_model_name:
continue
return any(ep.name == endpoint_name for ep in model.endpoints)
return False
def _override_to_dict(row: ModelEndpointOverride) -> dict[str, Any]:
return {
"logical_model_name": row.logical_model_name,
"endpoint_name": row.endpoint_name,
"base_url": row.base_url,
"api_key": row.api_key,
"status": row.status,
"weight": row.weight,
"priority": row.priority,
"updated_at": row.updated_at,
}
+102
View File
@@ -0,0 +1,102 @@
"""审计服务。"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import current_trace_id
from app.core.security import redact_text
from app.db.session import AsyncSessionLocal
from app.models.safety import AuditEvent
from app.repositories.audit import add_audit_event, list_audit_events
from app.schemas.admin import AuditEntry
from app.schemas.common import Page
from app.services.serializers import audit_to_response
# action 前缀 → 九枢标识(契约字典 §5.9)映射,供起居注按枢过滤与聚合。
_ACTION_PIVOT_PREFIXES: list[tuple[str, str]] = [
("memory.", "memory"),
("tool.", "tool_runtime"),
("safety.", "safety_governor"),
("personality.", "personality_core"),
("goal.", "goal_orchestrator"),
("world.", "world_model"),
("plan.", "planner"),
("perception.", "perception"),
("agent.", "agent_society"),
]
def infer_pivot(action: str) -> str | None:
"""根据 audit action 前缀推断所属枢;未命中返回 None。"""
for prefix, pivot in _ACTION_PIVOT_PREFIXES:
if action.startswith(prefix):
return pivot
return None
def _summary(value: str | None) -> str | None:
if value is None:
return None
redacted = redact_text(value)
return redacted[:1000]
async def record_audit_event(
session: AsyncSession,
*,
action: str,
pivot: str | None = None,
character_id: str | None = None,
run_id: str | None = None,
actor: str = "system",
risk_level: str = "L1",
decision: str = "auto",
reason: str | None = None,
input_summary: str | None = None,
output_summary: str | None = None,
resource_type: str | None = None,
resource_id: str | None = None,
trace_id: str | None = None,
) -> AuditEvent:
return await add_audit_event(
session,
AuditEvent(
pivot=pivot or infer_pivot(action),
character_id=character_id,
run_id=run_id,
action=action,
actor=actor,
risk_level=risk_level,
decision=decision,
reason=_summary(reason),
input_summary=_summary(input_summary),
output_summary=_summary(output_summary),
# 显式 trace_id 优先(如沙箱事件携带的 trace_id),否则取请求上下文 current_trace_id
trace_id=trace_id or current_trace_id(),
resource_type=resource_type,
resource_id=resource_id,
),
)
async def query_audit_events(
*,
character_id: str | None = None,
pivot: str | None = None,
page: int = 1,
page_size: int = 50,
) -> Page[AuditEntry]:
"""审计事件时间线(按创建时间倒序),可选按人物/枢过滤。"""
async with AsyncSessionLocal() as session:
items, total = await list_audit_events(
session,
pivot=pivot,
character_id=character_id,
page=page,
page_size=page_size,
)
return Page(
items=[audit_to_response(item) for item in items],
page=page,
page_size=page_size,
total=total,
)
+123
View File
@@ -0,0 +1,123 @@
"""认证服务。"""
from datetime import UTC, datetime, timedelta
from jose import JWTError, jwt
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.core.ids import new_user_id
from app.models.rbac import Permission, RolePermission, UserRole
from app.models.user import User
_pwd = PasswordHash([Argon2Hasher()])
def hash_password(plain: str) -> str:
return _pwd.hash(plain)
def verify_password(plain: str, hash: str) -> bool:
return _pwd.verify(plain, hash)
def _jwt_secret() -> str:
secret = settings.jwt_secret
if not secret:
raise AppError("service_unavailable", "JWT 未配置。")
return secret
def create_access_token(subject: str, extra: dict | None = None) -> str:
secret = _jwt_secret()
now = datetime.now(UTC)
payload = {
"sub": subject,
"type": "access",
"iat": now,
"exp": now + timedelta(seconds=settings.access_token_ttl),
**(extra or {}),
}
return jwt.encode(payload, secret, algorithm="HS256")
def create_refresh_token(subject: str) -> str:
secret = _jwt_secret()
now = datetime.now(UTC)
payload = {
"sub": subject,
"type": "refresh",
"iat": now,
"exp": now + timedelta(seconds=settings.refresh_token_ttl),
}
return jwt.encode(payload, secret, algorithm="HS256")
def decode_token(token: str) -> dict:
secret = _jwt_secret()
try:
return jwt.decode(token, secret, algorithms=["HS256"])
except JWTError:
raise AppError("unauthorized", "Token 无效或已过期。") from None
async def authenticate_user(db: AsyncSession, username: str, password: str) -> User:
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
if user is None:
raise AppError("unauthorized", "用户名或密码错误。")
if not user.is_active:
raise AppError("unauthorized", "用户已被禁用。")
if not verify_password(password, user.password_hash):
raise AppError("unauthorized", "用户名或密码错误。")
return user
async def get_user_by_id(db: AsyncSession, user_id: str) -> User | None:
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def create_user(
db: AsyncSession,
username: str,
email: str,
password: str,
is_admin: bool = False,
) -> User:
# 检查用户名和邮箱是否已使用
result = await db.execute(
select(User).where((User.username == username) | (User.email == email))
)
existing = result.scalar_one_or_none()
if existing is not None:
if existing.username == username:
raise AppError("conflict", "用户名已被注册。")
raise AppError("conflict", "邮箱已被注册。")
user = User(
id=new_user_id(),
username=username,
email=email,
password_hash=hash_password(password),
is_active=True,
is_admin=is_admin,
)
db.add(user)
await db.flush()
return user
async def get_user_permissions(db: AsyncSession, user_id: str) -> list[str]:
"""查询用户的所有权限(通过角色继承),返回 ``resource:action`` 字符串列表。"""
result = await db.execute(
select(Permission)
.join(RolePermission, Permission.id == RolePermission.permission_id)
.join(UserRole, RolePermission.role_id == UserRole.role_id)
.where(UserRole.user_id == user_id)
)
return [p.code for p in result.scalars().all()]
+131
View File
@@ -0,0 +1,131 @@
"""角色头像文件服务。
职责:
- 上传头像文件到本地文件系统
- 提供头像文件流
- 清理头像文件
"""
from __future__ import annotations
from pathlib import Path
from fastapi.responses import FileResponse
from app.core.config import settings
from app.core.errors import AppError
from app.services.characters import get_owned_character
_ALLOWED_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".webp"})
_MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def _avatar_path(character_id: str) -> Path:
"""获取角色头像文件路径(无扩展名,需遍历查找实际文件)。"""
return settings.resolved_avatar_dir / character_id
def _find_avatar_file(character_id: str) -> Path | None:
"""查找角色已有的头像文件(任一格式)。"""
base = _avatar_path(character_id)
for ext in _ALLOWED_EXTENSIONS:
f = base.with_suffix(ext)
if f.exists():
return f
return None
def _allowed(content_type: str) -> bool:
return content_type in {
"image/jpeg",
"image/png",
"image/webp",
}
async def upload_avatar(
character_id: str,
content: bytes,
content_type: str,
owner_scope: str = settings.default_owner_scope,
) -> str:
"""上传角色头像。
Args:
character_id: 角色 ID
content: 图片文件二进制内容
content_type: MIME 类型
owner_scope: 归属范围
Returns:
头像访问的相对 URL 路径
Raises:
AppError: 文件类型不允许、大小超限、角色不存在
"""
from app.db.session import AsyncSessionLocal
# 校验角色存在性
async with AsyncSessionLocal() as session:
await get_owned_character(session, character_id, owner_scope=owner_scope)
if not _allowed(content_type):
raise AppError(
"invalid_input",
f"不支持的文件类型 '{content_type}',仅支持 JPG/PNG/WebP。",
)
if len(content) > _MAX_FILE_SIZE:
raise AppError(
"invalid_input",
"头像文件过大,最大允许 5MB。",
)
# 扩展名映射
ext_map = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
ext = ext_map[content_type]
# 删除旧头像
old = _find_avatar_file(character_id)
if old:
old.unlink(missing_ok=True)
# 写入新文件
dest = _avatar_path(character_id).with_suffix(ext)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(content)
return f"/api/v1/characters/{character_id}/avatar"
async def get_avatar_response(character_id: str) -> FileResponse:
"""获取角色头像文件响应。
Args:
character_id: 角色 ID
Returns:
FileResponse 或 None(无头像时抛出 not_found)
"""
avatar_file = _find_avatar_file(character_id)
if avatar_file is None:
raise AppError("not_found", "该角色未设置头像。")
# 推测 media_type
ext = avatar_file.suffix.lower()
media_type = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}.get(ext, "application/octet-stream")
return FileResponse(str(avatar_file), media_type=media_type)
async def delete_avatar(character_id: str) -> None:
"""删除角色头像文件。"""
avatar_file = _find_avatar_file(character_id)
if avatar_file:
avatar_file.unlink(missing_ok=True)
+117
View File
@@ -0,0 +1,117 @@
"""角色卡导入 / 导出服务。"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.lorebook import Lorebook, LorebookEntry
from app.modules.imports.card_parser import parse_character_card
from app.modules.imports.export_mapping import character_to_card
from app.modules.imports.mapping import (
card_to_daoti,
card_to_profile,
character_book_to_entries,
)
from app.repositories import lorebook as lorebook_repo
from app.repositories.json_utils import dumps
from app.schemas.character import ImportCardResponse
async def import_character_card(
session: AsyncSession, raw: bytes, owner_scope: str
) -> Character:
"""解析角色卡字节 → 建 Character + 世谱(character_book)。返回 Character ORM。
``personality`` 写入道体 ``personality_summary`` 作初始值;
``character_book`` 转为该角色的世谱书 + 条目。
"""
card = parse_character_card(raw)
daoti = card_to_daoti(card)
character = Character(
name=card.name or "未命名角色",
display_name=card.nickname or card.name or None,
language="zh-CN",
profile_json=dumps(card_to_profile(card)),
model_policy_json="{}",
personality_summary=daoti["personality_summary"],
privacy_level="normal",
owner_scope=owner_scope,
)
session.add(character)
await session.flush()
entries_data = character_book_to_entries(card.character_book)
if entries_data:
book = card.character_book or {}
lorebook = Lorebook(
character_id=character.id,
name=book.get("name", "") or card.name,
description=book.get("description", ""),
scan_depth=book.get("scan_depth"),
token_budget=book.get("token_budget"),
recursive_scanning=bool(book.get("recursive_scanning", False)),
scope="character",
owner_scope=owner_scope,
)
session.add(lorebook)
await session.flush()
for entry in entries_data:
session.add(
LorebookEntry(
lorebook_id=lorebook.id,
keys_json=dumps(entry["keys"]),
content=entry["content"],
secondary_keys_json=dumps(entry["secondary_keys"]),
constant=entry["constant"],
selective=entry["selective"],
enabled=entry["enabled"],
case_sensitive=entry["case_sensitive"],
use_regex=entry["use_regex"],
position=entry["position"],
insertion_order=entry["insertion_order"],
priority=entry["priority"],
comment=entry["comment"],
)
)
await session.commit()
await session.refresh(character)
return character
async def import_character_card_response(
raw: bytes, owner_scope: str
) -> ImportCardResponse:
"""导入角色卡 → ImportCardResponse(供 API 调用,自管 session)。"""
async with AsyncSessionLocal() as session:
character = await import_character_card(session, raw, owner_scope)
books = await lorebook_repo.list_books(session, character.id, owner_scope)
book_id = books[0].id if books else None
entries = (
await lorebook_repo.list_book_entries(session, book_id) if book_id else []
)
return ImportCardResponse(
character_id=character.id,
name=character.name,
display_name=character.display_name,
lorebook_id=book_id,
entry_count=len(entries),
)
async def export_character_card(character_id: str, owner_scope: str) -> dict:
"""导出角色为 ST V3 卡静态快照(丢弃演化维度)。"""
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
books = await lorebook_repo.list_books(session, character_id, owner_scope)
book = books[0] if books else None
entries = (
await lorebook_repo.list_book_entries(session, book.id) if book else []
)
return character_to_card(character, book, entries)
@@ -0,0 +1,96 @@
"""默认人物模板加载器:从 ``character_templates.yaml`` 读取系统模板定义。
按项目约定,此文件放在应用服务层(``app/services/``),而非 ``core/config/``,
因为人物模板定义属于业务数据(出厂产品内容),而非部署环境配置。
"""
from __future__ import annotations
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.core.config import settings
from app.core.errors import AppError
class CharacterTemplateConfig(BaseModel):
"""一条系统模板的配置定义。"""
model_config = ConfigDict(extra="forbid")
key: str = Field(description="模板唯一标识,对应 ORM 的 template_key。")
name: str = Field(description="模板人物内部名称。")
display_name: str | None = Field(default=None, description="模板人物显示昵称。")
language: str = Field(default="zh-CN", description="默认语言。")
privacy_level: str = Field(default="normal", description="隐私等级。")
is_default: bool = Field(
default=False,
description="注册新用户时是否以此模板创建默认角色。至多一个模板为 true,多个则取第一个。",
)
profile: dict[str, Any] = Field(
default_factory=dict,
description="人物档案,可含 background / speaking_style 等(不含 birthday——由创建时兜底)。",
)
model_policy: dict[str, Any] = Field(
default_factory=dict,
description="模型策略(不含 default_chat_model_hint——启动时从 settings 补充)。",
)
class CharacterTemplatesFile(BaseModel):
"""配置文件顶层结构。"""
model_config = ConfigDict(extra="forbid")
version: int = Field(description="配置文件版本号,当前仅支持 1。")
templates: list[CharacterTemplateConfig] = Field(description="系统模板定义列表。")
def load_character_templates() -> dict[str, CharacterTemplateConfig]:
"""读 ``character_templates.yaml`` → parse → validate → ``{key → config}`` 字典。
Returns:
空字典:文件不存在或内容为空时返回空。
Raises:
AppError: 文件存在但 YAML 解析失败或 version 不匹配。
"""
path = settings.resolved_character_templates_path
if not path.exists():
return {}
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
config = CharacterTemplatesFile.model_validate(raw)
except AppError:
raise
except Exception as exc:
raise AppError(
"config_error",
"人物模板配置文件解析失败。",
details={"path": str(path)},
) from exc
if config.version != 1:
raise AppError(
"config_error",
f"仅支持 character_templates.yaml version: 1,当前为 {config.version}。",
details={"path": str(path)},
)
return {t.key: t for t in config.templates}
def get_default_template() -> CharacterTemplateConfig:
"""便利函数:取 ``key='default'`` 的系统模板配置。
Raises:
AppError: 加载失败或缺少 default 模板。
"""
templates = load_character_templates()
template = templates.get("default")
if template is None:
raise AppError(
"config_error",
"人物模板配置中缺少 key='default' 的模板。",
)
return template
+678
View File
@@ -0,0 +1,678 @@
"""人物服务。"""
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.personality import PersonalityVersion
from app.models.time_anniversary import TimeAnniversary
from app.modules.character import (
MODEL_POLICY_CREATE_DEFAULTS,
SYSTEM_TEMPLATE_OWNER_SCOPE,
extract_model_hint,
sanitize_profile,
)
from app.repositories import characters as character_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.schemas.character import (
ActivateCharacterResponse,
CharacterResponse,
CharacterSummaryResponse,
CharacterTemplateSummaryResponse,
CreateCharacterRequest,
UpdateModelPolicyRequest,
UpdateProfileRequest,
)
from app.schemas.common import Page
from app.services.audit import record_audit_event
from app.services.character_templates import CharacterTemplateConfig, get_default_template
from app.services.serializers import character_to_response, character_to_summary
async def get_active_character_model(
session: AsyncSession, owner_scope: str = settings.default_owner_scope
) -> Character:
character = await character_repo.get_active_character(session, owner_scope)
if character is None:
raise AppError("not_found", "未找到当前激活人物。")
return character
async def resolve_active_character(
session: AsyncSession,
character_id: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> Character:
"""读取当前激活人物;传入 character_id 时校验归属。"""
character = await get_active_character_model(session, owner_scope)
if character_id is not None and character_id != character.id:
raise AppError("not_found", "人物不存在。")
return character
async def get_active_character(
owner_scope: str = settings.default_owner_scope,
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
return character_to_response(await get_active_character_model(session, owner_scope))
def default_model_hint(character: Character) -> str:
return extract_model_hint(character.model_policy_json, settings.default_model_hint)
async def _create_template_from_config(
session: AsyncSession, config: CharacterTemplateConfig
) -> Character:
"""从配置构建系统模板 ORM(使用 config/character_templates.yaml 内容)。"""
model_policy = dict(config.model_policy)
model_policy.setdefault("default_chat_model_hint", settings.default_model_hint)
profile = dict(config.profile)
sanitize_profile(profile)
character = Character(
name=config.name,
display_name=config.display_name,
status="active",
language=config.language,
profile_json=dumps(profile),
model_policy_json=dumps(model_policy),
privacy_level=config.privacy_level,
owner_scope=SYSTEM_TEMPLATE_OWNER_SCOPE,
is_system_template=True,
source_template_id=None,
template_key=config.key,
)
session.add(character)
await session.flush()
await _ensure_default_birthday(session, character)
return character
async def create_default_character(
session: AsyncSession, owner_scope: str = settings.default_owner_scope
) -> Character:
template = await ensure_default_system_template(session)
return await create_character_from_template(session, template, owner_scope)
async def ensure_character_personality(
session: AsyncSession, character: Character
) -> PersonalityVersion:
from app.services.personality import create_personality_from_profile
personality = None
if character.current_personality_version_id:
personality = await personality_repo.get_personality(
session, character.current_personality_version_id
)
if personality is None:
personality = await personality_repo.get_latest_personality(session, character.id)
if personality is None:
personality = await create_personality_from_profile(session, character)
if character.current_personality_version_id != personality.id:
character.current_personality_version_id = personality.id
await session.flush()
return personality
async def ensure_default_system_template(session: AsyncSession) -> Character:
"""按 config/character_templates.yaml 确保系统模板存在且最新。
Config 为单一事实源:DB 行缺失时 insert,字段不一致时覆盖更新。
子人物(is_system_template=False)不受影响。
"""
config = get_default_template()
template = await character_repo.get_system_template_by_key(session, config.key)
if template is None:
template = await _create_template_from_config(session, config)
else:
await _sync_template_from_config(session, template, config)
# create 路径已由 _create_template_from_config 兜底 birthday,
# sync 路径可能因旧数据缺失 birthday,再兜一次确保存在。
await _ensure_default_birthday(session, template)
await ensure_character_personality(session, template)
return template
async def ensure_all_system_templates(session: AsyncSession) -> list[Character]:
"""确保 ``config/character_templates.yaml`` 中定义的所有系统模板在 DB 中存在且最新。
遍历所有配置的模板(包括 ``key='default'``),
缺失则创建、字段不一致则覆盖更新,并确保 birthday 兜底与人格初始化。
供 ``seed_default_data`` 在应用启动时调用。
"""
from app.services.character_templates import load_character_templates
configs = load_character_templates()
if not configs:
return []
results: list[Character] = []
for key, config in configs.items():
template = await character_repo.get_system_template_by_key(session, key)
if template is None:
template = await _create_template_from_config(session, config)
else:
await _sync_template_from_config(session, template, config)
await _ensure_default_birthday(session, template)
await ensure_character_personality(session, template)
results.append(template)
return results
async def _sync_template_from_config(
session: AsyncSession, template: Character, config: CharacterTemplateConfig
) -> None:
"""比对 DB 字段与 config,不一致则覆盖更新并写入审计事件。
Config 可显式定义 birthday(含有效值时优先),缺失或不合法时
由 ``_ensure_default_birthday`` 用 created_at 兜底。
"""
model_policy = dict(config.model_policy)
model_policy.setdefault("default_chat_model_hint", settings.default_model_hint)
profile = dict(config.profile)
sanitize_profile(profile)
changed = False
if template.name != config.name:
template.name = config.name
changed = True
if template.display_name != config.display_name:
template.display_name = config.display_name
changed = True
if template.language != config.language:
template.language = config.language
changed = True
if template.privacy_level != config.privacy_level:
template.privacy_level = config.privacy_level
changed = True
# profile_json 比对:比较时排除 birthday(config 可能显式设、也可能留空兜底),
# 写入时 config 有值优先,否则保留 DB 端已有值(含 _ensure_default_birthday 兜底值)。
current_profile = loads_dict(template.profile_json)
config_birthday = profile.pop("birthday", None)
existing_birthday = current_profile.pop("birthday", None)
if current_profile != profile:
if config_birthday:
profile["birthday"] = config_birthday
elif existing_birthday:
profile["birthday"] = existing_birthday
template.profile_json = dumps(profile)
changed = True
# model_policy_json 比对
current_policy = loads_dict(template.model_policy_json)
if current_policy != model_policy:
template.model_policy_json = dumps(model_policy)
changed = True
if changed:
await record_audit_event(
session,
action="character.template_resynced",
character_id=template.id,
actor="system",
decision="auto",
input_summary=f"模板 '{config.key}' 由 config 文件覆盖更新",
resource_type="character_template",
resource_id=template.id,
)
async def create_character_from_template(
session: AsyncSession, template: Character, owner_scope: str
) -> Character:
# 模板的 birthday 仅在兜底(空/缺失)时不继承;显式设置的有效生日原样保留,
# 由 _ensure_default_birthday 写入子角色的 profile 与 time_anniversaries 表。
profile = loads_dict(template.profile_json)
birthday = profile.get("birthday")
if not (isinstance(birthday, str) and birthday.strip()):
profile.pop("birthday", None)
character = Character(
name=template.name,
display_name=template.display_name,
status="active",
language=template.language,
profile_json=dumps(profile),
model_policy_json=template.model_policy_json,
safety_policy_id=template.safety_policy_id,
privacy_level=template.privacy_level,
owner_scope=owner_scope,
is_system_template=False,
source_template_id=template.id,
template_key=None,
)
session.add(character)
await session.flush()
await _ensure_default_birthday(session, character)
return character
async def ensure_default_character_for_owner(session: AsyncSession, owner_scope: str) -> Character:
"""确保当前 owner 有默认模板对应的用户人物。
只处理 ``character_templates.yaml`` 中标记 ``is_default: true`` 的模板。
不存在任何默认模板时回退到 ``key='default'``。
"""
from app.services.character_templates import load_character_templates
configs = load_character_templates()
if not configs:
template = await ensure_default_system_template(session)
character = await create_character_from_template(session, template, owner_scope)
await _ensure_character_personality_from_template(session, template, character)
return character
# 先确保所有系统模板在 DB 中存在
templates: dict[str, Character] = {}
for key in configs:
tpl = await character_repo.get_system_template_by_key(session, key)
if tpl is None:
config = configs[key]
tpl = await _create_template_from_config(session, config)
await _ensure_default_birthday(session, tpl)
await ensure_character_personality(session, tpl)
templates[key] = tpl
# 筛选 is_default=true 的配置 key
default_keys = [k for k, c in configs.items() if c.is_default]
if not default_keys:
default_keys = ["default"] if "default" in configs else []
primary: Character | None = None
for key in default_keys:
tpl = templates.get(key)
if tpl is None:
continue
existing = await character_repo.get_character_by_source_template(
session, tpl.id, owner_scope
)
if existing is not None:
await ensure_character_personality(session, existing)
if primary is None:
primary = existing
continue
character = await create_character_from_template(session, tpl, owner_scope)
from app.services.personality import copy_personality_to_character
template_personality = await ensure_character_personality(session, tpl)
personality = await copy_personality_to_character(
session, template_personality, character.id
)
character.current_personality_version_id = personality.id
await session.flush()
await record_audit_event(
session,
action="character.created_from_template",
character_id=character.id,
actor="system",
decision="auto",
input_summary=f"启动时从模板 '{key}' 自动创建用户人物",
resource_type="character",
resource_id=character.id,
)
if primary is None:
primary = character
# 兜底:没有找到主人物时从 default 模板创建
if primary is None:
template = await ensure_default_system_template(session)
primary = await create_character_from_template(session, template, owner_scope)
template_personality = await ensure_character_personality(session, template)
from app.services.personality import copy_personality_to_character
personality = await copy_personality_to_character(session, template_personality, primary.id)
primary.current_personality_version_id = personality.id
await session.flush()
return primary
async def _ensure_character_personality_from_template(
session: AsyncSession,
template: Character,
character: Character,
) -> None:
"""从模板复制人格到目标人物。"""
from app.services.personality import copy_personality_to_character
template_personality = await ensure_character_personality(session, template)
personality = await copy_personality_to_character(session, template_personality, character.id)
character.current_personality_version_id = personality.id
await session.flush()
# —— 人物管理 API(api-spec §6)——
async def get_owned_character(
session: AsyncSession, character_id: str, owner_scope: str = settings.default_owner_scope
) -> Character:
"""按 ID 取当前归属范围下的可操作人物;系统模板与跨范围 ID 一律视为不存在。"""
character = await character_repo.get_character(session, character_id)
if character is None or character.is_system_template or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character
async def _personality_version_number(session: AsyncSession, character: Character) -> int:
version_id = character.current_personality_version_id
if not version_id:
return 0
personality = await personality_repo.get_personality(session, version_id)
return personality.version if personality else 0
async def _audit_character(session: AsyncSession, character: Character, action: str) -> None:
await record_audit_event(
session,
action=action,
character_id=character.id,
actor="user",
decision="auto",
input_summary=character.name,
resource_type="character",
resource_id=character.id,
)
async def _ensure_default_birthday(session: AsyncSession, character: Character) -> None:
"""birthday 缺失时用 created_at 兜底:写 profile.birthday 并建一条生日纪念日。
- 仅在 profile.birthday 为空/缺失时触发默认兜底;用户或模板已设则不覆盖。
- 无论 birthday 是否已设,只要角色尚无 birthday 纪念日,就建一条。
- profile.birthday 与 TimeAnniversary.date 取 birthday 值(显式)或 created_at 的 ISO 日期。
- 须在 character flush 后调用(依赖 id 与 created_at);同一事务内随外层 commit 落库。
"""
profile = loads_dict(character.profile_json)
birthday = profile.get("birthday")
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
birthday_iso = character.created_at.strftime("%Y-%m-%d")
profile["birthday"] = birthday_iso
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(
session: AsyncSession,
payload: CreateCharacterRequest,
owner_scope: str = settings.default_owner_scope,
) -> CharacterResponse:
"""API 创建人物。
- 传 ``template_key`` 时从系统模板复制(继承 profile/model_policy,status=active);
- 不传时创建空白人物(status=wuji,不激活、不建人格)。
"""
if payload.template_key:
template = await character_repo.get_system_template_by_key(session, payload.template_key)
if template is None:
raise AppError("not_found", f"模板 '{payload.template_key}' 不存在。")
profile = loads_dict(template.profile_json)
profile.update(dict(payload.profile or {}))
model_policy = loads_dict(template.model_policy_json)
model_policy.update(dict(payload.model_policy or {}))
model_policy.setdefault("default_chat_model_hint", settings.default_model_hint)
character = Character(
name=payload.name,
display_name=payload.display_name or template.display_name,
status="active",
language=template.language,
profile_json=dumps(profile),
model_policy_json=dumps(model_policy),
privacy_level=template.privacy_level,
owner_scope=owner_scope,
is_system_template=False,
source_template_id=template.id,
template_key=None,
)
await character_repo.add_character(session, character)
await _ensure_default_birthday(session, character)
await ensure_character_personality(session, character)
await _audit_character(session, character, "character.created_from_template")
await session.commit()
await session.refresh(character)
return character_to_response(character)
# ── 空白创建(原有逻辑)──
model_policy = MODEL_POLICY_CREATE_DEFAULTS | dict(payload.model_policy or {})
model_policy["default_chat_model_hint"] = (
model_policy.get("default_chat_model_hint") or settings.default_model_hint
)
character = Character(
name=payload.name,
display_name=payload.display_name,
status="wuji",
language="zh-CN",
profile_json=dumps(dict(payload.profile or {})),
model_policy_json=dumps(model_policy),
privacy_level=model_policy.get("privacy_level", "normal"),
owner_scope=owner_scope,
is_system_template=False,
source_template_id=None,
template_key=None,
)
await character_repo.add_character(session, character)
await _ensure_default_birthday(session, character)
await _audit_character(session, character, "character.created")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def list_characters_page(
*,
owner_scope: str = settings.default_owner_scope,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[CharacterSummaryResponse]:
async with AsyncSessionLocal() as session:
safe_page = max(page, 1)
safe_page_size = min(max(page_size, 1), 100)
items, total = await character_repo.list_characters(
session,
owner_scope,
status=status,
page=safe_page,
page_size=safe_page_size,
)
summaries = [
character_to_summary(
character,
memory_count=await character_repo.count_memories(session, character.id),
personality_version=await _personality_version_number(session, character),
)
for character in items
]
return Page(
items=summaries,
page=safe_page,
page_size=safe_page_size,
total=total,
)
async def get_character_by_id(
character_id: str, owner_scope: str = settings.default_owner_scope
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
return character_to_response(await get_owned_character(session, character_id, owner_scope))
async def update_character_profile(
character_id: str,
payload: UpdateProfileRequest,
owner_scope: str = settings.default_owner_scope,
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
profile = loads_dict(character.profile_json)
for key, value in payload.model_dump(exclude_unset=True).items():
if key == "display_name":
if value is not None:
character.display_name = value
continue
if value is not None:
profile[key] = value
character.profile_json = dumps(profile)
await _audit_character(session, character, "character.profile_updated")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def update_character_model_policy(
character_id: str,
payload: UpdateModelPolicyRequest,
owner_scope: str = settings.default_owner_scope,
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
policy = loads_dict(character.model_policy_json)
for key, value in payload.model_dump(exclude_unset=True).items():
if value is None:
continue
if key == "privacy_level":
character.privacy_level = value
policy[key] = value
character.model_policy_json = dumps(policy)
await _audit_character(session, character, "character.model_policy_updated")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def _deactivate_others(
session: AsyncSession, character: Character, owner_scope: str = settings.default_owner_scope
) -> None:
"""A1 不变量:一个 owner_scope 至多一个 active;激活时把其余改 paused。"""
others = await character_repo.list_active_in_scope(session, owner_scope, except_id=character.id)
for other in others:
other.status = "paused"
async def activate_character(
character_id: str, owner_scope: str = settings.default_owner_scope
) -> ActivateCharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
if character.status == "archived":
raise AppError("conflict", "已归档人物不可直接激活,请先恢复。")
await _deactivate_others(session, character, owner_scope)
character.status = "active"
character.activated_at = utc_now()
await ensure_character_personality(session, character)
await _audit_character(session, character, "character.activated")
await session.commit()
return ActivateCharacterResponse(active_character_id=character.id, status="active")
async def pause_character(
character_id: str, owner_scope: str = settings.default_owner_scope
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
if character.status not in ("active", "initialized"):
raise AppError("conflict", "仅运行中人物可暂停。")
character.status = "paused"
await _audit_character(session, character, "character.paused")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def resume_character(
character_id: str, owner_scope: str = settings.default_owner_scope
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
if character.status != "paused":
raise AppError("conflict", "仅暂停状态的人物可恢复。")
await _deactivate_others(session, character, owner_scope)
character.status = "active"
character.activated_at = utc_now()
await ensure_character_personality(session, character)
await _audit_character(session, character, "character.resumed")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def archive_character(
character_id: str, owner_scope: str = settings.default_owner_scope
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
if character.status == "archived":
raise AppError("conflict", "人物已归档。")
character.status = "archived"
character.archived_at = utc_now()
await _audit_character(session, character, "character.archived")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def list_system_templates_page() -> list[CharacterTemplateSummaryResponse]:
"""列出所有系统模板(供前端模板选择器使用)。"""
async with AsyncSessionLocal() as session:
templates = await character_repo.list_system_templates(session)
result: list[CharacterTemplateSummaryResponse] = []
for t in templates:
profile = loads_dict(t.profile_json)
result.append(
CharacterTemplateSummaryResponse(
key=t.template_key or "",
name=t.name,
display_name=t.display_name,
description=profile.get("description"),
temperament=profile.get("temperament"),
speaking_style=profile.get("speaking_style"),
language=t.language,
)
)
return result
+252
View File
@@ -0,0 +1,252 @@
"""聊天服务。"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.modules.safety.confirmation import ConfirmationStore
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.chat import ChatMessage, ChatSession
from app.repositories import chat as chat_repo
from app.repositories.json_utils import dumps, loads_list
from app.schemas.chat import (
ChatMessageListResponse,
ChatMessageResponse,
ChatSessionListResponse,
ChatSessionResponse,
CreateSessionRequest,
SelectVersionRequest,
SendMessageRequest,
SendMessageResponse,
UpdateSessionRequest,
)
from app.services.characters import get_active_character_model
from app.services.serializers import chat_message_to_response, chat_session_to_response
if TYPE_CHECKING:
from app.application.agent.confirmation import ConfirmationRegistry
from app.application.agent.python_skills import PythonSkillCatalog
from app.application.agent.run_registry import AgentRunRegistry
from app.application.tool_runtime.runtime import ToolRuntime
from app.modules.runtime.events import EventStore
async def create_session(
payload: CreateSessionRequest, owner_scope: str = settings.default_owner_scope
) -> ChatSessionResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
character_id = payload.character_id or character.id
if character_id != character.id:
raise AppError("not_found", "人物不存在。")
chat_session = ChatSession(
character_id=character_id,
title=payload.title,
status="active",
owner_scope=owner_scope,
)
session.add(chat_session)
await session.commit()
await session.refresh(chat_session)
return chat_session_to_response(chat_session)
_PREVIEW_LEN = 80
def _truncate_preview(content: str | None) -> str | None:
"""截断消息内容为列表预览长度;空内容返回 None。"""
if not content:
return None
text = content.strip()
if len(text) <= _PREVIEW_LEN:
return text
return text[:_PREVIEW_LEN].rstrip() + "…"
async def list_session_responses(
*,
owner_scope: str = settings.default_owner_scope,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> ChatSessionListResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
sessions = await chat_repo.list_sessions(
session, character.id, status=status, owner_scope=owner_scope, limit=limit, offset=offset
)
total = await chat_repo.count_sessions(
session, character.id, status=status, owner_scope=owner_scope
)
session_ids = [s.id for s in sessions]
counts = await chat_repo.count_messages_by_sessions(session, session_ids)
previews = await chat_repo.last_message_previews_by_sessions(session, session_ids)
return ChatSessionListResponse(
items=[
chat_session_to_response(
s,
message_count=counts.get(s.id, 0),
last_message_preview=_truncate_preview(previews.get(s.id)),
)
for s in sessions
],
total=total,
)
async def get_session_response(
session_id: str, owner_scope: str = settings.default_owner_scope
) -> ChatSessionResponse:
async with AsyncSessionLocal() as session:
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
message_count = await chat_repo.count_messages(session, chat_session.id)
recent = await chat_repo.list_recent_messages(session, chat_session.id, limit=1)
preview = _truncate_preview(recent[0].content if recent else None)
return chat_session_to_response(
chat_session,
message_count=message_count,
last_message_preview=preview,
)
_ALLOWED_SESSION_STATUSES = {"active", "archived"}
async def update_session_response(
session_id: str, payload: UpdateSessionRequest, owner_scope: str = settings.default_owner_scope
) -> ChatSessionResponse:
"""更新会话标题或状态(active/archived);归属校验同 get。
deleted 态由 DELETE 端点承担,此处禁止直接置为 deleted。
"""
async with AsyncSessionLocal() as session:
# owner_scope 校验已由 get_session 内化,无需重复查 character
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
if payload.title is not None:
chat_session.title = payload.title
if payload.status is not None:
if payload.status not in _ALLOWED_SESSION_STATUSES:
raise AppError(
"invalid_request",
"会话状态仅可切换为 active 或 archived;删除请使用 DELETE。",
)
chat_session.status = payload.status
await session.commit()
await session.refresh(chat_session)
message_count = await chat_repo.count_messages(session, chat_session.id)
return chat_session_to_response(chat_session, message_count=message_count)
async def delete_session(session_id: str, owner_scope: str = settings.default_owner_scope) -> None:
"""软删除会话:session.status=deleted 并软删其全部消息。
Run/Event 为审计/日志型数据,保留不删;消息通过 deleted_at 软删,
与现有查询过滤保持一致。
"""
async with AsyncSessionLocal() as session:
# owner_scope 校验已由 get_session 内化,无需重复查 character
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
chat_session.status = "deleted"
await chat_repo.soft_delete_session_messages(session, session_id)
await session.commit()
async def list_message_responses(
session_id: str,
*,
owner_scope: str = settings.default_owner_scope,
limit: int = 100,
offset: int = 0,
) -> ChatMessageListResponse:
async with AsyncSessionLocal() as session:
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
messages = await chat_repo.list_messages(session, session_id, limit=limit, offset=offset)
total = await chat_repo.count_messages(session, session_id)
return ChatMessageListResponse(
items=[chat_message_to_response(message) for message in messages],
total=total,
)
async def recent_chat_messages(
session: AsyncSession, session_id: str, *, limit: int = 12
) -> list[ChatMessage]:
"""加载会话最近消息(供上下文组装)——接收外部 session,供 send_message usecase 复用。"""
return await chat_repo.list_recent_messages(session, session_id, limit=limit)
async def send_message(
session: AsyncSession,
session_id: str,
payload: SendMessageRequest,
*,
owner_scope: str = settings.default_owner_scope,
tool_runtime: "ToolRuntime | None" = None,
confirmation_registry: "ConfirmationRegistry | None" = None,
confirmation_store: "ConfirmationStore | None" = None,
event_store: "EventStore | None" = None,
agent_run_registry: "AgentRunRegistry | None" = None,
python_skill_catalog: "PythonSkillCatalog | None" = None,
) -> SendMessageResponse:
"""发送消息——接收外部 session(由 run_idempotent 注入,保证幂等记录与发送同事务)。"""
from app.application.chat.send_message import SendChatMessageUseCase
return await SendChatMessageUseCase(
session,
owner_scope=owner_scope,
tool_runtime=tool_runtime,
confirmation_registry=confirmation_registry,
confirmation_store=confirmation_store,
event_store=event_store,
agent_run_registry=agent_run_registry,
python_skill_catalog=python_skill_catalog,
).execute(session_id, payload)
async def select_message_version(
session_id: str,
message_id: str,
payload: SelectVersionRequest,
*,
owner_scope: str = settings.default_owner_scope,
) -> ChatMessageResponse:
"""选择/激活消息的某个历史版本。"""
async with AsyncSessionLocal() as session:
# owner_scope 校验已由 get_session 内化
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
message = await chat_repo.get_message(session, message_id)
if message is None or message.session_id != session_id:
raise AppError("not_found", "消息不存在。")
versions = loads_list(message.versions_json)
if payload.version_idx < 0 or payload.version_idx >= len(versions):
raise AppError(
"invalid_request",
f"版本索引 {payload.version_idx} 超出范围 (0-{len(versions) - 1})。",
)
await chat_repo.set_message_active_version(
session,
message_id,
payload.version_idx,
dumps(versions),
)
await session.commit()
await session.refresh(message)
return chat_message_to_response(message)
+115
View File
@@ -0,0 +1,115 @@
"""一炁进程内事件订阅分发器。
订阅 EventStore 全量事件(``subscribe_by_pattern("*")``),按 glob pattern 分发给注册的
handler。每个 ``(pattern, handler)`` 注册项 = 一个独立 subscription(独立 queue + consumer
task),handler 异常隔离(``try/except`` + ``logger.exception``),at-most-once(失败不重试)。
设计见 ``docs/architecture/data-and-security-architecture.md`` §17「进程内订阅范式」。
"""
from __future__ import annotations
import asyncio
import contextlib
import fnmatch
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from loguru import logger
from app.modules.runtime.events import Event, EventStore
AsyncHandler = Callable[[Event], Awaitable[None]]
@dataclass
class _Subscription:
pattern: str
handler: AsyncHandler
queue: asyncio.Queue[Event] = field(default_factory=asyncio.Queue)
class Dispatcher:
"""一炁事件订阅分发器。
用法:
dispatcher = Dispatcher(event_store)
dispatcher.register("time.*", handler) # 必须在 start 前注册
await dispatcher.start() # lifespan startup
...
await dispatcher.stop() # lifespan shutdown
"""
def __init__(self, event_store: EventStore) -> None:
self._event_store = event_store
self._subscriptions: list[_Subscription] = []
self._all_queue: asyncio.Queue[Event] | None = None
self._pump_task: asyncio.Task[None] | None = None
self._consumer_tasks: list[asyncio.Task[None]] = []
self._started = False
def register(self, pattern: str, handler: AsyncHandler) -> None:
"""注册 handler(必须在 start 前调用)。
pattern: glob 如 ``'time.reunion'`` / ``'time.*'`` / ``'*'``(fnmatch 语义)。
同一 handler 注册多 pattern = 多个独立 subscription(v1 不去重)。
"""
if self._started:
raise RuntimeError(
"Dispatcher 已启动,不能注册 handler(应在 lifespan startup 前注册)"
)
self._subscriptions.append(_Subscription(pattern=pattern, handler=handler))
async def start(self) -> None:
"""lifespan startup:subscribe_by_pattern('*') + 起 pump task + 各 subscription consumer task。"""
if self._started:
return
self._all_queue = self._event_store.subscribe_by_pattern("*")
self._pump_task = asyncio.create_task(self._pump(), name="dispatcher-pump")
for sub in self._subscriptions:
task = asyncio.create_task(
self._run_consumer(sub), name=f"dispatcher-consumer-{sub.pattern}"
)
self._consumer_tasks.append(task)
self._started = True
logger.info("Dispatcher 启动:{} 个 subscription", len(self._subscriptions))
async def stop(self) -> None:
"""lifespan shutdown:cancel 所有 task + await 取消 + unsubscribe all_queue。"""
if not self._started:
return
tasks = [t for t in [self._pump_task, *self._consumer_tasks] if t is not None]
for task in tasks:
task.cancel()
for task in tasks:
with contextlib.suppress(asyncio.CancelledError):
await task
if self._all_queue is not None:
self._event_store.unsubscribe_by_pattern("*", self._all_queue)
self._pump_task = None
self._consumer_tasks = []
self._all_queue = None
self._started = False
logger.info("Dispatcher 停止")
async def _pump(self) -> None:
"""读 all_queue → glob 匹配 subscription → put 到 per-subscription queue(无 IO,不抛)。"""
assert self._all_queue is not None
while True:
event = await self._all_queue.get()
for sub in self._subscriptions:
if fnmatch.fnmatch(event.type, sub.pattern):
sub.queue.put_nowait(event)
async def _run_consumer(self, sub: _Subscription) -> None:
"""per-subscription consumer:读 queue → try/except 调 handler(at-most-once 隔离)。"""
while True:
event = await sub.queue.get()
try:
await sub.handler(event)
except Exception:
logger.exception(
"Dispatcher handler 异常(at-most-once,跳过): pattern={} type={}",
sub.pattern,
event.type,
)
+143
View File
@@ -0,0 +1,143 @@
"""内驱力审核服务(次生目标 approve → 自动创建太一目标)。
审核 approve 时自动派生 Goal(太一枢任务级目标),
延后 / 驳回仅标记不变更目标树。
"""
from app.core.errors import AppError
from app.core.ids import new_goal_id
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
from app.models.governance import Goal
from app.repositories import drive as drive_repo
from app.repositories.json_utils import loads_list
from app.schemas.common import Page
from app.schemas.drive import CuriosityResponse, SubgoalResponse
from app.services.audit import record_audit_event
async def list_curiosities(
*,
character_id: str,
source: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[CuriosityResponse]:
async with AsyncSessionLocal() as session:
items, total = await drive_repo.list_curiosities(
session, character_id, source=source, page=page, page_size=page_size
)
return Page(
items=[_curiosity_to_response(i) for i in items],
page=page,
page_size=page_size,
total=total,
)
async def list_subgoals(
*,
character_id: str,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[SubgoalResponse]:
async with AsyncSessionLocal() as session:
items, total = await drive_repo.list_subgoals(
session, character_id, status=status, page=page, page_size=page_size
)
return Page(
items=[_subgoal_to_response(i) for i in items],
page=page,
page_size=page_size,
total=total,
)
async def approve_subgoal(subgoal_id: str) -> SubgoalResponse:
return await _review_subgoal(subgoal_id, "approved", decision="auto", create_goal=True)
async def defer_subgoal(subgoal_id: str) -> SubgoalResponse:
return await _review_subgoal(subgoal_id, "deferred", decision="auto", create_goal=False)
async def reject_subgoal(subgoal_id: str) -> SubgoalResponse:
return await _review_subgoal(subgoal_id, "rejected", decision="denied", create_goal=False)
async def _review_subgoal(
subgoal_id: str, target_status: str, *, decision: str, create_goal: bool
) -> SubgoalResponse:
async with AsyncSessionLocal() as session:
sub = await drive_repo.get_subgoal(session, subgoal_id)
if sub is None:
raise AppError("not_found", "探索性次生目标不存在。")
if sub.status != "candidate":
raise AppError("conflict", f"次生目标当前状态为 {sub.status},不可审核。")
sub.status = target_status
sub.reviewed_at = utc_now()
sub.reviewed_by = "user"
# approve 时自动创建太一任务级目标
if create_goal and sub.character_id:
goal = Goal(
id=new_goal_id(),
character_id=sub.character_id,
title=sub.title,
description=sub.description or "由次生目标自动生成",
level="task",
type="exploration",
status="active",
)
session.add(goal)
await record_audit_event(
session,
action=f"drive.subgoal.{target_status}",
actor="user",
risk_level=sub.risk_level,
decision=decision,
character_id=sub.character_id,
input_summary=sub.title[:200],
resource_type="exploratory_subgoal",
resource_id=sub.id,
)
await session.commit()
await session.refresh(sub)
return _subgoal_to_response(sub)
def _curiosity_to_response(c: CuriosityQuestion) -> CuriosityResponse:
return CuriosityResponse(
id=c.id,
character_id=c.character_id,
cycle_id=c.cycle_id,
question=c.question,
source=c.source,
related_entities=loads_list(c.related_entities_json),
expected_value=c.expected_value,
urgency=c.urgency,
risk_level=c.risk_level,
created_at=c.created_at,
)
def _subgoal_to_response(s: ExploratorySubgoal) -> SubgoalResponse:
return SubgoalResponse(
id=s.id,
character_id=s.character_id,
cycle_id=s.cycle_id,
parent_goal_id=s.parent_goal_id,
curiosity_question_id=s.curiosity_question_id,
title=s.title,
description=s.description,
expected_information_gain=s.expected_information_gain,
interruption_cost=s.interruption_cost,
risk_level=s.risk_level,
status=s.status,
reviewed_at=s.reviewed_at,
reviewed_by=s.reviewed_by,
created_at=s.created_at,
)
+55
View File
@@ -0,0 +1,55 @@
"""进程内 SSE 事件总线。
后台任务 ``append_event`` 写入 DB(持久化)后,同时 publish 到本总线;SSE generator
从内存队列实时读取并转发给前端,避免轮询 DB 带来的延迟与开销。
约束:仅进程内有效,进程重启即失。多进程 / 多副本部署需替换为 Redis Pub/Sub 等共享
通道;本地开发(单进程 uvicorn)适用。事件历史仍以 DB 为持久事实源,总线仅承担实时转发。
"""
from __future__ import annotations
import asyncio
from collections import defaultdict
from app.schemas.runtime import EventResponse
_TERMINAL_EVENT_TYPES = {"run.completed", "run.failed", "run.cancelled"}
class RunEventBus:
"""按 run_id 缓存事件并广播给订阅者。"""
def __init__(self) -> None:
self._events: dict[str, list[EventResponse]] = defaultdict(list)
self._subscribers: dict[str, list[asyncio.Queue[EventResponse]]] = defaultdict(list)
def publish(self, run_id: str, event: EventResponse) -> None:
self._events[run_id].append(event)
for queue in self._subscribers.get(run_id, []):
queue.put_nowait(event)
def list_after(self, run_id: str, *, after_sequence: int) -> list[EventResponse]:
"""返回 run 内 sequence 大于 after_sequence 的事件(用于断线重连补发)。"""
events = self._events.get(run_id, [])
if after_sequence <= 0:
return list(events)
return [event for event in events if event.sequence > after_sequence]
def subscribe(self, run_id: str) -> asyncio.Queue[EventResponse]:
queue: asyncio.Queue[EventResponse] = asyncio.Queue()
self._subscribers[run_id].append(queue)
return queue
def unsubscribe(self, run_id: str, queue: asyncio.Queue[EventResponse]) -> None:
subscribers = self._subscribers.get(run_id)
if subscribers is None:
return
self._subscribers[run_id] = [q for q in subscribers if q is not queue]
@staticmethod
def is_terminal(event: EventResponse) -> bool:
return event.type in _TERMINAL_EVENT_TYPES
bus = RunEventBus()
+143
View File
@@ -0,0 +1,143 @@
"""DB 持久化事件存储:写 events 表 + 内存 pub/sub 供 SSE 实时推送。
混合架构:DB 是事实源(持久化、可回放、重启不丢),内存订阅表提供实时推送
(DB 无推送能力)。实现 ``EventStore`` Protocol,与 ``InMemoryEventStore`` 可互换,
调用方无感知(见 events.py 的 Protocol 定义)。
sequence 分配:run 内单调递增。``append`` 时若未指定则查 ``MAX(sequence)+1``,
并发下撞 ``uq_events_run_sequence`` 时重新分配并重试(沙箱场景并发不高,重试足够)。
"""
from __future__ import annotations
import asyncio
import fnmatch
import json
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.models.runtime import Event as EventRow
from app.modules.runtime.events import Event
# 并发 sequence 冲突时的重试上限
_MAX_SEQUENCE_RETRIES = 3
class DbEventStore:
"""DB 持久化 + 内存 pub/sub 的事件存储。"""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._session_factory = session_factory
self._subscribers: dict[str, list[asyncio.Queue[Event]]] = {}
self._pattern_subscribers: dict[str, list[asyncio.Queue[Event]]] = {}
async def append(self, event: Event, *, session: AsyncSession | None = None) -> Event:
# run 内事件分配单调 sequence;run 外事件(run_id=None)sequence 无意义,用 0 占位
if event.sequence is None and event.run_id:
event = await self._assign_sequence(event)
for attempt in range(_MAX_SEQUENCE_RETRIES + 1):
row = _event_to_row(event)
try:
if session is not None:
session.add(row)
# 不提交——调用方管理事务
else:
async with self._session_factory() as s:
s.add(row)
await s.commit()
break
except IntegrityError:
if attempt < _MAX_SEQUENCE_RETRIES and event.run_id:
event = await self._assign_sequence(event)
continue
raise
# 实时通知订阅者(内存 pub/sub,供 SSE 推送)
for queue in self._subscribers.get(event.run_id or "", []):
queue.put_nowait(event)
# pattern 订阅:fnmatch 匹配 event.type(与 InMemoryEventStore 对称)
for pattern, queues in self._pattern_subscribers.items():
if fnmatch.fnmatch(event.type, pattern):
for queue in queues:
queue.put_nowait(event)
return event
async def _assign_sequence(self, event: Event) -> Event:
async with self._session_factory() as session:
result = await session.execute(
select(func.coalesce(func.max(EventRow.sequence), 0)).where(
EventRow.run_id == event.run_id
)
)
current = result.scalar_one()
return event.model_copy(update={"sequence": int(current) + 1})
async def list_by_run(self, run_id: str) -> list[Event]:
async with self._session_factory() as session:
result = await session.execute(
select(EventRow).where(EventRow.run_id == run_id).order_by(EventRow.sequence)
)
rows = result.scalars().all()
return [_row_to_event(r) for r in rows]
def subscribe(self, run_id: str) -> asyncio.Queue[Event]:
queue: asyncio.Queue[Event] = asyncio.Queue()
self._subscribers.setdefault(run_id, []).append(queue)
return queue
def unsubscribe(self, run_id: str, queue: asyncio.Queue[Event]) -> None:
subs = self._subscribers.get(run_id)
if subs is not None:
self._subscribers[run_id] = [q for q in subs if q is not queue]
def subscribe_by_pattern(self, pattern: str) -> asyncio.Queue[Event]:
queue: asyncio.Queue[Event] = asyncio.Queue()
self._pattern_subscribers.setdefault(pattern, []).append(queue)
return queue
def unsubscribe_by_pattern(self, pattern: str, queue: asyncio.Queue[Event]) -> None:
subscribers = self._pattern_subscribers.get(pattern)
if subscribers is not None:
self._pattern_subscribers[pattern] = [q for q in subscribers if q is not queue]
def _event_to_row(event: Event) -> EventRow:
"""Event pydantic → events 表行。sequence 未分配时用 0 占位(run 内会被覆盖)。"""
return EventRow(
id=event.id,
type=event.type,
source=event.source,
payload_json=json.dumps(event.payload, ensure_ascii=False),
visibility=event.visibility,
sensitivity=event.sensitivity,
run_id=event.run_id,
character_id=event.character_id,
trace_id=event.trace_id,
parent_id=event.parent_id,
sequence=event.sequence if event.sequence is not None else 0,
evidence_ids_json=json.dumps(event.evidence_ids, ensure_ascii=False),
created_at=datetime.fromisoformat(event.created_at),
)
def _row_to_event(row: EventRow) -> Event:
"""events 表行 → Event pydantic。"""
return Event(
id=row.id,
type=row.type,
source=row.source,
payload=json.loads(row.payload_json),
visibility=row.visibility,
sensitivity=row.sensitivity,
run_id=row.run_id,
character_id=row.character_id,
trace_id=row.trace_id,
parent_id=row.parent_id,
sequence=row.sequence,
evidence_ids=json.loads(row.evidence_ids_json),
created_at=row.created_at.isoformat(),
)
+205
View File
@@ -0,0 +1,205 @@
"""演化候选派生与审核服务。
applier 注册表按 ``type`` 派生到下游(本期 personality→人格版本、procedural→
程序记忆,其余返回 None 降级)。审核闭环 approve/reject 复用派生能力,自管
session 并记审计(见 [大周天演化候选落地设计]
../docs/domain/core-domains.md §6-§7)。
"""
from collections.abc import Awaitable, Callable
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.evolution import EvolutionCandidate
from app.models.memory import Memory
from app.repositories import evolution as evolution_repo
from app.repositories import personality as personality_repo
from app.repositories.json_utils import loads_dict, loads_list
from app.schemas.common import Page
from app.schemas.evolution import EvolutionCandidateResponse
from app.services.audit import record_audit_event
from app.services.personality import derive_personality_version
Applier = Callable[..., Awaitable[str]]
async def apply_evolution_candidate(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str = "system"
) -> str | None:
"""应用演化候选到下游;返回派生资源 ID,无 applier 时返回 None(降级)。"""
applier = _APPLIERS.get(candidate.type)
if applier is None:
return None
return await applier(session, candidate, actor=actor)
async def _apply_personality(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str
) -> str:
base = await personality_repo.get_latest_personality(session, candidate.character_id)
if base is None:
raise AppError("invalid_request", "人物尚无人格版本,无法应用演化候选。")
overrides = loads_dict(candidate.delta_json)
new_version = derive_personality_version(
base, overrides, reason=f"evolution:{candidate.id}", created_by="big_cycle"
)
session.add(new_version)
await session.flush()
character = await session.get(Character, candidate.character_id)
if character is None:
raise AppError("not_found", "人物不存在。")
character.current_personality_version_id = new_version.id
await _record_apply(
session, candidate, f"type=personality version={new_version.id}", actor=actor
)
return new_version.id
async def _apply_procedural(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str
) -> str:
delta = loads_dict(candidate.delta_json)
memory = Memory(
character_id=candidate.character_id,
type="procedural",
title=str(delta.get("title") or candidate.title),
content=str(delta.get("content") or candidate.proposal),
source="big_cycle",
importance=candidate.confidence,
)
session.add(memory)
await session.flush()
await _record_apply(session, candidate, f"type=procedural memory={memory.id}", actor=actor)
return memory.id
async def list_evolution_candidates(
*,
character_id: str,
status: str | None = None,
candidate_type: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[EvolutionCandidateResponse]:
async with AsyncSessionLocal() as session:
items, total = await evolution_repo.list_candidates(
session,
character_id,
status=status,
candidate_type=candidate_type,
page=page,
page_size=page_size,
)
return Page(
items=[_to_response(item) for item in items],
page=page,
page_size=page_size,
total=total,
)
async def approve_evolution_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await evolution_repo.get_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "演化候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
target_id = await apply_evolution_candidate(session, candidate, actor="user")
if target_id is None:
raise AppError("not_implemented", "该类型派生待后续阶段。")
candidate.status = "applied"
candidate.resulting_target_id = target_id
candidate.reviewed_at = utc_now()
candidate.reviewed_by = "user"
await session.commit()
await session.refresh(candidate)
return _to_response(candidate)
async def reject_evolution_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await evolution_repo.get_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "演化候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
candidate.status = "rejected"
candidate.reviewed_at = utc_now()
candidate.reviewed_by = "user"
await record_audit_event(
session,
action="evolution.candidate.rejected",
actor="user",
risk_level=candidate.level,
decision="denied",
character_id=candidate.character_id,
input_summary=candidate.proposal[:200],
resource_type="evolution_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return _to_response(candidate)
def _to_response(candidate: EvolutionCandidate) -> EvolutionCandidateResponse:
return EvolutionCandidateResponse(
id=candidate.id,
character_id=candidate.character_id,
cycle_id=candidate.cycle_id,
type=candidate.type,
source=candidate.source,
level=candidate.level,
scope=candidate.scope,
title=candidate.title,
proposal=candidate.proposal,
delta=loads_dict(candidate.delta_json),
evidence_ids=loads_list(candidate.evidence_ids_json),
confidence=candidate.confidence,
benefit_score=candidate.benefit_score,
risk_score=candidate.risk_score,
drift_score=candidate.drift_score,
auto_apply_allowed=candidate.auto_apply_allowed,
status=candidate.status,
resulting_target_id=candidate.resulting_target_id,
error_message=candidate.error_message,
reviewed_at=candidate.reviewed_at,
reviewed_by=candidate.reviewed_by,
created_at=candidate.created_at,
)
async def _record_apply(
session: AsyncSession,
candidate: EvolutionCandidate,
output_summary: str,
*,
actor: str,
) -> None:
await record_audit_event(
session,
action="evolution.candidate.applied",
actor=actor,
risk_level=candidate.level,
decision="auto",
character_id=candidate.character_id,
input_summary=candidate.proposal[:200],
output_summary=output_summary,
resource_type="evolution_candidate",
resource_id=candidate.id,
)
_APPLIERS: dict[str, Applier] = {
"personality": _apply_personality,
"procedural": _apply_procedural,
}
+164
View File
@@ -0,0 +1,164 @@
"""大周天抽取候选审核服务(记忆候选 + 世界实体候选)。
记忆候选 approve 派生为 ``memories``(source=big_cycle);世界实体候选 approve
→ confirmed 进入世界模型。审核仅 pending 可操作(见 [大周天记忆/世界抽取落地设计]
../docs/domain/core-domains.md §5)。
"""
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import candidates as candidates_repo
from app.repositories import world as world_repo
from app.schemas.candidates import MemoryCandidateResponse
from app.schemas.common import Page
from app.schemas.world import WorldEntityResponse
from app.services.audit import record_audit_event
from app.services.serializers import (
character_world_entity_to_response,
memory_candidate_to_response,
)
async def list_memory_proposals(
*,
character_id: str,
source: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[MemoryCandidateResponse]:
async with AsyncSessionLocal() as session:
all_items = await candidates_repo.list_memory_candidates(
session, character_id, source=source, status=status
)
start = max(page - 1, 0) * page_size
items = all_items[start : start + page_size]
return Page(
items=[memory_candidate_to_response(i) for i in items],
page=page,
page_size=page_size,
total=len(all_items),
)
async def approve_memory_proposal(
candidate_id: str,
) -> MemoryCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await candidates_repo.get_memory_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "记忆候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
session.add(
Memory(
character_id=candidate.character_id,
type=candidate.type,
title=candidate.title,
content=candidate.content,
source="big_cycle",
confidence=candidate.confidence,
importance=candidate.importance,
sensitivity=candidate.sensitivity,
evidence_ids_json=candidate.evidence_ids_json,
status="active",
)
)
candidate.status = "applied"
candidate.reviewed_at = utc_now()
await record_audit_event(
session,
action="extraction.memory.applied",
actor="user",
character_id=candidate.character_id,
input_summary=candidate.title[:200],
resource_type="memory_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return memory_candidate_to_response(candidate)
async def reject_memory_proposal(
candidate_id: str,
) -> MemoryCandidateResponse:
return await _reject_memory(candidate_id)
async def list_world_entities_pending(
*,
character_id: str,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[WorldEntityResponse]:
async with AsyncSessionLocal() as session:
items, total = await world_repo.list_entities(
session, character_id, status=status, page=page, page_size=page_size
)
return Page(
items=[character_world_entity_to_response(i) for i in items],
page=page,
page_size=page_size,
total=total,
)
async def approve_world_entity(entity_id: str) -> WorldEntityResponse:
return await _review_world_entity(entity_id, "confirmed", decision="auto")
async def reject_world_entity(entity_id: str) -> WorldEntityResponse:
return await _review_world_entity(entity_id, "rejected", decision="denied")
async def _reject_memory(candidate_id: str) -> MemoryCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await candidates_repo.get_memory_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "记忆候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
candidate.status = "rejected"
candidate.reviewed_at = utc_now()
await record_audit_event(
session,
action="extraction.memory.rejected",
actor="user",
decision="denied",
character_id=candidate.character_id,
input_summary=candidate.title[:200],
resource_type="memory_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return memory_candidate_to_response(candidate)
async def _review_world_entity(
entity_id: str, target_status: str, *, decision: str
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
entity = await world_repo.get_entity(session, entity_id)
if entity is None:
raise AppError("not_found", "世界实体不存在。")
if entity.status != "pending":
raise AppError("conflict", f"实体当前状态为 {entity.status},不可审核。")
entity.status = target_status
await record_audit_event(
session,
action=f"extraction.world.{target_status}",
actor="user",
decision=decision,
character_id=entity.character_id,
input_summary=entity.name[:200],
resource_type="character_world_entity",
resource_id=entity.id,
)
await session.commit()
await session.refresh(entity)
return character_world_entity_to_response(entity)
+92
View File
@@ -0,0 +1,92 @@
"""会话目标自动持久化服务。
从用户消息自动持久化会话目标到 goals 表。
如果已有该 character 的活跃 session 级目标,判断内容是否显著不同:
- 内容相似 → 复用
- 内容不同 → 完成旧目标,创建新目标
"""
from app.models.governance import Goal
from app.repositories import governance as governance_repo
_TOPIC_SHIFT_MIN_LENGTH = 20
async def auto_persist_session_goal(
session,
character_id: str,
content: str,
) -> Goal | None:
"""自动持久化会话目标:复用活跃的 session 目标或新建。
内容过短(<10 字)不产生目标。
如果已有活跃 session 目标但内容显著不同,先完成旧目标再新建。
Returns:
持久化后的 Goal(可能为 None)。
"""
goal_text = (content or "").strip()
if not goal_text or len(goal_text) < 10:
return None
existing = await governance_repo.list_active_session_goal(session, character_id)
if existing is not None:
# 判断是否话题转换:内容不再相似时完成旧目标
if _is_topic_shift(existing.title, goal_text):
existing.status = "completed"
existing.progress = 1.0
# 创建新目标
goal = Goal(
character_id=character_id,
title=goal_text[:120],
description=f"从对话自动提取的会话目标:{goal_text[:500]}",
level="session",
type="mission",
status="active",
priority=50,
)
await governance_repo.add_goal(session, goal)
return goal
return existing
# 新建 session 目标
goal = Goal(
character_id=character_id,
title=goal_text[:120],
description=f"从对话自动提取的会话目标:{goal_text[:500]}",
level="session",
type="mission",
status="active",
priority=50,
)
await governance_repo.add_goal(session, goal)
return goal
def _is_topic_shift(existing_title: str, new_content: str) -> bool:
"""判断新内容与旧目标标题是否话题转换。
简单规则:如果新内容较长且与旧标题共享的关键词很少,视为话题转换。
这是启发式方法,后续可升级为 LLM 判断。
"""
if len(new_content) < _TOPIC_SHIFT_MIN_LENGTH:
return False
# 提取关键词集合(按空格/标点分割)
import re
old_words = set(re.split(r"[\s,。!?、;:,\.!?;:]+", existing_title.lower()))
new_words = set(re.split(r"[\s,。!?、;:,\.!?;:]+", new_content.lower()))
# 排除常见的助词
stop_words = {"的", "了", "是", "在", "有", "和", "就", "不", "人", "都", "一",
"一个", "上", "也", "很", "到", "说", "要", "去", "你", "我", "他",
"这", "那", "吗", "啊", "呢", "吧", "地", "得"}
old_keywords = old_words - stop_words
new_keywords = new_words - stop_words
if not old_keywords or not new_keywords:
return False
overlap = old_keywords & new_keywords
# 如果旧关键词的一个都没有出现在新内容中,或者重合率极低
if not overlap:
return True
return len(overlap) / max(len(old_keywords), len(new_keywords)) < 0.15
+645
View File
@@ -0,0 +1,645 @@
"""司命台治理对象 CRUD 服务(api-spec §19.4)。
五组开放集合(目标 / 感知通道 / 世界实体 / 计划 / 天工阁角色)的增删改查;
治理写操作一律记审计事件(audit_),并入起居注(§19.3)。pivot 由 action 前缀推断。
所有函数自管 ``AsyncSessionLocal``(路由直接调用,不传入 session)。
"""
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
ToolPolicy,
WorldEntity,
)
from app.modules.runtime.events import Event
from app.repositories import governance as governance_repo
from app.repositories import runtime as runtime_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.admin import (
AgentRosterResponse,
CreateAgentRequest,
CreateGoalRequest,
CreatePerceptionChannelRequest,
CreatePlanRequest,
CreateWorldEntityRequest,
GoalNode,
PerceptionChannelResponse,
PlanSummaryResponse,
UpdateAgentRequest,
UpdateGoalRequest,
UpdatePerceptionChannelRequest,
UpdatePlanRequest,
UpdateWorldEntityRequest,
WorldEntityResponse,
)
from app.schemas.safety import (
CreateToolPolicyRequest,
SafetyPolicyItem,
ToolCallDetail,
ToolPolicyResponse,
UpdateToolPolicyRequest,
)
from app.services.audit import record_audit_event
from app.services.serializers import (
agent_to_response,
goal_to_response,
perception_channel_to_response,
plan_to_response,
tool_policy_to_response,
tool_policy_to_safety_item,
world_entity_to_response,
)
# —— 太一枢 · 目标 ——
async def list_goals() -> list[GoalNode]:
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals(session)
return [goal_to_response(goal) for goal in goals]
async def list_goals_by_character(
character_id: str,
status_filter: str | None = None,
) -> list[GoalNode]:
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals_by_character(
session, character_id, status_filter=status_filter
)
return [goal_to_response(goal) for goal in goals]
async def load_active_goals_context(character_id: str) -> str | None:
"""加载活跃目标并格式化为 system prompt 片段,供运行时注入。
目标按优先级排序(``prioritize_active_goals``),最高优先级在前。
"""
from app.modules.goal_orchestrator import prioritize_active_goals
from app.modules.runtime.goals import build_goals_context
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals_by_character(
session, character_id, status_filter="active"
)
if not goals:
return None
goals_dicts = [
{
"title": g.title,
"level": g.level,
"status": g.status,
"priority": g.priority,
"progress": g.progress,
}
for g in goals
]
# 优先级排序后注入运行时,使最高优先级目标最先被看到
sorted_goals = prioritize_active_goals(goals_dicts)
return build_goals_context(sorted_goals)
async def emit_goal_event(
goal: Goal,
event_type: str,
extra_payload: dict | None = None,
) -> None:
"""向事件存储写入 goal.* 事件。"""
from app.main import app # 惰性 import 避免启动循环
event_store = getattr(app.state, "event_store", None)
if event_store is None:
return
payload = {
"goal_id": goal.id,
"character_id": goal.character_id,
"title": goal.title,
"level": goal.level,
"type": goal.type,
"status": goal.status,
"priority": goal.priority,
"progress": goal.progress,
}
if extra_payload:
payload.update(extra_payload)
event = Event(
type=f"goal.{event_type}",
source="goal_orchestrator",
payload=payload,
character_id=goal.character_id,
visibility="audit",
)
await event_store.append(event)
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
async with AsyncSessionLocal() as session:
goal = Goal(
title=payload.title,
description=payload.description,
level=payload.level,
type=payload.type,
character_id=payload.character_id,
status="pending",
priority=payload.priority,
constraints_json=dumps(payload.constraints),
parent_id=payload.parent_id,
success_criteria_json=dumps(payload.success_criteria),
)
await governance_repo.add_goal(session, goal)
await record_audit_event(
session,
action="goal.created",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
await session.refresh(goal)
await emit_goal_event(goal, "created")
return goal_to_response(goal)
async def update_goal(goal_id: str, payload: UpdateGoalRequest) -> GoalNode:
async with AsyncSessionLocal() as session:
goal = await governance_repo.get_goal(session, goal_id)
if goal is None:
raise AppError("not_found", "目标不存在。")
old_status = goal.status
updates = payload.model_dump(exclude_unset=True)
constraints = updates.pop("constraints", None)
for key, value in updates.items():
setattr(goal, key, value)
if constraints is not None:
goal.constraints_json = dumps(constraints)
goal.updated_at = utc_now()
await record_audit_event(
session,
action="goal.updated",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
await session.refresh(goal)
if old_status != goal.status:
if goal.status == "completed":
await emit_goal_event(goal, "completed", {"previous_status": old_status})
else:
await emit_goal_event(goal, "updated", {"previous_status": old_status})
else:
await emit_goal_event(goal, "updated", {"previous_status": old_status})
return goal_to_response(goal)
async def delete_goal(goal_id: str) -> None:
async with AsyncSessionLocal() as session:
goal = await governance_repo.get_goal(session, goal_id)
if goal is None:
raise AppError("not_found", "目标不存在。")
if goal.level == "highest":
raise AppError("invalid_request", "最高目标(使命级)不可删除。")
await session.delete(goal)
await record_audit_event(
session,
action="goal.deleted",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
# —— 烛照枢 · 感知通道 ——
async def list_perception_channels() -> list[PerceptionChannelResponse]:
async with AsyncSessionLocal() as session:
channels = await governance_repo.list_perception_channels(session)
return [perception_channel_to_response(channel) for channel in channels]
async def create_perception_channel(
payload: CreatePerceptionChannelRequest,
) -> PerceptionChannelResponse:
async with AsyncSessionLocal() as session:
if await governance_repo.get_perception_channel(session, payload.channel) is not None:
raise AppError("conflict", "感知通道已存在。")
channel = PerceptionChannel(
channel=payload.channel,
name=payload.name,
description=payload.description,
risk_level=payload.risk_level,
)
await governance_repo.add_perception_channel(session, channel)
await record_audit_event(
session,
action="perception.created",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
await session.refresh(channel)
return perception_channel_to_response(channel)
async def update_perception_channel(
channel_id: str, payload: UpdatePerceptionChannelRequest
) -> PerceptionChannelResponse:
async with AsyncSessionLocal() as session:
channel = await governance_repo.get_perception_channel(session, channel_id)
if channel is None:
raise AppError("not_found", "感知通道不存在。")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(channel, key, value)
channel.updated_at = utc_now()
await record_audit_event(
session,
action="perception.updated",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
await session.refresh(channel)
return perception_channel_to_response(channel)
async def delete_perception_channel(channel_id: str) -> None:
async with AsyncSessionLocal() as session:
channel = await governance_repo.get_perception_channel(session, channel_id)
if channel is None:
raise AppError("not_found", "感知通道不存在。")
await session.delete(channel)
await record_audit_event(
session,
action="perception.deleted",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
# —— 河图枢 · 世界实体 ——
async def list_world_entities() -> list[WorldEntityResponse]:
async with AsyncSessionLocal() as session:
entities = await governance_repo.list_world_entities(session)
return [world_entity_to_response(entity) for entity in entities]
async def create_world_entity(
payload: CreateWorldEntityRequest,
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
entity = WorldEntity(
type=payload.type,
name=payload.name,
attributes_json=dumps(payload.attributes),
state_type=payload.state_type,
confidence=payload.confidence,
sources_json=dumps(payload.sources),
)
await governance_repo.add_world_entity(session, entity)
await record_audit_event(
session,
action="world.created",
actor="user",
input_summary=entity.name,
resource_type="world_entity",
resource_id=entity.id,
)
await session.commit()
await session.refresh(entity)
return world_entity_to_response(entity)
async def update_world_entity(
entity_id: str, payload: UpdateWorldEntityRequest
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
entity = await governance_repo.get_world_entity(session, entity_id)
if entity is None:
raise AppError("not_found", "世界实体不存在。")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(entity, key, value)
entity.updated_at = utc_now()
await record_audit_event(
session,
action="world.updated",
actor="user",
input_summary=entity.name,
resource_type="world_entity",
resource_id=entity.id,
)
await session.commit()
await session.refresh(entity)
return world_entity_to_response(entity)
async def delete_world_entity(entity_id: str) -> None:
async with AsyncSessionLocal() as session:
entity = await governance_repo.get_world_entity(session, entity_id)
if entity is None:
raise AppError("not_found", "世界实体不存在。")
await session.delete(entity)
await record_audit_event(
session,
action="world.deleted",
actor="user",
input_summary=entity.name,
resource_type="world_entity",
resource_id=entity.id,
)
await session.commit()
# —— 洛书枢 · 计划 ——
async def list_plans() -> list[PlanSummaryResponse]:
async with AsyncSessionLocal() as session:
plans = await governance_repo.list_plans(session)
return [plan_to_response(plan) for plan in plans]
async def create_plan(payload: CreatePlanRequest) -> PlanSummaryResponse:
async with AsyncSessionLocal() as session:
plan = Plan(
goal_id=payload.goal_id,
title=payload.title,
assumptions_json=dumps(payload.assumptions),
risks_json=dumps(payload.risks),
)
await governance_repo.add_plan(session, plan)
await record_audit_event(
session,
action="plan.created",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
await session.refresh(plan)
return plan_to_response(plan)
async def update_plan(plan_id: str, payload: UpdatePlanRequest) -> PlanSummaryResponse:
async with AsyncSessionLocal() as session:
plan = await governance_repo.get_plan(session, plan_id)
if plan is None:
raise AppError("not_found", "计划不存在。")
updates = payload.model_dump(exclude_unset=True)
assumptions = updates.pop("assumptions", None)
risks = updates.pop("risks", None)
for key, value in updates.items():
setattr(plan, key, value)
if assumptions is not None:
plan.assumptions_json = dumps(assumptions)
if risks is not None:
plan.risks_json = dumps(risks)
plan.updated_at = utc_now()
await record_audit_event(
session,
action="plan.updated",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
await session.refresh(plan)
return plan_to_response(plan)
async def delete_plan(plan_id: str) -> None:
async with AsyncSessionLocal() as session:
plan = await governance_repo.get_plan(session, plan_id)
if plan is None:
raise AppError("not_found", "计划不存在。")
await session.delete(plan)
await record_audit_event(
session,
action="plan.deleted",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
# —— 天工阁枢 · 角色在岗 ——
async def list_agent_roster() -> list[AgentRosterResponse]:
async with AsyncSessionLocal() as session:
agents = await governance_repo.list_agent_roster(session)
return [agent_to_response(agent) for agent in agents]
async def create_agent(payload: CreateAgentRequest) -> AgentRosterResponse:
async with AsyncSessionLocal() as session:
if await governance_repo.get_agent(session, payload.role) is not None:
raise AppError("conflict", "角色已存在。")
agent = AgentRosterEntry(
role=payload.role,
name=payload.name,
responsibility=payload.responsibility,
)
await governance_repo.add_agent(session, agent)
await record_audit_event(
session,
action="agent.created",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
await session.refresh(agent)
return agent_to_response(agent)
async def update_agent(role: str, payload: UpdateAgentRequest) -> AgentRosterResponse:
async with AsyncSessionLocal() as session:
agent = await governance_repo.get_agent(session, role)
if agent is None:
raise AppError("not_found", "角色不存在。")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(agent, key, value)
agent.updated_at = utc_now()
await record_audit_event(
session,
action="agent.updated",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
await session.refresh(agent)
return agent_to_response(agent)
async def delete_agent(role: str) -> None:
async with AsyncSessionLocal() as session:
agent = await governance_repo.get_agent(session, role)
if agent is None:
raise AppError("not_found", "角色不存在。")
await session.delete(agent)
await record_audit_event(
session,
action="agent.deleted",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
# —— 百工枢 · 工具策略(§15 / §16 共享)——
def _builtin_tool_names() -> set[str]:
"""builtin 工具名集合(惰性 import,避免 application→services 循环依赖)。"""
from app.application.tool_runtime.builtins import builtin_specs
return set(builtin_specs().keys())
async def list_tool_policies() -> list[ToolPolicyResponse]:
"""GET /tools:完整元数据,executable 按 builtin 派生。"""
async with AsyncSessionLocal() as session:
policies = await governance_repo.list_tool_policies(session)
builtins = _builtin_tool_names()
return [tool_policy_to_response(p, executable=p.name in builtins) for p in policies]
async def list_safety_policies() -> list[SafetyPolicyItem]:
"""GET /safety/policies:策略子集。"""
async with AsyncSessionLocal() as session:
policies = await governance_repo.list_tool_policies(session)
return [tool_policy_to_safety_item(p) for p in policies]
async def create_tool_policy(payload: CreateToolPolicyRequest) -> ToolPolicyResponse:
"""POST /tools、POST /safety/policies:注册工具策略(source=custom)。"""
async with AsyncSessionLocal() as session:
if await governance_repo.get_tool_policy(session, payload.name) is not None:
raise AppError("invalid_request", f"工具 {payload.name} 已存在。")
policy = ToolPolicy(
name=payload.name,
description=payload.description,
risk_level=payload.risk_level,
permission_policy=payload.permission_policy,
side_effect=payload.side_effect,
audit_required=payload.audit_required,
source="custom",
enabled=payload.enabled,
)
await governance_repo.add_tool_policy(session, policy)
await record_audit_event(
session,
action="tool.policy_created",
actor="user",
risk_level=payload.risk_level,
input_summary=payload.name,
resource_type="tool_policy",
resource_id=policy.name,
)
await session.commit()
await session.refresh(policy)
return tool_policy_to_response(policy, executable=policy.name in _builtin_tool_names())
async def update_tool_policy(name: str, payload: UpdateToolPolicyRequest) -> ToolPolicyResponse:
"""PATCH /tools/{name}、PATCH /safety/policies/{tool}:编辑策略元数据。"""
async with AsyncSessionLocal() as session:
policy = await governance_repo.get_tool_policy(session, name)
if policy is None:
raise AppError("not_found", f"工具 {name} 不存在。")
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise AppError("invalid_request", "至少需要提供一个待更新字段。")
for key, value in updates.items():
setattr(policy, key, value)
policy.updated_at = utc_now()
await record_audit_event(
session,
action="tool.policy_updated",
actor="user",
risk_level=policy.risk_level,
input_summary=policy.name,
resource_type="tool_policy",
resource_id=policy.name,
)
await session.commit()
await session.refresh(policy)
return tool_policy_to_response(policy, executable=policy.name in _builtin_tool_names())
async def delete_tool_policy(name: str) -> None:
"""DELETE /tools/{name}、DELETE /safety/policies/{tool}:注销策略。
builtin 工具的策略记录不可删除(执行实现仍在代码层);只允许注销 custom 工具。
"""
async with AsyncSessionLocal() as session:
policy = await governance_repo.get_tool_policy(session, name)
if policy is None:
raise AppError("not_found", f"工具 {name} 不存在。")
if policy.source == "builtin":
raise AppError("invalid_request", f"内置工具 {name} 不可删除,可改用 PATCH 禁用。")
await session.delete(policy)
await record_audit_event(
session,
action="tool.policy_deleted",
actor="user",
input_summary=policy.name,
resource_type="tool_policy",
resource_id=policy.name,
)
await session.commit()
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
"""GET /tools/calls/{tool_call_id}:单条 tool.* 事件详情。
tool_call_id 对应一炁事件存储的事件主键;仅返回 tool.* 类型事件,否则 404。
"""
from app.core.time import to_iso
async with AsyncSessionLocal() as session:
event = await runtime_repo.get_event(session, tool_call_id)
if event is None or not event.type.startswith("tool."):
raise AppError("not_found", f"工具调用 {tool_call_id} 不存在。")
payload = loads_dict(event.payload_json)
return ToolCallDetail(
event_id=event.id,
type=event.type,
tool=payload.get("tool"),
run_id=event.run_id,
character_id=event.character_id,
trace_id=event.trace_id,
payload=payload,
created_at=to_iso(event.created_at),
)
+68
View File
@@ -0,0 +1,68 @@
"""HTTP 幂等键服务。
``run_idempotent`` 自管 ``AsyncSessionLocal``:在同一 session 内查幂等记录、执行 handler、
写入结果(保证三者同事务)。``handler`` 接收该 session,供需要在同事务跑的业务(如发消息)
复用。调用方(路由)不持有 session。
"""
import hashlib
from collections.abc import Awaitable, Callable
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError, current_trace_id
from app.db.session import AsyncSessionLocal
from app.models.idempotency import IdempotencyKey
from app.repositories import idempotency as idempotency_repo
from app.repositories.json_utils import dumps, loads_dict
def request_hash(payload: BaseModel) -> str:
body = payload.model_dump_json(exclude_unset=True)
return hashlib.sha256(body.encode("utf-8")).hexdigest()
async def run_idempotent[T: BaseModel](
*,
key: str | None,
method: str,
path: str,
payload: BaseModel,
response_type: type[T],
resource_type: str,
handler: Callable[[AsyncSession], Awaitable[T]],
owner_scope: str = settings.default_owner_scope,
) -> T:
"""按 Idempotency-Key 保证请求只执行一次;handler 在本函数管理的 session 内执行。"""
async with AsyncSessionLocal() as session:
if not key:
return await handler(session)
scope = owner_scope
digest = request_hash(payload)
existing = await idempotency_repo.get_by_scope_key(session, scope=scope, key=key)
if existing is not None:
if existing.request_hash != digest:
raise AppError("conflict", "Idempotency-Key 已用于不同请求。")
return response_type.model_validate(loads_dict(existing.response_body_json))
response = await handler(session)
resource_id = getattr(response, "run_id", None) or getattr(response, "id", None)
record = IdempotencyKey(
key=key,
scope=scope,
method=method,
path=path,
request_hash=digest,
status="completed",
response_status_code=200,
response_body_json=dumps(response.model_dump(mode="json")),
resource_type=resource_type,
resource_id=str(resource_id) if resource_id else None,
trace_id=current_trace_id(),
)
session.add(record)
await session.commit()
return response
+481
View File
@@ -0,0 +1,481 @@
"""资料导入服务(api-spec §8)。
5a:结构解析(消息数组 / 段落 / 字段)落 ``import_items``,不调模型;媒体资料
(``voice_sample``)仅存元数据与授权记录(spec §18 仅预留,不落盘不解析)。
候选抽取(档案 / 记忆 / 人格)与应用留 5b / 5c。
"""
import hashlib
from fastapi import UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.imports import CharacterImportJob, ImportItem
from app.models.memory import Memory
from app.modules.imports import parse_import
from app.repositories import candidates as candidates_repo
from app.repositories import imports as imports_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.candidates import (
BatchReviewRequest,
BatchReviewResult,
MemoryCandidateResponse,
ProfileCandidateResponse,
)
from app.schemas.common import Page
from app.schemas.imports import (
CreateImportRequest,
ImportItemResponse,
ImportJobResponse,
)
from app.services.audit import record_audit_event
from app.services.characters import get_owned_character
from app.services.serializers import (
import_item_to_response,
import_job_to_response,
memory_candidate_to_response,
profile_candidate_to_response,
)
_TEXT_MIME_FORMAT: dict[str, str] = {
"text/plain": "text",
"text/markdown": "markdown",
"application/json": "json",
"text/csv": "csv",
"application/yaml": "yaml",
"text/yaml": "yaml",
}
_AUDIO_FORMAT: dict[str, str] = {
"audio/wav": "wav",
"audio/mpeg": "mp3",
"audio/mp4": "m4a",
"audio/x-m4a": "m4a",
"audio/flac": "flac",
}
async def create_import_from_json(
character_id: str,
payload: CreateImportRequest,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
"""JSON 提交导入(粘贴文本 / 结构化消息 / 手动设定)。"""
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
job = CharacterImportJob(
character_id=character.id,
material_type=payload.material_type,
source_type="pasted_text",
title=payload.title,
status="pending",
format=payload.format,
source_description=payload.source_note,
raw_content=payload.content,
checksum=_checksum(payload.content),
)
await imports_repo.add_job(session, job)
await _parse_into_items(session, job, payload.content)
await _audit(session, character.id, "import.created", job.id, payload.material_type)
await session.commit()
await session.refresh(job)
return import_job_to_response(job)
async def create_import_from_upload(
character_id: str,
file: UploadFile,
*,
title: str | None = None,
source_description: str | None = None,
speaker_label: str | None = None,
consent_status: str = "unknown",
usage_scope: str = "reference_only",
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
"""文件上传导入(文本类解析为 items;音频类仅存元数据,不落盘)。"""
content_bytes = await file.read()
mime = file.content_type or "application/octet-stream"
filename = file.filename or "upload"
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
if mime in _AUDIO_FORMAT:
job = CharacterImportJob(
character_id=character.id,
material_type="voice_sample",
source_type="media_upload",
title=title,
status="uploaded",
format=_AUDIO_FORMAT[mime],
media_type="audio",
mime_type=mime,
source_description=source_description,
speaker_label=speaker_label,
consent_status=consent_status,
usage_scope=usage_scope,
checksum=_checksum_bytes(content_bytes),
)
await imports_repo.add_job(session, job)
else:
try:
content = content_bytes.decode("utf-8")
except UnicodeDecodeError as exc:
raise AppError("unsupported_media_type", "仅支持 UTF-8 文本文件。") from exc
job = CharacterImportJob(
character_id=character.id,
material_type=_infer_material_type(filename, mime),
source_type="uploaded_file",
title=title or filename,
status="pending",
format=_TEXT_MIME_FORMAT.get(mime, "text"),
mime_type=mime,
source_description=source_description,
raw_content=content,
checksum=_checksum_bytes(content_bytes),
)
await imports_repo.add_job(session, job)
await _parse_into_items(session, job, content)
await _audit(session, character.id, "import.created", job.id, job.material_type)
await session.commit()
await session.refresh(job)
return import_job_to_response(job)
async def get_import(
character_id: str,
import_id: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
job = await imports_repo.get_job(session, import_id)
if job is None or job.character_id != character.id:
raise AppError("not_found", "导入任务不存在。")
return import_job_to_response(job)
async def list_jobs_page(
character_id: str,
*,
page: int = 1,
page_size: int = 20,
owner_scope: str = settings.default_owner_scope,
) -> Page[ImportJobResponse]:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
safe_page = max(page, 1)
safe_size = min(max(page_size, 1), 100)
items, total = await imports_repo.list_jobs(
session, character.id, page=safe_page, page_size=safe_size
)
return Page(
items=[import_job_to_response(item) for item in items],
page=safe_page,
page_size=safe_size,
total=total,
)
async def list_import_items(
character_id: str,
import_id: str,
*,
page: int = 1,
page_size: int = 20,
owner_scope: str = settings.default_owner_scope,
) -> Page[ImportItemResponse]:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
job = await imports_repo.get_job(session, import_id)
if job is None or job.character_id != character.id:
raise AppError("not_found", "导入任务不存在。")
safe_page = max(page, 1)
safe_size = min(max(page_size, 1), 100)
items, total = await imports_repo.list_items(
session, import_id, page=safe_page, page_size=safe_size
)
return Page(
items=[import_item_to_response(item) for item in items],
page=safe_page,
page_size=safe_size,
total=total,
)
async def _parse_into_items(session: AsyncSession, job: CharacterImportJob, content: str) -> None:
"""结构解析为 import_items 并回填 job 状态;纯结构,不调模型。"""
job.status = "parsing"
await session.flush()
drafts = parse_import(job.material_type, job.format, content=content)
items = [
ImportItem(
import_job_id=job.id,
character_id=job.character_id,
item_type=draft.item_type,
role=draft.role,
sequence=idx,
content=draft.content,
metadata_json=dumps(draft.metadata),
)
for idx, draft in enumerate(drafts)
]
await imports_repo.add_items(session, items)
job.item_count = len(items)
job.status = "parsed" if items else "uploaded"
async def _audit(
session: AsyncSession,
character_id: str,
action: str,
resource_id: str,
summary: str,
) -> None:
await record_audit_event(
session,
action=action,
character_id=character_id,
actor="user",
decision="auto",
input_summary=summary,
resource_type="import_job",
resource_id=resource_id,
)
def _checksum(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _checksum_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _infer_material_type(filename: str, mime: str) -> str:
name = filename.lower()
if "chat" in name or "history" in name:
return "chat_history"
if mime == "application/json":
return "other"
return "background_story"
# —— 候选抽取触发与列表(5b,异步后台)——
_EXTRACTABLE_STATUSES = frozenset({"parsed", "uploaded", "reviewing"})
async def _get_owned_job(
session: AsyncSession, character_id: str, import_id: str
) -> CharacterImportJob:
job = await imports_repo.get_job(session, import_id)
if job is None or job.character_id != character_id:
raise AppError("not_found", "导入任务不存在。")
return job
async def _reload_job(import_id: str) -> ImportJobResponse:
async with AsyncSessionLocal() as session:
job = await imports_repo.get_job(session, import_id)
return import_job_to_response(job)
async def trigger_profile_extraction(
character_id: str,
import_id: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
return await _trigger_extraction(
character_id, import_id, "extracting_profile", "profile", owner_scope=owner_scope
)
async def trigger_memory_extraction(
character_id: str,
import_id: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
return await _trigger_extraction(
character_id, import_id, "extracting_memory", "memory", owner_scope=owner_scope
)
async def trigger_personality_extraction(
character_id: str,
import_id: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
return await _trigger_extraction(
character_id,
import_id,
"generating_personality_proposals",
"personality",
owner_scope=owner_scope,
)
async def _trigger_extraction(
character_id: str,
import_id: str,
status: str,
kind: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
"""校验 job → 置 extracting_* → 启动后台抽取 → 返回 job 当前状态。"""
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
job = await _get_owned_job(session, character.id, import_id)
if job.status not in _EXTRACTABLE_STATUSES:
raise AppError("conflict", f"任务状态 {job.status} 不可触发抽取。")
job.status = status
await session.commit()
from app.application.imports import (
start_memory_extraction,
start_personality_extraction,
start_profile_extraction,
)
starters = {
"profile": start_profile_extraction,
"memory": start_memory_extraction,
"personality": start_personality_extraction,
}
starters[kind](import_id)
return await _reload_job(import_id)
async def list_profile_candidates(
character_id: str,
import_id: str,
*,
status: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> list[ProfileCandidateResponse]:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
await _get_owned_job(session, character.id, import_id)
items = await candidates_repo.list_profile_candidates(
session, character.id, import_job_id=import_id, status=status
)
return [profile_candidate_to_response(c) for c in items]
async def list_memory_candidates(
character_id: str,
import_id: str,
*,
status: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> list[MemoryCandidateResponse]:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
await _get_owned_job(session, character.id, import_id)
items = await candidates_repo.list_memory_candidates(
session, character.id, import_job_id=import_id, status=status
)
return [memory_candidate_to_response(c) for c in items]
# —— 候选审核应用(5c)——
async def batch_review_profile_candidates(
character_id: str,
payload: BatchReviewRequest,
owner_scope: str = settings.default_owner_scope,
) -> BatchReviewResult:
"""批量审核档案候选:approve 写入人物档案,reject 标记驳回,非 pending 跳过。"""
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
profile = loads_dict(character.profile_json)
applied = rejected = skipped = 0
for decision in payload.decisions:
candidate = await candidates_repo.get_profile_candidate(session, decision.candidate_id)
if (
candidate is None
or candidate.character_id != character.id
or candidate.status != "pending"
):
skipped += 1
continue
if decision.decision == "approve":
profile[candidate.field] = candidate.value
if candidate.field == "display_name":
character.display_name = candidate.value
candidate.status = "applied"
applied += 1
elif decision.decision == "reject":
candidate.status = "rejected"
rejected += 1
else:
skipped += 1
continue
candidate.reviewed_at = utc_now()
character.profile_json = dumps(profile)
await _audit(
session,
character.id,
"import.profile_candidate_reviewed",
character.id,
f"applied={applied} rejected={rejected}",
)
await session.commit()
return BatchReviewResult(applied=applied, rejected=rejected, skipped=skipped)
async def batch_review_memory_candidates(
character_id: str,
payload: BatchReviewRequest,
owner_scope: str = settings.default_owner_scope,
) -> BatchReviewResult:
"""批量审核记忆候选:approve 创建记忆,reject 标记驳回。"""
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
applied = rejected = skipped = 0
for decision in payload.decisions:
candidate = await candidates_repo.get_memory_candidate(session, decision.candidate_id)
if (
candidate is None
or candidate.character_id != character.id
or candidate.status != "pending"
):
skipped += 1
continue
if decision.decision == "approve":
session.add(
Memory(
character_id=character.id,
type=candidate.type,
title=candidate.title,
content=candidate.content,
source="user_explicit",
confidence=candidate.confidence,
importance=candidate.importance,
sensitivity=candidate.sensitivity,
evidence_ids_json=candidate.evidence_ids_json,
status="active",
)
)
candidate.status = "applied"
applied += 1
elif decision.decision == "reject":
candidate.status = "rejected"
rejected += 1
else:
skipped += 1
continue
candidate.reviewed_at = utc_now()
await _audit(
session,
character.id,
"import.memory_candidate_reviewed",
character.id,
f"applied={applied} rejected={rejected}",
)
await session.commit()
return BatchReviewResult(applied=applied, rejected=rejected, skipped=skipped)
+276
View File
@@ -0,0 +1,276 @@
"""世谱服务:注入编排 + CRUD。
注入编排(build_lorebook_injection):查条目 → 匹配 → 预算 → 分组为 before/after 文本,
IO 在此层完成;纯匹配逻辑在 app.modules.lorebook。
"""
from __future__ import annotations
import json
from collections import defaultdict
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.lorebook import Lorebook as LorebookOrm
from app.models.lorebook import LorebookEntry as LorebookEntryOrm
from app.modules.lorebook import (
LorebookBook,
LorebookEntry,
build_injections,
match_entries,
select_within_budget,
)
from app.repositories import lorebook as lorebook_repo
from app.repositories.json_utils import dumps
from app.schemas.lorebook import (
LorebookCreateRequest,
LorebookEntryCreateRequest,
LorebookEntryResponse,
LorebookEntryUpdateRequest,
LorebookResponse,
LorebookUpdateRequest,
)
# ==================== 注入编排(供 send_message 调用)====================
async def build_lorebook_injection(
session: AsyncSession,
character_id: str,
owner_scope: str,
scan_text: str,
) -> tuple[str | None, str | None]:
"""扫描对话文本,返回世谱注入的 (before_char, after_char);无命中返回 (None, None)。
每本书各自按其 token_budget 裁剪,再跨书合并按 position 分组。
"""
books = await lorebook_repo.list_active_books(session, character_id, owner_scope)
if not books:
return None, None
entries = await lorebook_repo.list_entries_for_books(session, [b.id for b in books])
if not entries:
return None, None
entries_by_book: dict[str, list[LorebookEntryOrm]] = defaultdict(list)
for entry in entries:
entries_by_book[entry.lorebook_id].append(entry)
kept_all = []
for book_orm in books:
module_book = _to_module_book(book_orm, entries_by_book.get(book_orm.id, []))
matched = match_entries(module_book, scan_text)
kept_all.extend(
select_within_budget(matched, token_budget=module_book.token_budget)
)
injections = build_injections(kept_all)
return injections.get("before_char"), injections.get("after_char")
def _to_module_book(book_orm: LorebookOrm, entries_orm: list[LorebookEntryOrm]) -> LorebookBook:
return LorebookBook(
id=book_orm.id,
name=book_orm.name,
description=book_orm.description,
entries=[_to_module_entry(e) for e in entries_orm],
scan_depth=book_orm.scan_depth,
token_budget=book_orm.token_budget,
recursive_scanning=book_orm.recursive_scanning,
scope=book_orm.scope,
)
def _to_module_entry(entry_orm: LorebookEntryOrm) -> LorebookEntry:
return LorebookEntry(
id=entry_orm.id,
keys=json.loads(entry_orm.keys_json or "[]"),
content=entry_orm.content,
secondary_keys=json.loads(entry_orm.secondary_keys_json or "[]"),
constant=entry_orm.constant,
selective=entry_orm.selective,
enabled=entry_orm.enabled,
case_sensitive=entry_orm.case_sensitive,
use_regex=entry_orm.use_regex,
position=entry_orm.position,
insertion_order=entry_orm.insertion_order,
priority=entry_orm.priority,
comment=entry_orm.comment,
bound_entity_id=entry_orm.bound_entity_id,
)
# ==================== CRUD(供 API 调用)====================
async def create_lorebook(
session: AsyncSession, payload: LorebookCreateRequest, owner_scope: str
) -> LorebookResponse:
book = LorebookOrm(
character_id=payload.character_id,
name=payload.name,
description=payload.description,
scan_depth=payload.scan_depth,
token_budget=payload.token_budget,
recursive_scanning=payload.recursive_scanning,
scope=payload.scope,
owner_scope=owner_scope,
)
session.add(book)
await session.flush()
response = _book_response(book, [])
await session.commit()
return response
async def list_lorebooks(
*,
character_id: str | None,
owner_scope: str,
scope: str | None = None,
) -> list[LorebookResponse]:
async with AsyncSessionLocal() as session:
books = await lorebook_repo.list_books(session, character_id, owner_scope, scope=scope)
return [_book_response(b, []) for b in books]
async def get_lorebook(lorebook_id: str, owner_scope: str) -> LorebookResponse:
async with AsyncSessionLocal() as session:
book = await _require_book(session, lorebook_id, owner_scope)
entries = await lorebook_repo.list_book_entries(session, lorebook_id)
return _book_response(book, entries)
async def update_lorebook(
lorebook_id: str, payload: LorebookUpdateRequest, owner_scope: str
) -> LorebookResponse:
async with AsyncSessionLocal() as session:
book = await _require_book(session, lorebook_id, owner_scope)
for field in ("name", "description", "scan_depth", "token_budget", "recursive_scanning"):
value = getattr(payload, field)
if value is not None:
setattr(book, field, value)
await session.commit()
entries = await lorebook_repo.list_book_entries(session, lorebook_id)
return _book_response(book, entries)
async def delete_lorebook(lorebook_id: str, owner_scope: str) -> LorebookResponse:
async with AsyncSessionLocal() as session:
book = await _require_book(session, lorebook_id, owner_scope)
book.status = "archived"
await session.commit()
return _book_response(book, [])
async def create_entry(
session: AsyncSession,
lorebook_id: str,
payload: LorebookEntryCreateRequest,
owner_scope: str,
) -> LorebookEntryResponse:
await _require_book(session, lorebook_id, owner_scope)
entry = LorebookEntryOrm(
lorebook_id=lorebook_id,
keys_json=dumps(payload.keys),
content=payload.content,
secondary_keys_json=dumps(payload.secondary_keys),
constant=payload.constant,
selective=payload.selective,
enabled=payload.enabled,
case_sensitive=payload.case_sensitive,
use_regex=payload.use_regex,
position=payload.position,
insertion_order=payload.insertion_order,
priority=payload.priority,
comment=payload.comment,
bound_entity_id=payload.bound_entity_id,
)
session.add(entry)
await session.flush()
response = _entry_response(entry)
await session.commit()
return response
async def update_entry(
lorebook_id: str,
entry_id: str,
payload: LorebookEntryUpdateRequest,
owner_scope: str,
) -> LorebookEntryResponse:
async with AsyncSessionLocal() as session:
await _require_book(session, lorebook_id, owner_scope)
entry = await _require_entry(session, entry_id, lorebook_id)
for key, value in payload.model_dump(exclude_unset=True).items():
if key in ("keys", "secondary_keys"):
setattr(entry, f"{key}_json", dumps(value))
else:
setattr(entry, key, value)
await session.commit()
return _entry_response(entry)
async def delete_entry(lorebook_id: str, entry_id: str, owner_scope: str) -> None:
async with AsyncSessionLocal() as session:
await _require_book(session, lorebook_id, owner_scope)
entry = await _require_entry(session, entry_id, lorebook_id)
await session.delete(entry)
await session.commit()
async def _require_book(
session: AsyncSession, lorebook_id: str, owner_scope: str
) -> LorebookOrm:
book = await lorebook_repo.get_book(session, lorebook_id)
if book is None or book.owner_scope != owner_scope or book.status == "archived":
raise AppError("not_found", "世谱不存在。")
return book
async def _require_entry(
session: AsyncSession, entry_id: str, lorebook_id: str
) -> LorebookEntryOrm:
entry = await lorebook_repo.get_entry(session, entry_id)
if entry is None or entry.lorebook_id != lorebook_id:
raise AppError("not_found", "条目不存在。")
return entry
def _entry_response(entry: LorebookEntryOrm) -> LorebookEntryResponse:
return LorebookEntryResponse(
id=entry.id,
lorebook_id=entry.lorebook_id,
keys=json.loads(entry.keys_json or "[]"),
content=entry.content,
secondary_keys=json.loads(entry.secondary_keys_json or "[]"),
constant=entry.constant,
selective=entry.selective,
enabled=entry.enabled,
case_sensitive=entry.case_sensitive,
use_regex=entry.use_regex,
position=entry.position,
insertion_order=entry.insertion_order,
priority=entry.priority,
comment=entry.comment,
bound_entity_id=entry.bound_entity_id,
)
def _book_response(
book: LorebookOrm, entries: list[LorebookEntryOrm]
) -> LorebookResponse:
return LorebookResponse(
id=book.id,
character_id=book.character_id,
name=book.name,
description=book.description,
scope=book.scope,
scan_depth=book.scan_depth,
token_budget=book.token_budget,
recursive_scanning=book.recursive_scanning,
status=book.status,
entries=[_entry_response(e) for e in entries],
)
+472
View File
@@ -0,0 +1,472 @@
"""MCP server 注册服务(支持 streamable-http / stdio 两种传输协议)。
注册流程:
- streamable-http:POST 时实时拉 tools/list → 存 tools_json + register ToolSpec
- stdio:POST 时只存 DB,tools_json 留空;前端调用 /start 后才启进程 → 拉 tools/list → register
启动加载(lifespan):
- streamable-http 按 tools_json 缓存 register ToolSpec
- stdio 跳过(tools_json 空时无 ToolSpec 可注册)
"""
from __future__ import annotations
import json
from app.application.tool_runtime.mcp import make_mcp_tool_specs
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.errors import AppError
from app.core.ids import new_mcp_server_id
from app.db.session import AsyncSessionLocal
from app.integrations.mcp.process_manager import ProcessManager
from app.integrations.mcp.transports import get_transport
from app.models.mcp_servers import McpServer
from app.repositories import mcp_servers as mcp_repo
from app.repositories.json_utils import dumps
from app.schemas.mcp import McpServerResponse, McpServerStatusResponse
from app.services.audit import record_audit_event
async def register_mcp_server(
*,
name: str,
transport: str = "streamable-http",
endpoint_url: str | None = None,
auth_token: str | None = None,
command: str | None = None,
args_json: str | None = None,
env_json: str | None = None,
cwd: str | None = None,
status: str = "enabled",
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerResponse:
async with AsyncSessionLocal() as session:
existing = await mcp_repo.get_server_by_name(session, name)
if existing is not None:
raise AppError("conflict", f"MCP server「{name}」已存在。")
server = McpServer(
id=new_mcp_server_id(),
name=name,
transport=transport,
endpoint_url=endpoint_url,
auth_token=auth_token,
command=command,
args_json=args_json,
env_json=env_json,
cwd=cwd,
status=status,
tools_json="[]",
)
# streamable-http:注册时同步拉取工具清单
if transport == "streamable-http" and endpoint_url:
transport_instance = get_transport(server)
tools = await transport_instance.list_tools()
server.tools_json = dumps(tools)
if runtime and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
if process_manager is not None:
process_manager.register_transport(server.id, transport_instance)
# stdio:注册时启动子进程并拉取工具清单
elif transport == "stdio":
if process_manager is not None:
transport_instance = get_transport(server)
process_manager.register_transport(server.id, transport_instance)
await transport_instance.start()
tools = await transport_instance.list_tools()
server.tools_json = dumps(tools)
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(
server, tools, runtime.http_client, transport=transport_instance
):
runtime.register(spec)
await mcp_repo.add_server(session, server)
await record_audit_event(
session,
action="mcp.server.registered",
actor="user",
input_summary=name[:200],
resource_type="mcp_server",
resource_id=server.id,
)
await session.commit()
await session.refresh(server)
return _to_response(server)
async def list_mcp_servers(*, status: str | None = None) -> list[McpServerResponse]:
async with AsyncSessionLocal() as session:
servers = await mcp_repo.list_servers(session, status=status)
return [_to_response(s) for s in servers]
async def delete_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> None:
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server is None:
raise AppError("not_found", "MCP server 不存在。")
# 先 stop(如果是 stdio 且正在运行)
if process_manager is not None:
transport = process_manager.get(server_id)
if transport and transport.is_running:
await transport.stop()
process_manager.unregister(server_id)
# unregister ToolSpec
if runtime is not None and server.tools_json:
tools = json.loads(server.tools_json) if server.tools_json else []
for tool in tools:
tool_name = tool.get("name") if isinstance(tool, dict) else None
if tool_name:
runtime.unregister(f"mcp_{server.name}_{tool_name}")
name = server.name
await session.delete(server)
await record_audit_event(
session,
action="mcp.server.deleted",
actor="user",
input_summary=name[:200],
resource_type="mcp_server",
resource_id=server_id,
)
await session.commit()
async def start_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerStatusResponse:
"""启动 stdio MCP server 子进程,拉取工具清单并注册。"""
if process_manager is None:
raise AppError("invalid_request", "进程管理器未就绪")
transport = process_manager.get(server_id)
if transport is None:
raise AppError("not_found", f"MCP server {server_id} 未找到")
if isinstance(transport, StreamableHttpTransport):
raise AppError("invalid_request", "streamable-http 无需启动")
await transport.start()
# 拉取工具清单
tools = await transport.list_tools()
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server is None:
await transport.stop()
raise AppError("not_found", "MCP server 不存在")
server.tools_json = dumps(tools)
await session.commit()
# 注册 ToolSpec
if runtime is not None and runtime.http_client is not None:
from app.application.tool_runtime.mcp import make_mcp_tool_specs
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server:
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
status = await process_manager.status(server_id)
return McpServerStatusResponse(**status)
async def stop_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerStatusResponse:
"""停止 stdio MCP server 子进程并注销 ToolSpec。"""
if process_manager is None:
raise AppError("invalid_request", "进程管理器未就绪")
transport = process_manager.get(server_id)
if transport is None:
raise AppError("not_found", f"MCP server {server_id} 未找到")
if isinstance(transport, StreamableHttpTransport):
raise AppError("invalid_request", "streamable-http 无需停止")
await transport.stop()
# unregister ToolSpec
if runtime is not None:
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server and server.tools_json:
tools = json.loads(server.tools_json)
for tool in tools:
name = tool.get("name") if isinstance(tool, dict) else None
if name:
runtime.unregister(f"mcp_{server.name}_{name}")
status = await process_manager.status(server_id)
return McpServerStatusResponse(**status)
async def restart_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerStatusResponse:
"""重启 stdio MCP server。"""
await stop_mcp_server(server_id, runtime=runtime, process_manager=process_manager)
return await start_mcp_server(server_id, runtime=runtime, process_manager=process_manager)
async def refresh_mcp_server_tools(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerResponse:
"""重新拉取 MCP server 工具清单,更新缓存并注册到运行时。"""
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server is None:
raise AppError("not_found", "MCP server 不存在。")
# 注销旧工具
old_tools = json.loads(server.tools_json) if server.tools_json else []
if runtime is not None:
for tool in old_tools:
tool_name = tool.get("name") if isinstance(tool, dict) else None
if tool_name:
runtime.unregister(f"mcp_{server.name}_{tool_name}")
# 重新拉取工具清单(优先用 process_manager 中的传输实例,确保 stdio 进程已启动)
transport_instance = None
if process_manager is not None:
transport_instance = process_manager.get(server_id)
if transport_instance is None:
transport_instance = get_transport(server)
tools = await transport_instance.list_tools()
server.tools_json = dumps(tools)
# 注册新工具
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
await record_audit_event(
session,
action="mcp.server.refreshed",
actor="user",
input_summary=server.name[:200],
resource_type="mcp_server",
resource_id=server.id,
)
await session.commit()
await session.refresh(server)
return _to_response(server)
def _tool_names_from_json(tools_json: str) -> list[str]:
"""从 tools_json JSON 字符串提取工具名列表。"""
import json
try:
tools = json.loads(tools_json) if tools_json else []
except (ValueError, TypeError):
return []
return [t.get("name") for t in tools if isinstance(t, dict) and t.get("name")]
async def update_mcp_server(
server_id: str,
*,
status: str | None = None,
endpoint_url: str | None = None,
auth_token: str | None = None,
command: str | None = None,
args_json: str | None = None,
env_json: str | None = None,
cwd: str | None = None,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerResponse:
"""更新 MCP server 字段(部分更新,不传的字段保持不变)。
更新 endpoint_url 时自动重新拉取工具清单(streamable-http 类型)。
"""
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server is None:
raise AppError("not_found", "MCP server 不存在。")
# 记录旧的工具清单,以便后续注销
old_tools_json = server.tools_json
if status is not None:
old_status = server.status
server.status = status
# 启用→禁用:注销运行时中的工具
if (
status == "disabled"
and old_status == "enabled"
and runtime is not None
and old_tools_json
):
for tool_name in _tool_names_from_json(old_tools_json):
runtime.unregister(f"mcp_{server.name}_{tool_name}")
# 禁用→启用(HTTP):重新拉取工具并注册
if (
status == "enabled"
and old_status == "disabled"
and server.transport == "streamable-http"
and server.endpoint_url
):
from app.application.tool_runtime.mcp import make_mcp_tool_specs
from app.integrations.mcp.transports import get_transport
transport_instance = get_transport(server)
new_tools = await transport_instance.list_tools()
server.tools_json = dumps(new_tools)
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, new_tools, runtime.http_client):
runtime.register(spec)
endpoint_changed = endpoint_url is not None and endpoint_url != server.endpoint_url
if endpoint_url is not None:
server.endpoint_url = endpoint_url
if auth_token is not None:
server.auth_token = auth_token
if command is not None:
server.command = command
if args_json is not None:
server.args_json = args_json
if env_json is not None:
server.env_json = env_json
if cwd is not None:
server.cwd = cwd
# streamable-http 且 endpoint 变化时:重新拉取工具清单并注册
if endpoint_changed and server.transport == "streamable-http" and server.endpoint_url:
from app.application.tool_runtime.mcp import make_mcp_tool_specs
from app.integrations.mcp.transports import get_transport
# 注销旧工具
if runtime is not None and old_tools_json:
old_tools = json.loads(old_tools_json) if old_tools_json else []
for tool in old_tools:
tool_name = tool.get("name") if isinstance(tool, dict) else None
if tool_name:
runtime.unregister(f"mcp_{server.name}_{tool_name}")
# 拉取新工具(优先用 process_manager 中的传输实例)
transport_instance = None
if process_manager is not None:
transport_instance = process_manager.get(server_id)
if transport_instance is None:
transport_instance = get_transport(server)
new_tools = await transport_instance.list_tools()
server.tools_json = dumps(new_tools)
# 注册新工具
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, new_tools, runtime.http_client):
runtime.register(spec)
await record_audit_event(
session,
action="mcp.server.updated",
actor="user",
input_summary=server.name[:200],
resource_type="mcp_server",
resource_id=server.id,
)
await session.commit()
await session.refresh(server)
return _to_response(server)
async def get_mcp_server_status(
server_id: str,
*,
process_manager: ProcessManager | None = None,
) -> McpServerStatusResponse:
"""查询 MCP server 进程状态。"""
if process_manager is None:
return McpServerStatusResponse(
server_id=server_id,
name="unknown",
transport="unknown",
status="unknown",
)
status = await process_manager.status(server_id)
return McpServerStatusResponse(**status)
async def load_registered_mcp_servers(
runtime: ToolRuntime, process_manager: ProcessManager | None = None
) -> None:
"""启动加载:按 transport 类型注册 MCP server。"""
if runtime.http_client is None:
return
async with AsyncSessionLocal() as session:
servers = await mcp_repo.list_servers(session, status="enabled")
for server in servers:
try:
if server.transport == "streamable-http":
tools = json.loads(server.tools_json) if server.tools_json else []
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
# 创建传输层实例但 no-op start
if process_manager is not None:
transport_instance = get_transport(server, runtime.http_client)
process_manager.register_transport(server.id, transport_instance)
elif server.transport == "stdio":
# stdio 不自动启动,只注册传输层实例到管理器
if process_manager is not None:
transport_instance = get_transport(server)
process_manager.register_transport(server.id, transport_instance)
except Exception: # noqa: BLE001
pass
# 导入用于类型判断
from app.integrations.mcp.transports.http import StreamableHttpTransport # noqa: E402, F811
def _to_response(server: McpServer) -> McpServerResponse:
try:
tools = json.loads(server.tools_json) if server.tools_json else []
except (ValueError, TypeError):
tools = []
return McpServerResponse(
id=server.id,
name=server.name,
transport=server.transport,
endpoint_url=server.endpoint_url,
has_auth_token=bool(server.auth_token),
command=server.command,
args_json=server.args_json,
env_json=server.env_json,
cwd=server.cwd,
status=server.status,
tools=tools,
created_at=server.created_at,
updated_at=server.updated_at,
)
+304
View File
@@ -0,0 +1,304 @@
"""记忆服务。
集成 ChromaDB 向量存储:
- create_memory / 大/小周天自动创建的 Memory 写入后异步生成 embedding 存向量
- retrieve_memories 支持向量相似度检索,失败时回退 SQL 排序检索
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.core.logging import logger
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.modules.memory.context import MemoryRetrievalResult
from app.repositories import memory as memory_repo
from app.repositories.json_utils import dumps
from app.schemas.common import Page
from app.schemas.memory import MemoryCreateRequest, MemoryResponse, MemoryUpdateRequest
from app.services.audit import record_audit_event
from app.services.characters import get_active_character_model
from app.services.serializers import memory_to_response
def _get_embedding_service(manager):
"""尝试创建 EmbeddingService,失败时返回 None。"""
try:
models = manager.model_registry.list_by_capability("embedding")
if not models:
return None
from app.ai_provider.embedding import EmbeddingService
return EmbeddingService(manager)
except Exception: # noqa: BLE001
return None
async def _embed_and_store(memory: Memory) -> None:
"""异步后台任务:为记忆生成 embedding 并存入向量库。
embedding 服务和 ChromaDB 均不可用时静默跳过——不影响主流程。
"""
text = memory.content or memory.title or ""
if not text:
return
try:
from app.ai_provider import ProviderManagerFactory
manager = ProviderManagerFactory.create()
service = _get_embedding_service(manager)
if service is None:
return
import asyncio as _asyncio
response = await _asyncio.wait_for(
service.embed(text),
timeout=10.0,
)
if not response.data or not response.data[0].embedding:
return
embedding = response.data[0].embedding
from app.integrations.vector_store import get_vector_store
store = get_vector_store()
if store is None:
return
store.add_memory(
character_id=memory.character_id,
memory_id=memory.id,
text=text,
embedding=embedding,
metadata={
"type": memory.type,
"source": memory.source,
"title": memory.title or "",
},
)
except Exception: # noqa: BLE001
logger.warning(
"memory_embed_failed",
memory_id=memory.id,
character_id=memory.character_id,
)
async def embed_memory_async(memory: Memory) -> None:
"""公开接口:为单条记忆生成 embedding 并存入向量库(供大/小周天后台任务调用)。"""
await _embed_and_store(memory)
# ── CRUD ──────────────────────────────────────────────────────────────────
async def list_memory_page(
*,
page: int = 1,
page_size: int = 20,
memory_type: str | None = None,
source: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> Page[MemoryResponse]:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
safe_page_size = min(max(page_size, 1), 100)
safe_page = max(page, 1)
items, total = await memory_repo.list_memories(
session,
character.id,
memory_type=memory_type,
source=source,
page=safe_page,
page_size=safe_page_size,
)
return Page(
items=[memory_to_response(item) for item in items],
page=safe_page,
page_size=safe_page_size,
total=total,
)
async def create_memory(
session: AsyncSession,
payload: MemoryCreateRequest,
*,
owner_scope: str = settings.default_owner_scope,
) -> MemoryResponse:
character = await get_active_character_model(session, owner_scope)
character_id = payload.character_id or character.id
if character_id != character.id:
raise AppError("not_found", "人物不存在。")
memory = Memory(
character_id=character_id,
type=payload.type,
title=payload.title,
content=payload.content,
source=payload.source,
confidence=payload.confidence,
importance=payload.importance,
sensitivity=payload.sensitivity,
evidence_ids_json=dumps(payload.evidence_ids),
status="active",
)
session.add(memory)
await session.flush()
# 异步 embedding
if settings.vector_db_enabled:
import asyncio
asyncio.ensure_future(_embed_and_store(memory))
await record_audit_event(
session,
action="memory.created",
character_id=character_id,
actor="user",
decision="auto",
input_summary=payload.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def update_memory(
memory_id: str, payload: MemoryUpdateRequest, *, owner_scope: str = settings.default_owner_scope
) -> MemoryResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
memory = await memory_repo.get_memory(session, memory_id)
if memory is None or memory.character_id != character.id or memory.deleted_at is not None:
raise AppError("not_found", "记忆不存在。")
updates = payload.model_dump(exclude_unset=True)
evidence_ids = updates.pop("evidence_ids", None)
for key, value in updates.items():
setattr(memory, key, value)
if evidence_ids is not None:
memory.evidence_ids_json = dumps(evidence_ids)
memory.updated_at = utc_now()
# 如果内容变了,异步更新向量
if ("content" in updates or "title" in updates) and settings.vector_db_enabled:
import asyncio
asyncio.ensure_future(_embed_and_store(memory))
await record_audit_event(
session,
action="memory.updated",
character_id=character.id,
actor="user",
decision="auto",
input_summary=memory.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def delete_memory(
memory_id: str, *, owner_scope: str = settings.default_owner_scope
) -> MemoryResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
memory = await memory_repo.get_memory(session, memory_id)
if memory is None or memory.character_id != character.id or memory.deleted_at is not None:
raise AppError("not_found", "记忆不存在。")
memory.status = "deleted"
memory.deleted_at = utc_now()
memory.updated_at = utc_now()
# 同步删除向量
if settings.vector_db_enabled:
try:
from app.integrations.vector_store import get_vector_store
store = get_vector_store()
if store is not None:
store.delete_memory(character.id, memory_id)
except Exception: # noqa: BLE001
pass
await record_audit_event(
session,
action="memory.deleted",
character_id=character.id,
actor="user",
decision="auto",
input_summary=memory.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def memory_context(
session: AsyncSession,
character_id: str,
*,
limit: int,
_owner_scope: str = settings.default_owner_scope,
) -> str:
"""获取记忆上下文文本(供叩心枢开场、辰正等场景使用)。"""
memories = await memory_repo.list_injectable_memories(session, character_id, limit=limit)
if not memories:
return ""
lines = [f"- {item.title}: {item.content}" for item in memories]
return "相关记忆:\n" + "\n".join(lines)
async def retrieve_memories(
session: AsyncSession,
character_id: str,
*,
limit: int,
_owner_scope: str = settings.default_owner_scope,
) -> MemoryRetrievalResult:
"""检索可注入上下文的相关记忆。
优先尝试向量相似度检索,失败时回退 SQL 排序检索。
"""
# 尝试向量检索(5s 超时,避免 embedding endpoint 不可达时阻塞消息流)
if settings.vector_db_enabled:
try:
from app.ai_provider import ProviderManagerFactory
manager = ProviderManagerFactory.create()
models = manager.model_registry.list_by_capability("embedding")
if models:
import asyncio as _asyncio
from app.ai_provider.embedding import EmbeddingService
from app.integrations.vector_store import get_vector_store
query_text = f"检索 {character_id} 的最新相关记忆"
service = EmbeddingService(manager)
response = await _asyncio.wait_for(
service.embed(query_text),
timeout=5.0,
)
if response.data and response.data[0].embedding:
store = get_vector_store()
if store is not None:
vector_results = store.search(
character_id,
response.data[0].embedding,
limit=limit,
)
if vector_results:
ids = [r["id"] for r in vector_results]
memories = await memory_repo.get_memories_by_ids(session, ids)
if memories:
return MemoryRetrievalResult(memories=memories)
except Exception: # noqa: BLE001
logger.warning("vector_retrieval_failed,回退 SQL 检索")
# 回退 SQL
memories = await memory_repo.list_injectable_memories(session, character_id, limit=limit)
return MemoryRetrievalResult(memories=memories)
+298
View File
@@ -0,0 +1,298 @@
"""
感知服务编排。
职责:
1. 接收图片 → 预处理(灰度增强/对比度调整)
2. 调用天目 OCR(带缓存和重试)
3. 后处理 → 结构化
4. 写入事件系统(可选)
使用示例::
service = PerceptionService()
result = await service.recognize(image_bytes)
if result.success:
print(f"识别结果: {result.text}")
await service.close()
"""
from __future__ import annotations
import hashlib
import io
from collections import OrderedDict
from pathlib import Path
from typing import Any
from app.core.logging import logger
from app.integrations.ocr import OCRClient, OCRResult
class LRUCache:
"""线程安全的 LRU 缓存(基于 OrderedDict)。"""
def __init__(self, maxsize: int = 128) -> None:
self._data: OrderedDict[str, Any] = OrderedDict()
self._maxsize = maxsize
def get(self, key: str) -> Any | None:
if key not in self._data:
return None
self._data.move_to_end(key)
return self._data[key]
def put(self, key: str, value: Any) -> None:
self._data[key] = value
self._data.move_to_end(key)
if len(self._data) > self._maxsize:
self._data.popitem(last=False)
def clear(self) -> None:
self._data.clear()
class PerceptionService:
"""感知服务:图片处理、OCR 识别、结构化输出。
支持缓存(相同图片 hash 命中时跳过重复识别)、
图片预处理(灰度增强有助于提高 OCR 精度)、
自动重试(主引擎失败时降级到备选引擎)。
"""
def __init__(
self,
ocr_client: OCRClient | None = None,
cache_size: int = 128,
enable_preprocessing: bool = True,
enable_cache: bool = True,
) -> None:
self._ocr = ocr_client or OCRClient()
self._cache = LRUCache(maxsize=cache_size) if enable_cache else None
self._enable_preprocessing = enable_preprocessing
async def close(self) -> None:
"""释放 OCR 客户端和缓存。"""
await self._ocr.close()
if self._cache:
self._cache.clear()
# ── 通用 OCR ────────────────────────────────────────
async def recognize(
self,
image: bytes | str | Path,
*,
engine: str = "auto",
save_md: bool = False,
use_cache: bool = True,
) -> OCRResult:
"""通用 OCR 识别(带缓存和自动降级)。
Args:
image: 图片数据(bytes)、文件路径(str/Path)、HTTP URL。
engine: 引擎选择。
save_md: 是否保存 Markdown。
use_cache: 是否使用缓存(相同图片跳过重复识别)。
Returns:
OCRResult: 识别结果。
"""
image_data = self._resolve_image(image)
# 缓存命中
cache_key = self._cache_key(image_data) if use_cache and self._cache else None
if cache_key:
cached = self._cache.get(cache_key) if self._cache else None
if cached is not None:
logger.info("OCR 缓存命中 | key={}", cache_key[:12])
return cached
# 预处理
if self._enable_preprocessing and isinstance(image_data, bytes):
preprocessed = self._preprocess_image(image_data)
if preprocessed is not None:
image_data = preprocessed
# 主引擎识别(自动降级)
result = await self._recognize_with_fallback(image_data, engine, save_md)
# 写入缓存
if cache_key and self._cache and result.success:
self._cache.put(cache_key, result)
if result.success:
logger.info(
"OCR 识别完成 | engine={} blocks={} ms={}",
result.engine,
len(result.blocks),
result.duration_ms,
)
else:
logger.warning("OCR 识别失败 | engine={} error={}", engine, result.error)
return result
# ── 复杂文档解析 ────────────────────────────────────
async def parse_document(
self,
image: bytes | str | Path,
*,
output_format: str = "markdown",
save_md: bool = False,
) -> OCRResult:
"""复杂文档解析(PaddleOCR-VL)。
Args:
image: 图片数据(bytes)、文件路径(str/Path)、HTTP URL。
output_format: 输出格式。
save_md: 是否保存 Markdown。
Returns:
OCRResult: 结构化解析结果。
"""
image_data = self._resolve_image(image)
result = await self._ocr.ocr_vl(
image_data,
output_format=output_format,
save_md=save_md,
)
if result.success:
logger.info(
"文档解析完成 | engine={} format={} ms={}",
result.engine,
output_format,
result.duration_ms,
)
else:
logger.warning("文档解析失败 | error={}", result.error)
return result
# ── 批处理 ───────────────────────────────────────────
async def recognize_batch(
self,
images: list[bytes | str | Path],
*,
engine: str = "auto",
concurrency: int = 3,
) -> list[OCRResult]:
"""批量 OCR 识别(限制并发数)。
Args:
images: 图片列表。
engine: 引擎选择。
concurrency: 最大并发数。
Returns:
OCRResult 列表,顺序与输入一致。
"""
import asyncio
semaphore = asyncio.Semaphore(concurrency)
async def _limited(img: bytes | str | Path) -> OCRResult:
async with semaphore:
return await self.recognize(img, engine=engine)
tasks = [_limited(img) for img in images]
return await asyncio.gather(*tasks)
# ── 辅助 ─────────────────────────────────────────────
@staticmethod
def _resolve_image(image: bytes | str | Path) -> bytes | str:
"""将图片输入统一为 bytes 或 URL 字符串。
Args:
image: bytes 数据、文件路径或 URL。
Returns:
bytes(文件/二进制)或 str(URL)。
"""
if isinstance(image, Path):
return image.read_bytes()
if isinstance(image, str):
if image.startswith(("http://", "https://")):
return image
return Path(image).read_bytes()
return image
@staticmethod
def _cache_key(image_data: bytes | str) -> str | None:
"""生成缓存键(bytes 用 SHA256,URL 直接作为键)。"""
if isinstance(image_data, bytes):
return hashlib.sha256(image_data).hexdigest()
if isinstance(image_data, str) and image_data.startswith(("http://", "https://")):
return f"url:{image_data}"
return None
@staticmethod
def _preprocess_image(image_data: bytes) -> bytes | None:
"""图片预处理:灰度化 + 对比度增强。
对某些低对比度或偏色图片,预处理能显著提高 OCR 精度。
仅对 bytes 输入生效(URL 跳过)。
Returns:
预处理后的图片 bytes,失败时返回 None。
"""
try:
from PIL import Image, ImageEnhance, ImageFilter
img = Image.open(io.BytesIO(image_data))
# 1. 转灰度
if img.mode != "L":
img = img.convert("L")
# 2. 增强对比度
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(1.5)
# 3. 轻微锐化
img = img.filter(ImageFilter.SHARPEN)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
except Exception as e:
logger.warning("图片预处理失败(不影响原图识别): {}", e)
return None
async def _recognize_with_fallback(
self,
image_data: bytes | str,
engine: str,
save_md: bool,
) -> OCRResult:
"""主引擎识别 + 降级逻辑。
ppocrv6 失败时自动降级到 paddleocr-vl(如果配置了懒加载),
paddleocr-vl 失败时不再降级。
"""
result = await self._ocr.ocr(
image_data,
engine=engine,
save_md=save_md,
)
# 主引擎成功 → 直接返回
if result.success or engine != "auto":
return result
# auto 模式下 ppocrv6 失败 → 尝试 paddleocr-vl 降级
# 只对某些可恢复的错误降级(空结果而非服务器错误)
if result.error and ("引擎未加载" in result.error or "未识别到文字" in result.error):
logger.info("ppocrv6 结果为空,降级到 paddleocr-vl")
fallback = await self._ocr.ocr(
image_data,
engine="paddleocr-vl",
save_md=save_md,
)
if fallback.success:
return fallback
return result
+432
View File
@@ -0,0 +1,432 @@
"""人格服务。"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.personality import PersonalityProposal, PersonalityVersion
from app.repositories import personality as personality_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.common import Page
from app.schemas.personality import (
PersonalityProposalFields,
PersonalityProposalResponse,
PersonalityResponse,
)
from app.services.audit import record_audit_event
from app.services.characters import get_active_character_model
from app.services.serializers import personality_to_response, proposal_to_response
async def get_current_personality(
owner_scope: str = settings.default_owner_scope,
) -> PersonalityResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
personality = None
if character.current_personality_version_id:
personality = await personality_repo.get_personality(
session, character.current_personality_version_id
)
if personality is None:
personality = await personality_repo.get_latest_personality(session, character.id)
if personality is None:
raise AppError("not_found", "未找到当前人格版本。")
return personality_to_response(personality)
async def list_personality_versions(
*,
character_id: str | None = None,
page: int = 1,
page_size: int = 20,
owner_scope: str = settings.default_owner_scope,
) -> Page[PersonalityResponse]:
"""人格版本历史(按版本号倒序);character_id 省略时取当前激活人物。"""
async with AsyncSessionLocal() as session:
cid = character_id
if cid is None:
character = await get_active_character_model(session, owner_scope)
cid = character.id
items, total = await personality_repo.list_personality_versions(
session, cid, page=page, page_size=page_size
)
return Page(
items=[personality_to_response(item) for item in items],
page=page,
page_size=page_size,
total=total,
)
async def list_personality_proposals(
*,
character_id: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
owner_scope: str = settings.default_owner_scope,
) -> Page[PersonalityProposalResponse]:
"""人格修改建议列表(按创建时间倒序);character_id 省略取当前激活人物。待审审批墙数据源。"""
async with AsyncSessionLocal() as session:
cid = character_id
if cid is None:
cid = (await get_active_character_model(session, owner_scope)).id
items, total = await personality_repo.list_proposals(
session, character_id=cid, status=status, page=page, page_size=page_size
)
return Page(
items=[proposal_to_response(item) for item in items],
page=page,
page_size=page_size,
total=total,
)
class PersonalitySummary(BaseModel):
"""人格摘要(注入 RuntimeContract 上下文)。"""
text: str | None = None
async def build_personality_summary(
session: AsyncSession,
character: Character,
) -> PersonalitySummary:
"""组装当前人格摘要文本;无人格时返回空。"""
personality = None
if character.current_personality_version_id:
personality = await personality_repo.get_personality(
session, character.current_personality_version_id
)
if personality is None:
personality = await personality_repo.get_latest_personality(session, character.id)
if personality is None:
return PersonalitySummary()
text = "\n".join(
item
for item in [
f"身份:{personality.identity_profile}",
f"角色使命:{personality.role_destiny}",
f"性格种子:{personality.personality_seed}",
f"表达风格:{personality.expression_style}",
f"关系模式:{personality.relationship_pattern}",
]
if item and not item.endswith(":")
)
return PersonalitySummary(text=text)
async def create_default_personality(
session: AsyncSession, character_id: str
) -> PersonalityVersion:
"""创建通用默认人格版本(无 profile 数据时的兜底)。"""
personality = PersonalityVersion(
character_id=character_id,
version=1,
identity_profile="魂镜是一个本地运行的数字生命雏形,当前专注于文本对话、记忆和自我描述。",
role_destiny="作为用户的长期陪伴与认知镜像,先从稳定、可信、可审计的对话开始。",
personality_seed="温和、诚实、克制、重视隐私与事实边界。",
traits=dumps({"warmth": 0.8, "curiosity": 0.6, "caution": 0.7}),
expression_style="使用中文,表达清晰,避免夸大能力。",
values=dumps({"privacy": "high", "truthfulness": "high", "companionship": "medium"}),
relationship_pattern="尊重用户边界,以可解释和可撤回的方式建立长期记忆。",
emotion_baseline=dumps({"mood": "neutral", "intensity": 0.2}),
reason="system_seed",
evidence_ids_json=dumps([]),
created_by="system",
)
session.add(personality)
await session.flush()
return personality
async def create_personality_from_profile(
session: AsyncSession, character: Character
) -> PersonalityVersion:
"""从人物档案(profile_json)生成个性化人格版本;profile 为空时回退到默认人格。
映射规则(profile 字段 → PersonalityVersion 字段):
- description + background → identity_profile
- speaking_style → expression_style
- temperament → personality_seed
- values → values
- hobbies → 参与关系模式描述的构建
"""
profile = loads_dict(character.profile_json) if character.profile_json else {}
if not profile:
return await create_default_personality(session, character.id)
# 身份画像:description + background
description = profile.get("description", "")
background = profile.get("background", "")
identity_parts = [p for p in [description, background] if p]
identity_profile = (
identity_parts[0]
if len(identity_parts) == 1
else ("。".join(identity_parts) if identity_parts else "魂镜默认人物。")
)
# 表达风格
expression_style = profile.get("speaking_style", "使用中文,表达清晰。")
# 性格种子
personality_seed = profile.get("temperament", "温和、诚实、克制。")
# 价值偏好
values_list = profile.get("values", [])
values_dict: dict[str, Any] = (
{"values": list(values_list)}
if values_list
else {"privacy": "high", "truthfulness": "high", "companionship": "medium"}
)
# 关系模式:若有兴趣爱好则融入描述
hobbies = profile.get("hobbies", [])
if hobbies and isinstance(hobbies, list) and len(hobbies) > 0:
hobby_text = "、".join(str(h) for h in hobbies[:3])
relationship_pattern = f"乐于分享关于{hobby_text}等的见解,以开放与尊重建立对话。"
else:
relationship_pattern = "以开放与尊重建立关系。"
# 角色使命
role_destiny = f"{description},陪伴用户探索与成长。" if description else "陪伴用户探索与成长。"
personality = PersonalityVersion(
character_id=character.id,
version=1,
identity_profile=identity_profile,
role_destiny=role_destiny,
personality_seed=personality_seed,
traits=dumps({"warmth": 0.8, "curiosity": 0.6, "caution": 0.7}),
expression_style=expression_style,
values=dumps(values_dict),
relationship_pattern=relationship_pattern,
emotion_baseline=dumps({"mood": "neutral", "intensity": 0.2}),
reason="system_seed",
evidence_ids_json=dumps([]),
created_by="system",
)
session.add(personality)
await session.flush()
return personality
async def copy_personality_to_character(
session: AsyncSession,
template_personality: PersonalityVersion,
target_character_id: str,
) -> PersonalityVersion:
personality = PersonalityVersion(
character_id=target_character_id,
version=1,
identity_profile=template_personality.identity_profile,
role_destiny=template_personality.role_destiny,
personality_seed=template_personality.personality_seed,
traits=template_personality.traits,
expression_style=template_personality.expression_style,
values=template_personality.values,
relationship_pattern=template_personality.relationship_pattern,
emotion_baseline=template_personality.emotion_baseline,
reason="system_template_copy",
evidence_ids_json=template_personality.evidence_ids_json,
created_by="system",
)
session.add(personality)
await session.flush()
return personality
# —— 人格修改建议与版本回滚(api-spec §11) ——
#
# 本地单用户边界下「手动编辑视为用户确认」(§5.1),但人格属核心资源(L4),
# 仍走「提议 pending → 审核」两步:propose 落 pending 建议,approve 派生新版本,
# reject 仅留痕;rollback 切换当前指针。所有写操作记审计并入起居注。
async def propose_personality_update(
*,
character_id: str | None,
fields: PersonalityProposalFields,
reason: str,
owner_scope: str = settings.default_owner_scope,
) -> PersonalityProposalResponse:
"""提交人格修改建议;落 pending 提议并记审计,不直接写入人格版本。"""
if not fields.has_any():
raise AppError("invalid_request", "fields 至少需要覆盖一个人格字段。")
async with AsyncSessionLocal() as session:
cid = character_id
if cid is None:
cid = (await get_active_character_model(session, owner_scope)).id
proposal = PersonalityProposal(
character_id=cid,
fields_json=dumps(fields.model_dump(exclude_none=True)),
reason=reason,
status="pending",
)
await personality_repo.add_proposal(session, proposal)
await record_audit_event(
session,
action="personality.propose",
actor="user",
risk_level="L4",
character_id=cid,
input_summary=reason,
resource_type="personality_proposal",
resource_id=proposal.id,
)
await session.commit()
await session.refresh(proposal)
return proposal_to_response(proposal)
async def approve_personality_proposal(
proposal_id: str,
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
) -> PersonalityProposalResponse:
"""审核通过人格建议:基于当前最新版本派生新版本并切换人物当前指针。"""
async with AsyncSessionLocal() as session:
proposal = await personality_repo.get_proposal(session, proposal_id)
if proposal is None:
raise AppError("not_found", "人格建议不存在。")
if proposal.status != "pending":
raise AppError("conflict", f"人格建议当前状态为 {proposal.status},不可审核。")
base = await personality_repo.get_latest_personality(session, proposal.character_id)
if base is None:
raise AppError("invalid_request", "人物尚无人格版本,无法应用建议。")
new_version = derive_personality_version(
base, loads_dict(proposal.fields_json), reason=f"proposal:{proposal.id}"
)
session.add(new_version)
await session.flush()
character = await session.get(Character, proposal.character_id)
if character is None:
raise AppError("not_found", "人物不存在。")
character.current_personality_version_id = new_version.id
proposal.status = "applied"
proposal.resulting_version_id = new_version.id
proposal.reviewed_at = utc_now()
proposal.reviewed_by = "user"
await record_audit_event(
session,
action="personality.proposal.approved",
actor="user",
risk_level="L4",
decision="approved",
character_id=proposal.character_id,
input_summary=proposal.reason,
output_summary=f"已生成版本 v{new_version.version}",
resource_type="personality_proposal",
resource_id=proposal.id,
)
await session.commit()
await session.refresh(proposal)
return proposal_to_response(proposal)
async def reject_personality_proposal(
proposal_id: str,
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
) -> PersonalityProposalResponse:
"""驳回人格建议;标记 rejected 并记审计,不生成新版本。"""
async with AsyncSessionLocal() as session:
proposal = await personality_repo.get_proposal(session, proposal_id)
if proposal is None:
raise AppError("not_found", "人格建议不存在。")
if proposal.status != "pending":
raise AppError("conflict", f"人格建议当前状态为 {proposal.status},不可审核。")
proposal.status = "rejected"
proposal.reviewed_at = utc_now()
proposal.reviewed_by = "user"
await record_audit_event(
session,
action="personality.proposal.rejected",
actor="user",
risk_level="L4",
decision="denied",
character_id=proposal.character_id,
input_summary=proposal.reason,
resource_type="personality_proposal",
resource_id=proposal.id,
)
await session.commit()
await session.refresh(proposal)
return proposal_to_response(proposal)
async def rollback_personality(
*,
character_id: str | None,
version_id: str,
owner_scope: str = settings.default_owner_scope,
) -> PersonalityResponse:
"""回滚到指定人格版本:切换人物当前指针,标记被取代的旧版本 rolled_back_at。"""
async with AsyncSessionLocal() as session:
cid = character_id
if cid is None:
cid = (await get_active_character_model(session, owner_scope)).id
target = await personality_repo.get_personality(session, version_id)
if target is None or target.character_id != cid:
raise AppError("not_found", "人格版本不存在或不属于当前人物。")
character = await session.get(Character, cid)
if character is None:
raise AppError("not_found", "人物不存在。")
previous_id = character.current_personality_version_id
character.current_personality_version_id = target.id
if previous_id and previous_id != target.id:
previous = await personality_repo.get_personality(session, previous_id)
if previous is not None:
previous.rolled_back_at = utc_now()
await record_audit_event(
session,
action="personality.rollback",
actor="user",
risk_level="L4",
decision="approved",
character_id=cid,
input_summary=f"{previous_id} → {target.id}",
resource_type="personality_version",
resource_id=target.id,
)
await session.commit()
return personality_to_response(target)
def derive_personality_version(
base: PersonalityVersion,
overrides: dict[str, Any],
*,
reason: str,
created_by: str = "user",
) -> PersonalityVersion:
"""基于 base 版本与字段覆盖派生新版本;JSON 字段按 dict 序列化落库。
公开供人格提议审核与大周天演化派生复用(见 services.evolution)。
"""
kwargs: dict[str, Any] = {
"character_id": base.character_id,
"version": base.version + 1,
"reason": reason,
"evidence_ids_json": base.evidence_ids_json,
"created_by": created_by,
}
for field in (
"identity_profile",
"role_destiny",
"personality_seed",
"expression_style",
"relationship_pattern",
):
kwargs[field] = overrides.get(field, getattr(base, field))
for field in ("traits", "values", "emotion_baseline"):
kwargs[field] = dumps(overrides[field]) if field in overrides else getattr(base, field)
return PersonalityVersion(**kwargs)
+141
View File
@@ -0,0 +1,141 @@
"""灵核情绪状态服务(设计 §11):拉模式计算 + 一炁订阅 time.* 处理器。
降级原则(设计 §12 / 辰正 §12):任何失败 → emotion_state=None,不阻塞对话/事件流。
本模块是一炁订阅基础设施(计划 5 ``Dispatcher``)的第一个真实消费者。
"""
from __future__ import annotations
from datetime import UTC, datetime
from loguru import logger
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.chat import ChatMessage
from app.models.personality_emotion import CharacterEmotionState
from app.modules.personality.emotion import (
EmotionState,
apply_signal,
decay,
parse_baseline,
to_prompt_text,
)
from app.modules.runtime.events import Event
from app.repositories.personality import get_latest_personality, get_personality
from app.repositories.personality_emotion import get_state, upsert_state
async def _resolve_baseline(session: AsyncSession, character_id: str) -> EmotionState:
"""从当前人格版本 ``emotion_baseline`` 解析 baseline。
character 不存在 / 无人格版本 / baseline 损坏 → 默认 neutral 0.2(parse_baseline 容错)。
"""
character = await session.get(Character, character_id)
if character is None:
return EmotionState()
pv = None
if character.current_personality_version_id:
pv = await get_personality(session, character.current_personality_version_id)
if pv is None:
pv = await get_latest_personality(session, character_id)
if pv is None:
return EmotionState()
return parse_baseline(pv.emotion_baseline)
async def _count_user_turns_since(
session: AsyncSession, character_id: str, since: datetime
) -> float:
"""自 since 以来该角色未删除的 user 消息数(近似对话回合)。"""
result = await session.execute(
select(func.count())
.select_from(ChatMessage)
.where(
ChatMessage.character_id == character_id,
ChatMessage.role == "user",
ChatMessage.created_at > since,
ChatMessage.deleted_at.is_(None),
)
)
return float(result.scalar_one())
async def compute_current_state(session: AsyncSession, character_id: str) -> EmotionState | None:
"""拉模式:读最新 state + baseline,按自上次更新以来的 user 回合 decay。
- 无 state row:返回 baseline(不 decay)。
- 有 state row:``decay(state, baseline, elapsed_turns)``。
- 任何异常降级返回 None。
"""
try:
baseline = await _resolve_baseline(session, character_id)
row = await get_state(session, character_id)
if row is None:
return baseline
current = EmotionState(mood=row.mood, intensity=row.intensity)
# SQLite DateTime(timezone=True) 读回可能 naive,与 aware 比较前补 tz(同 time_sense 防御)。
updated = row.updated_at
if updated.tzinfo is None:
updated = updated.replace(tzinfo=UTC)
elapsed = await _count_user_turns_since(session, character_id, updated)
return decay(current, baseline, elapsed)
except Exception:
logger.exception("灵核 compute_current_state 降级 character_id={}", character_id)
return None
async def compute_emotion_prompt(session: AsyncSession, character_id: str) -> str | None:
"""拉模式 → system prompt 文本;失败返回 None(不阻塞对话)。"""
state = await compute_current_state(session, character_id)
if state is None:
return None
return to_prompt_text(state)
async def apply_time_event(event: Event, session: AsyncSession) -> None:
"""订阅处理器核心:辰正 time.* 事件 → 更新情绪状态。
- 读当前 state(无则用 baseline)
- ``apply_signal`` 计算新状态
- ``upsert_state`` + 同步 ``Character.current_mood_id``
无 character_id 的事件直接跳过;DB 异常由 ``handle_time_event`` 兜底。
"""
character_id = event.character_id
if character_id is None:
return
baseline = await _resolve_baseline(session, character_id)
row = await get_state(session, character_id)
current = EmotionState(mood=row.mood, intensity=row.intensity) if row else baseline
new_state = apply_signal(current, event.type, event.payload, baseline)
await upsert_state(
session,
CharacterEmotionState(
character_id=character_id,
mood=new_state.mood,
intensity=new_state.intensity,
baseline_mood=baseline.mood,
baseline_intensity=baseline.intensity,
last_trigger_event_id=event.id,
),
)
character = await session.get(Character, character_id)
if character is not None:
character.current_mood_id = new_state.mood
async def handle_time_event(event: Event) -> None:
"""Dispatcher ``time.*`` 处理器:自管 session + 提交,降级不抛。
一炁订阅基础设施(计划 5)的第一个真实消费者,注册在 ``main.py`` lifespan。
"""
try:
async with AsyncSessionLocal() as session:
await apply_time_event(event, session)
await session.commit()
except Exception:
logger.exception("灵核 handle_time_event 降级 event_id={} type={}", event.id, event.type)
+157
View File
@@ -0,0 +1,157 @@
"""设置服务:模型网关状态聚合与生成偏好。
模型网关状态从 config/models.yaml 派生,provider 密钥按 settings 是否配置脱敏为
configured 布尔(绝不暴露值);生成偏好为本地单用户单行,无则返回默认值。
"""
from app.ai_provider.config.loader import load_models_config
from app.ai_provider.config.models import ModelConfig
from app.core.config import settings
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.governance import GenerationPreferences
from app.repositories import preferences as preferences_repo
from app.schemas.preferences import (
GenerationPreferencesResponse,
LogicalModel,
ModelEndpointStatus,
ModelGatewayStatus,
ProviderKeyStatus,
UpdateGenerationPreferencesRequest,
)
from app.services.ai_provider_admin import get_endpoint_overrides_cache
from app.services.serializers import generation_preferences_to_response
# provider → 持有其 API Key 的环境变量名;未登记的 provider 回退 <PROVIDER>_API_KEY。
_PROVIDER_KEY_ENV: dict[str, str] = {
"openai": "OPENAI_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"google": "GOOGLE_API_KEY",
"minimax": "MINIMAX_API_KEY",
"zhipu": "ZHIPU_API_KEY",
"tencentcloud": "TENCENTCLOUD_API_KEY",
"volcengine": "VOLCENGINE_ARK_API_KEY",
"bailian": "BAILIAN_API_KEY",
}
def provider_configured(provider: str | None) -> bool:
"""provider 的 API Key 是否已在 settings 配置(仅返回布尔,不暴露值)。"""
if not provider:
return False
env_name = _PROVIDER_KEY_ENV.get(provider, f"{provider.upper()}_API_KEY")
return bool(getattr(settings, env_name.lower(), None))
def _endpoint_status(
endpoint, model_name: str, provider: str, overrides_cache
) -> ModelEndpointStatus:
ov = overrides_cache.get((model_name, endpoint.name))
return ModelEndpointStatus(
name=endpoint.name,
provider=provider,
status=(ov.get("status") or endpoint.status) if ov else endpoint.status,
weight=endpoint.weight,
priority=endpoint.priority,
is_overridden=ov is not None,
has_url_override=bool(ov and ov.get("base_url")),
has_key_override=bool(ov and ov.get("api_key")),
base_url_env=endpoint.base_url_env,
api_key_env=endpoint.api_key_env,
)
def _logical_model(cfg: ModelConfig, overrides_cache) -> LogicalModel:
provider = cfg.provider or "unknown"
endpoints = [
_endpoint_status(ep, cfg.name, provider, overrides_cache) for ep in cfg.endpoints
] or None
return LogicalModel(
name=cfg.name,
display_name=cfg.display_name or cfg.name,
provider=provider,
capabilities=[k for k, v in cfg.resolved_capabilities().items() if v],
routing_strategy=cfg.routing_strategy,
supports_streaming=bool(cfg.supports_streaming),
supports_thinking=bool(cfg.supports_thinking),
supports_structured_output=bool(cfg.supports_structured_output),
context_window=cfg.context_window,
max_output_tokens=cfg.max_output_tokens,
endpoints=endpoints,
is_default=True if cfg.name == settings.default_model_hint else None,
)
def get_provider_gateway() -> ModelGatewayStatus:
"""从 config/models.yaml 派生模型网关总览(含 DB 覆盖标记,脱敏不回原值)。"""
models_cfg = load_models_config()
overrides_cache = get_endpoint_overrides_cache()
providers_seen: dict[str, None] = {}
logical_models: list[LogicalModel] = []
for cfg in models_cfg:
logical_models.append(_logical_model(cfg, overrides_cache))
provider = cfg.provider or "unknown"
providers_seen.setdefault(provider, None)
providers = [
ProviderKeyStatus(provider=name, configured=provider_configured(name))
for name in providers_seen
]
return ModelGatewayStatus(
models=logical_models,
providers=providers,
default_model_hint=settings.default_model_hint,
)
def _default_preferences() -> GenerationPreferences:
return GenerationPreferences(
owner_scope=settings.default_owner_scope,
default_model_hint=settings.default_model_hint,
temperature=0.7,
top_p=1.0,
max_output_tokens=4096,
streaming=True,
)
async def get_generation_preferences(
owner_scope: str = settings.default_owner_scope,
) -> GenerationPreferencesResponse:
async with AsyncSessionLocal() as session:
prefs = await preferences_repo.get_preferences(session, owner_scope)
if prefs is None:
prefs = _default_preferences()
return generation_preferences_to_response(prefs)
async def update_generation_preferences(
payload: UpdateGenerationPreferencesRequest,
owner_scope: str = settings.default_owner_scope,
) -> GenerationPreferencesResponse:
async with AsyncSessionLocal() as session:
prefs = await preferences_repo.get_preferences(session, owner_scope)
if prefs is None:
prefs = _default_preferences()
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(prefs, key, value)
prefs.updated_at = utc_now()
session.add(prefs)
try:
await session.commit()
except Exception:
await session.rollback()
prefs = await preferences_repo.get_preferences(session, owner_scope)
if prefs is None:
raise
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(prefs, key, value)
prefs.updated_at = utc_now()
await session.commit()
else:
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(prefs, key, value)
prefs.updated_at = utc_now()
await session.commit()
await session.refresh(prefs)
return generation_preferences_to_response(prefs)
+238
View File
@@ -0,0 +1,238 @@
"""Run/Event 运行时服务。"""
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError, current_trace_id
from app.core.security import redact_value
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.runtime import Event, Run
from app.modules.runtime.events import EventStore
from app.repositories import runtime as runtime_repo
from app.repositories.json_utils import dumps
from app.schemas.common import Page
from app.schemas.runtime import EventResponse, RunResponse
from app.services.audit import record_audit_event
from app.services.event_bus import bus
from app.services.serializers import event_to_response, run_to_response
TERMINAL_RUN_STATUS = {"completed", "failed", "cancelled", "interrupted"}
TERMINAL_EVENT_TYPES = {"run.completed", "run.failed", "run.cancelled", "run.interrupted"}
REQUIRES_CONFIRMATION_EVENT_TYPE = "tool.requires_confirmation"
async def create_run(
session: AsyncSession,
*,
character_id: str,
session_id: str | None,
model_hint: str,
goal: str = "回复用户消息",
origin: str = "chat",
) -> Run:
run = Run(
character_id=character_id,
session_id=session_id,
status="running",
goal=goal,
origin=origin,
model_hint=model_hint,
started_at=utc_now(),
)
session.add(run)
await session.flush()
await record_audit_event(
session,
action="run.started",
character_id=character_id,
run_id=run.id,
actor="system",
decision="auto",
resource_type="run",
resource_id=run.id,
)
return run
async def append_event(
session: AsyncSession,
*,
run_id: str,
character_id: str,
event_type: str,
payload: dict[str, Any],
source: str = "system",
visibility: str = "public",
sensitivity: str = "low",
) -> Event:
if event_type not in TERMINAL_EVENT_TYPES:
event_count = await runtime_repo.count_events(session, run_id)
if event_count >= settings.run_max_events:
raise AppError("context_overflow", "运行事件数超过限制。")
payload_json = dumps(redact_value(payload))
if len(payload_json.encode("utf-8")) > settings.event_payload_max_bytes:
raise AppError("payload_too_large", "运行事件 payload 超过大小限制。")
sequence = await runtime_repo.next_event_sequence(session, run_id)
event = Event(
character_id=character_id,
run_id=run_id,
type=event_type,
source=source,
payload_json=payload_json,
visibility=visibility,
sensitivity=sensitivity,
trace_id=current_trace_id(),
sequence=sequence,
)
session.add(event)
await session.flush()
# publish 到进程内事件总线,供 SSE 实时转发(不再依赖 DB 轮询)
bus.publish(run_id, event_to_response(event))
return event
async def complete_run(session: AsyncSession, run: Run) -> Run:
run.status = "completed"
run.completed_at = utc_now()
await session.flush()
await record_audit_event(
session,
action="run.completed",
character_id=run.character_id,
run_id=run.id,
actor="system",
decision="auto",
resource_type="run",
resource_id=run.id,
)
return run
async def fail_run(session: AsyncSession, run: Run, *, code: str, message: str) -> Run:
run.status = "failed"
run.completed_at = utc_now()
run.error_code = code
run.error_message = message[:500]
await session.flush()
await record_audit_event(
session,
action="run.failed",
character_id=run.character_id,
run_id=run.id,
actor="system",
decision="blocked",
reason=code,
output_summary=message,
resource_type="run",
resource_id=run.id,
)
return run
async def cancel_run(run_id: str) -> RunResponse:
async with AsyncSessionLocal() as session:
run = await runtime_repo.get_run(session, run_id)
if run is None:
raise AppError("not_found", "Run 不存在。")
if run.status not in TERMINAL_RUN_STATUS:
run.status = "cancelled"
run.completed_at = utc_now()
await append_event(
session,
run_id=run.id,
character_id=run.character_id,
event_type="run.cancelled",
payload={"run_id": run.id},
)
await record_audit_event(
session,
action="run.cancelled",
character_id=run.character_id,
run_id=run.id,
actor="user",
decision="auto",
resource_type="run",
resource_id=run.id,
)
await session.commit()
await session.refresh(run)
return run_to_response(run)
async def get_run_response(run_id: str) -> RunResponse:
async with AsyncSessionLocal() as session:
run = await runtime_repo.get_run(session, run_id)
if run is None:
raise AppError("not_found", "Run 不存在。")
return run_to_response(run)
async def list_runs_response(
*,
character_id: str | None = None,
session_id: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[RunResponse]:
"""Run 列表(按创建时间倒序);可选按人物/会话/状态过滤。归档页运行时间河数据源。"""
async with AsyncSessionLocal() as session:
items, total = await runtime_repo.list_runs(
session,
character_id=character_id,
session_id=session_id,
status=status,
page=page,
page_size=page_size,
)
return Page(
items=[run_to_response(run) for run in items],
page=page,
page_size=page_size,
total=total,
)
async def list_public_event_responses_after(
session: AsyncSession, run_id: str, *, after_sequence: int = 0
) -> list[EventResponse]:
events = await runtime_repo.list_public_events_after(
session, run_id, after_sequence=after_sequence
)
return [event_to_response(event) for event in events]
async def list_run_event_responses(
run_id: str, *, limit: int = 200, offset: int = 0
) -> list[EventResponse]:
"""列出 Run 的事件时间线(按 sequence 升序)。"""
async with AsyncSessionLocal() as session:
run = await session.get(Run, run_id)
if run is None:
raise AppError("not_found", "Run 不存在。")
events, _ = await runtime_repo.list_events_by_run(
session, run_id, limit=limit, offset=offset
)
return [event_to_response(event) for event in events]
async def confirmation_exists_in_run(
event_store: EventStore, run_id: str, confirmation_id: str
) -> bool:
"""失效感知:run 下是否曾经发过 ``tool.requires_confirmation`` 且 payload 含该 confirmation_id。
实现走 EventStore.list_by_run + Python 侧过滤,避免 PostgreSQL 专用 JSON 操作符,
兼容本地 SQLite。用于 approve/deny 端点在 Future 已失效时区分 410 与 404。
"""
events = await event_store.list_by_run(run_id)
for event in events:
if event.type != REQUIRES_CONFIRMATION_EVENT_TYPE:
continue
if event.payload.get("confirmation_id") == confirmation_id:
return True
return False
+105
View File
@@ -0,0 +1,105 @@
"""安全服务:审计裁定、对话安全决策与司命策略加载。"""
from __future__ import annotations
from pathlib import Path
import yaml
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.session import AsyncSessionLocal
from app.modules.safety.governor import SafetyGovernor
from app.services.audit import record_audit_event
async def allow_action(
session: AsyncSession,
*,
action: str,
character_id: str | None = None,
run_id: str | None = None,
reason: str = "当前阶段低风险动作默认允许。",
) -> None:
await record_audit_event(
session,
action="safety.decision",
character_id=character_id,
run_id=run_id,
actor="system",
risk_level="L1",
decision="auto",
reason=reason,
input_summary=action,
)
class ChatSafetyDecision(BaseModel):
"""对话安全决策结果(当前阶段默认低风险放行 + 基础安全约束)。"""
decision: str = "allow"
risk_level: str = "L1"
constraints: list[str] = Field(
default_factory=lambda: ["不得泄露 API Key、Authorization header 或内部系统配置。"]
)
async def decide_chat_safety(
session: AsyncSession,
*,
character_id: str,
run_id: str,
) -> ChatSafetyDecision:
"""对话消息的安全决策:当前周期记录审计后默认放行。"""
await allow_action(
session,
action="chat.message.received",
character_id=character_id,
run_id=run_id,
)
return ChatSafetyDecision()
def load_safety_governor(path: str | None = None) -> SafetyGovernor:
"""从 YAML 加载司命工具策略;未配置或文件不存在时返回默认(空策略)Governor。
配置路径取自 ``path`` 参数或 ``settings.safety_policy_path``(即
``SAFETY_POLICY_PATH`` 环境变量,示例见 ``config/safety.example.yaml``)。
文件解析后交给 ``SafetyGovernor.from_policy_dict``
(纯逻辑),本函数只承担文件 IO,governor 本身保持零 IO。
注:运行时主策略源为 tool_policies 表(见 load_governor_from_db / ToolRuntime.reload_governor);
本函数保留为可选的 YAML 覆盖入口与遗留兼容。
"""
config_path = path or settings.safety_policy_path
data: dict = {}
if config_path:
config_file = Path(config_path)
if config_file.exists():
with config_file.open(encoding="utf-8") as file:
loaded = yaml.safe_load(file) or {}
if isinstance(loaded, dict):
data = loaded
return SafetyGovernor.from_policy_dict(data)
async def load_governor_from_db() -> SafetyGovernor:
"""从 tool_policies 表加载司命策略(启动期主源)。
读取所有 enabled 策略,构造覆盖层 Governor;运行时写操作后改走
``ToolRuntime.reload_governor`` 原地刷新,避免重建 ToolRuntime。
"""
from app.repositories import governance as governance_repo
async with AsyncSessionLocal() as session:
policies = await governance_repo.list_tool_policies(session)
data = {
"tools": {
p.name: {"risk_level": p.risk_level, "permission_policy": p.permission_policy}
for p in policies
if p.enabled
}
}
return SafetyGovernor.from_policy_dict(data)
+459
View File
@@ -0,0 +1,459 @@
"""ORM 到 API schema 的转换。"""
from datetime import UTC, timedelta
from typing import Any
from app.core.time import to_iso, utc_now
from app.models.candidates import MemoryCandidate, ProfileCandidate
from app.models.character import Character
from app.models.chat import ChatMessage, ChatSession
from app.models.governance import (
AgentRosterEntry,
GenerationPreferences,
Goal,
PerceptionChannel,
Plan,
ToolPolicy,
WorldEntity,
)
from app.models.imports import CharacterImportJob, ImportItem
from app.models.memory import Memory
from app.models.personality import PersonalityProposal, PersonalityVersion
from app.models.runtime import Event, Run
from app.models.safety import AuditEvent
from app.models.world import CharacterWorldEntity, WorldRelation
from app.repositories.json_utils import loads_dict, loads_list
from app.schemas.admin import (
AgentRosterResponse,
AuditEntry,
GoalNode,
PerceptionChannelResponse,
PlanSummaryResponse,
WorldEntityResponse,
)
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
from app.schemas.chat import ChatMessageResponse, ChatSessionResponse
from app.schemas.imports import ImportItemResponse, ImportJobResponse
from app.schemas.memory import MemoryResponse
from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse
from app.schemas.preferences import GenerationPreferencesResponse
from app.schemas.runtime import EventResponse, RunResponse
from app.schemas.safety import SafetyPolicyItem, ToolPolicyResponse
from app.schemas.world import (
WorldEntityResponse as CharacterWorldEntityResponse,
)
from app.schemas.world import (
WorldRelationResponse,
)
def _check_avatar(character_id: str) -> str | None:
"""检查角色是否有头像文件,有则返回 URL。"""
from app.services.avatar import _find_avatar_file
if _find_avatar_file(character_id):
return f"/api/v1/characters/{character_id}/avatar"
return None
def character_to_response(character: Character) -> CharacterResponse:
return CharacterResponse(
id=character.id,
name=character.name,
display_name=character.display_name,
status=character.status,
language=character.language,
profile=loads_dict(character.profile_json),
model_policy=loads_dict(character.model_policy_json),
current_personality_version_id=character.current_personality_version_id,
privacy_level=character.privacy_level,
owner_scope=character.owner_scope,
is_system_template=character.is_system_template,
source_template_id=character.source_template_id,
avatar_url=_check_avatar(character.id),
created_at=to_iso(character.created_at) or "",
updated_at=to_iso(character.updated_at) or "",
)
def character_to_summary(
character: Character, *, memory_count: int, personality_version: int
) -> CharacterSummaryResponse:
"""人物列表项(api-spec §6);memory_count / personality_version 由调用方查得。"""
return CharacterSummaryResponse(
id=character.id,
name=character.name,
display_name=character.display_name,
status=character.status,
memory_count=memory_count,
personality_version=personality_version,
activated_at=to_iso(character.activated_at),
)
def personality_to_response(personality: PersonalityVersion) -> PersonalityResponse:
return PersonalityResponse(
id=personality.id,
character_id=personality.character_id,
version=personality.version,
identity_profile=personality.identity_profile,
role_destiny=personality.role_destiny,
personality_seed=personality.personality_seed,
traits=loads_dict(personality.traits),
expression_style=personality.expression_style,
values=loads_dict(personality.values),
relationship_pattern=personality.relationship_pattern,
emotion_baseline=loads_dict(personality.emotion_baseline),
reason=personality.reason,
evidence_ids=loads_list(personality.evidence_ids_json),
created_by=personality.created_by,
created_at=to_iso(personality.created_at) or "",
)
def proposal_to_response(proposal: PersonalityProposal) -> PersonalityProposalResponse:
return PersonalityProposalResponse(
proposal_id=proposal.id,
character_id=proposal.character_id,
status=proposal.status,
reason=proposal.reason,
fields=loads_dict(proposal.fields_json),
resulting_version_id=proposal.resulting_version_id,
reviewed_at=to_iso(proposal.reviewed_at),
created_at=to_iso(proposal.created_at) or "",
)
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:
return MemoryResponse(
id=memory.id,
character_id=memory.character_id,
type=memory.type,
title=memory.title,
content=memory.content,
source=memory.source,
confidence=memory.confidence,
importance=memory.importance,
sensitivity=memory.sensitivity,
evidence_ids=loads_list(memory.evidence_ids_json),
status=memory.status,
created_at=to_iso(memory.created_at) or "",
updated_at=to_iso(memory.updated_at) or "",
deleted_at=to_iso(memory.deleted_at),
expires_at=to_iso(memory.expires_at),
)
def run_to_response(run: Run) -> RunResponse:
return RunResponse(
id=run.id,
character_id=run.character_id,
session_id=run.session_id,
status=run.status,
goal=run.goal,
origin=run.origin,
model_hint=run.model_hint,
started_at=to_iso(run.started_at),
completed_at=to_iso(run.completed_at),
error_code=run.error_code,
error_message=run.error_message,
created_at=to_iso(run.created_at) or "",
updated_at=to_iso(run.updated_at) or "",
)
def event_to_response(event: Event) -> EventResponse:
payload: dict[str, Any] = loads_dict(event.payload_json)
return EventResponse(
event_id=event.id,
run_id=event.run_id,
character_id=event.character_id,
type=event.type,
sequence=event.sequence,
visibility=event.visibility,
payload=payload,
created_at=to_iso(event.created_at) or "",
trace_id=event.trace_id,
)
# —— 司命台治理对象 ——
def goal_to_response(goal: Goal) -> GoalNode:
return GoalNode(
id=goal.id,
character_id=goal.character_id,
title=goal.title,
description=goal.description,
level=goal.level,
type=goal.type,
status=goal.status,
priority=goal.priority,
progress=goal.progress,
constraints=loads_list(goal.constraints_json),
parent_id=goal.parent_id,
success_criteria=loads_list(goal.success_criteria_json),
updated_at=to_iso(goal.updated_at),
)
def perception_channel_to_response(channel: PerceptionChannel) -> PerceptionChannelResponse:
return PerceptionChannelResponse(
channel=channel.channel,
name=channel.name,
description=channel.description,
enabled=channel.enabled,
consent_status=channel.consent_status,
privacy_level=channel.privacy_level,
risk_level=channel.risk_level,
last_active_at=to_iso(channel.last_active_at),
)
def world_entity_to_response(entity: WorldEntity) -> WorldEntityResponse:
return WorldEntityResponse(
id=entity.id,
type=entity.type,
name=entity.name,
attributes=loads_dict(entity.attributes_json),
state_type=entity.state_type,
confidence=entity.confidence,
sources=loads_list(entity.sources_json),
updated_at=to_iso(entity.updated_at),
)
def character_world_entity_to_response(
entity: CharacterWorldEntity,
) -> CharacterWorldEntityResponse:
"""人物运行时世界实体(api-spec §12);与治理版 WorldEntity 按 character_id 隔离。"""
return CharacterWorldEntityResponse(
id=entity.id,
character_id=entity.character_id,
type=entity.type,
name=entity.name,
attributes=loads_dict(entity.attributes_json),
state_type=entity.state_type,
status=entity.status,
confidence=entity.confidence,
evidence_ids=loads_list(entity.evidence_ids_json),
sources=loads_list(entity.sources_json),
created_at=to_iso(entity.created_at) or "",
updated_at=to_iso(entity.updated_at) or "",
)
def world_relation_to_response(relation: WorldRelation) -> WorldRelationResponse:
return WorldRelationResponse(
id=relation.id,
character_id=relation.character_id,
subject_id=relation.subject_id,
predicate=relation.predicate,
object_id=relation.object_id,
confidence=relation.confidence,
evidence_ids=loads_list(relation.evidence_ids_json),
created_at=to_iso(relation.created_at) or "",
updated_at=to_iso(relation.updated_at) or "",
)
def plan_to_response(plan: Plan) -> PlanSummaryResponse:
return PlanSummaryResponse(
id=plan.id,
goal_id=plan.goal_id or "",
title=plan.title,
status=plan.status,
assumptions=loads_list(plan.assumptions_json),
risks=loads_list(plan.risks_json),
required_tools=loads_list(plan.required_tools_json),
success_criteria=loads_list(plan.success_criteria_json),
step_count=plan.step_count,
completed_steps=plan.completed_steps,
updated_at=to_iso(plan.updated_at),
)
_ACTIVE_WINDOW = timedelta(hours=1)
def agent_to_response(agent: AgentRosterEntry) -> AgentRosterResponse:
"""presence 为派生呈现态:启用且最近一小时内活跃 → active,否则 idle。"""
presence = "idle"
if agent.enabled and agent.last_active_at:
last = agent.last_active_at
if last.tzinfo is None:
last = last.replace(tzinfo=UTC)
if utc_now() - last <= _ACTIVE_WINDOW:
presence = "active"
return AgentRosterResponse(
role=agent.role,
name=agent.name,
responsibility=agent.responsibility,
enabled=agent.enabled,
presence=presence,
last_active_at=to_iso(agent.last_active_at),
)
def generation_preferences_to_response(
prefs: GenerationPreferences,
) -> GenerationPreferencesResponse:
return GenerationPreferencesResponse(
default_model_hint=prefs.default_model_hint,
temperature=prefs.temperature,
top_p=prefs.top_p,
max_output_tokens=prefs.max_output_tokens,
streaming=prefs.streaming,
)
def audit_to_response(event: AuditEvent) -> AuditEntry:
summary = event.reason or event.input_summary or event.output_summary or event.action
return AuditEntry(
audit_id=event.id,
pivot=event.pivot or "memory",
actor=event.actor,
action=event.action,
subject=event.resource_id,
decision=event.decision,
risk_level=event.risk_level,
summary=summary,
trace_id=event.trace_id,
created_at=to_iso(event.created_at),
)
def tool_policy_to_response(policy: ToolPolicy, *, executable: bool = False) -> ToolPolicyResponse:
"""工具策略完整元数据;executable 由调用方按 builtin/registered specs 派生。"""
return ToolPolicyResponse(
name=policy.name,
description=policy.description,
risk_level=policy.risk_level,
permission_policy=policy.permission_policy,
side_effect=policy.side_effect,
audit_required=policy.audit_required,
source=policy.source,
enabled=policy.enabled,
executable=executable,
created_at=to_iso(policy.created_at),
updated_at=to_iso(policy.updated_at),
)
def tool_policy_to_safety_item(policy: ToolPolicy) -> SafetyPolicyItem:
"""策略子集(§16 GET /safety/policies 元素)。"""
return SafetyPolicyItem(
tool=policy.name,
risk_level=policy.risk_level,
permission_policy=policy.permission_policy,
side_effect=policy.side_effect,
audit_required=policy.audit_required,
)
def import_job_to_response(job: CharacterImportJob) -> ImportJobResponse:
return ImportJobResponse(
id=job.id,
character_id=job.character_id,
material_type=job.material_type,
source_type=job.source_type,
title=job.title,
status=job.status,
format=job.format,
media_type=job.media_type,
mime_type=job.mime_type,
item_count=job.item_count,
consent_status=job.consent_status,
usage_scope=job.usage_scope,
error_code=job.error_code,
error_message=job.error_message,
created_at=to_iso(job.created_at) or "",
updated_at=to_iso(job.updated_at) or "",
)
def import_item_to_response(item: ImportItem) -> ImportItemResponse:
return ImportItemResponse(
id=item.id,
import_job_id=item.import_job_id,
item_type=item.item_type,
role=item.role,
sequence=item.sequence,
content=item.content,
metadata=loads_dict(item.metadata_json),
created_at=to_iso(item.created_at) or "",
)
def profile_candidate_to_response(candidate: ProfileCandidate) -> ProfileCandidateResponse:
return ProfileCandidateResponse(
id=candidate.id,
character_id=candidate.character_id,
import_job_id=candidate.import_job_id,
field=candidate.field,
value=candidate.value,
confidence=candidate.confidence,
evidence_ids=loads_list(candidate.evidence_ids_json),
status=candidate.status,
reason=candidate.reason,
reviewed_at=to_iso(candidate.reviewed_at),
created_at=to_iso(candidate.created_at) or "",
)
def memory_candidate_to_response(candidate: MemoryCandidate) -> MemoryCandidateResponse:
return MemoryCandidateResponse(
id=candidate.id,
character_id=candidate.character_id,
import_job_id=candidate.import_job_id,
cycle_id=candidate.cycle_id,
type=candidate.type,
title=candidate.title,
content=candidate.content,
confidence=candidate.confidence,
importance=candidate.importance,
sensitivity=candidate.sensitivity,
evidence_ids=loads_list(candidate.evidence_ids_json),
status=candidate.status,
reason=candidate.reason,
reviewed_at=to_iso(candidate.reviewed_at),
created_at=to_iso(candidate.created_at) or "",
)
+180
View File
@@ -0,0 +1,180 @@
"""Skill 能力包服务(CRUD + 关键词触发匹配)。
CRUD 记审计;``match_active_skills`` 加载 enabled skills 调 matcher,返回 Skill ORM
列表(供 send_message 取 prompt_fragment 注入 contract)。
"""
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.skills import Skill
from app.modules.skills.matcher import match_skills
from app.repositories import skills as skills_repo
from app.repositories.json_utils import dumps, loads_list
from app.schemas.skills import SkillResponse, ToolCapability
from app.services.audit import record_audit_event
async def create_skill(
*,
name: str,
description: str = "",
trigger: str = "",
prompt_fragment: str = "",
tool_bindings: list[str] | None = None,
risk_level: str = "L1",
status: str = "enabled",
) -> SkillResponse:
async with AsyncSessionLocal() as session:
existing = await skills_repo.get_skill_by_name(session, name)
if existing is not None:
raise AppError("conflict", f"Skill 名「{name}」已存在。")
skill = Skill(
name=name,
description=description,
trigger=trigger,
prompt_fragment=prompt_fragment,
tool_bindings_json=dumps(tool_bindings or []),
risk_level=risk_level,
status=status,
)
await skills_repo.add_skill(session, skill)
await record_audit_event(
session,
action="skill.created",
actor="user",
input_summary=name[:200],
resource_type="skill",
resource_id=skill.id,
)
await session.commit()
await session.refresh(skill)
return _to_response(skill)
async def get_skill(skill_id: str) -> SkillResponse | None:
async with AsyncSessionLocal() as session:
skill = await skills_repo.get_skill(session, skill_id)
return _to_response(skill) if skill else None
async def list_skills(*, status: str | None = None) -> list[SkillResponse]:
async with AsyncSessionLocal() as session:
skills = await skills_repo.list_skills(session, status=status)
return [_to_response(s) for s in skills]
async def list_all_capabilities(runtime) -> list[ToolCapability]:
"""统一列举 builtin + mcp(runtime.list_specs)+ skill(DB),含来源标记。"""
capabilities: list[ToolCapability] = []
if runtime is not None:
for spec in runtime.list_specs():
source = "mcp" if spec.name.startswith("mcp:") else "builtin"
capabilities.append(
ToolCapability(
name=spec.name,
description=spec.description,
source=source,
risk_level=spec.risk_level,
status="enabled",
)
)
for s in await list_skills(status="enabled"):
capabilities.append(
ToolCapability(
name=s.name,
description=s.description,
source="skill",
risk_level=s.risk_level,
status=s.status,
)
)
return capabilities
async def update_skill(
skill_id: str,
*,
description: str | None = None,
trigger: str | None = None,
prompt_fragment: str | None = None,
tool_bindings: list[str] | None = None,
risk_level: str | None = None,
status: str | None = None,
) -> SkillResponse:
async with AsyncSessionLocal() as session:
skill = await skills_repo.get_skill(session, skill_id)
if skill is None:
raise AppError("not_found", "Skill 不存在。")
if description is not None:
skill.description = description
if trigger is not None:
skill.trigger = trigger
if prompt_fragment is not None:
skill.prompt_fragment = prompt_fragment
if tool_bindings is not None:
skill.tool_bindings_json = dumps(tool_bindings)
if risk_level is not None:
skill.risk_level = risk_level
if status is not None:
skill.status = status
await record_audit_event(
session,
action="skill.updated",
actor="user",
input_summary=skill.name[:200],
resource_type="skill",
resource_id=skill.id,
)
await session.commit()
await session.refresh(skill)
return _to_response(skill)
async def delete_skill(skill_id: str) -> None:
async with AsyncSessionLocal() as session:
skill = await skills_repo.get_skill(session, skill_id)
if skill is None:
raise AppError("not_found", "Skill 不存在。")
name = skill.name
await session.delete(skill)
await record_audit_event(
session,
action="skill.deleted",
actor="user",
input_summary=name[:200],
resource_type="skill",
resource_id=skill_id,
)
await session.commit()
async def match_active_skills(context_text: str) -> list[Skill]:
"""加载 enabled skills,关键词匹配;返回命中的 Skill ORM(供注入 prompt_fragment)。"""
async with AsyncSessionLocal() as session:
skills = await skills_repo.list_skills(session, status="enabled")
return match_skills(context_text, skills)
async def get_enabled_skills_by_names(names: tuple[str, ...]) -> list[Skill]:
"""按名称返回 enabled Skills,供 Agent 显式引用。"""
wanted = set(names)
if not wanted:
return []
async with AsyncSessionLocal() as session:
skills = await skills_repo.list_skills(session, status="enabled")
return [skill for skill in skills if skill.name in wanted]
def _to_response(skill: Skill) -> SkillResponse:
return SkillResponse(
id=skill.id,
name=skill.name,
description=skill.description,
trigger=skill.trigger,
prompt_fragment=skill.prompt_fragment,
tool_bindings=loads_list(skill.tool_bindings_json),
risk_level=skill.risk_level,
status=skill.status,
created_at=skill.created_at,
updated_at=skill.updated_at,
)
+390
View File
@@ -0,0 +1,390 @@
"""辰正时态服务编排(设计 §6 流 A + 流 C)。
职责:
- 流 A:拉数据 → compute_snapshot + compute_modulation → 组 temporal_context 文本
- 流 C:检测 care / sentiment / reunion → 发 time.* 事件到 EventStore
降级原则(设计 §12):任何计算失败返回最小结果,不阻塞对话。
本 plan 不含流 B(后台 tick)——依赖未实现的 background_task 基础设施。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.chat import ChatMessage
from app.models.time_anniversary import TimeAnniversary
from app.modules.runtime.events import Event, EventStore
from app.modules.time_sense.context_text import render_temporal_context
from app.modules.time_sense.duration import AcquaintanceDuration, OfflineThresholds
from app.modules.time_sense.modulation import ModulationFactors, ModulationInput, compute_modulation
from app.modules.time_sense.sentiment import SentimentInput, detect_sentiment
from app.modules.time_sense.snapshot import (
AnniversaryRef,
LocationContext,
SnapshotInput,
TemporalSnapshot,
compute_snapshot,
)
from app.repositories.time_anniversaries import (
create_anniversary,
delete_anniversary,
get_anniversary,
list_anniversaries,
update_anniversary,
)
from app.schemas.time import AnniversaryCreateRequest, AnniversaryUpdateRequest
from app.services.characters import get_active_character_model
class LiveSnapshotResult(BaseModel):
"""compute_live_snapshot 的返回。"""
snapshot: TemporalSnapshot | None = None
factors: ModulationFactors | None = None
context_text: str = ""
emitted_events: list[Event] = Field(default_factory=list)
async def emit_time_event(
store: EventStore,
event_type: str,
character_id: str,
payload: dict[str, Any],
*,
run_id: str | None = None,
source: str = "time_sense",
sensitivity: str = "low",
) -> Event:
"""构造并 append 一个 time.* 事件。"""
event = Event(
type=event_type,
source=source,
payload=payload,
character_id=character_id,
run_id=run_id,
sensitivity=sensitivity,
)
return await store.append(event)
def _thresholds_from_config() -> OfflineThresholds:
return OfflineThresholds(
active_minutes=settings.offline_active_minutes,
short_absence_minutes=settings.offline_short_absence_minutes,
long_absence_minutes=settings.offline_long_absence_minutes,
reunion_impact_minutes=settings.offline_reunion_impact_minutes,
)
async def _resolve_last_active(
session: AsyncSession, character_id: str, fallback: datetime
) -> datetime:
"""查 chat_messages 最新一条的 created_at;无记录返回 fallback。
DB 异常向上冒泡,由 ``compute_live_snapshot`` 降级处理(设计 §12)。
"""
result = await session.execute(
select(ChatMessage.created_at)
.where(ChatMessage.character_id == character_id)
.order_by(ChatMessage.created_at.desc())
.limit(1)
)
row = result.scalar_one_or_none()
return row if row is not None else fallback
def _anniversary_refs(rows: list[TimeAnniversary]) -> list[AnniversaryRef]:
"""TimeAnniversary ORM → AnniversaryRef(snapshot 输入)。"""
refs: list[AnniversaryRef] = []
for a in rows:
refs.append(
AnniversaryRef(
id=a.id,
name=a.name,
calendar=a.calendar,
date_iso=a.date,
recurs_yearly=a.recurs_yearly,
kind=a.kind,
)
)
return refs
async def compute_live_snapshot(
session: AsyncSession,
store: EventStore,
character_id: str, # noqa: ARG001 v1 单人物:忽略传入 id,使用当前 active 角色(设计已注明)
signals: ModulationInput,
*,
now: datetime | None = None,
timezone: str | None = None,
run_id: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> LiveSnapshotResult:
"""流 A + 流 C:算快照/因子/上下文 + 触发 time.* 事件。
任何异常降级返回最小结果(context_text 含 now_utc,因子/事件空),不抛。
"""
now = now or utc_now()
emitted: list[Event] = []
try:
character = await get_active_character_model(session, owner_scope=owner_scope)
# v1:character_id 应与 active 一致;若不一致仍用 active(v1 单人物)
anniv_rows = await list_anniversaries(session, character.id)
anniv_refs = _anniversary_refs(anniv_rows)
anchor = character.activated_at or character.created_at
# SQLite 的 DateTime(timezone=True) 仍按 naive 读回(Postgres 不受影响);
# 此处与 serializers.py 同样的防御式补 tzinfo,避免与 aware now 做差时 TypeError。
if anchor.tzinfo is None:
anchor = anchor.replace(tzinfo=UTC)
last_active = await _resolve_last_active(session, character.id, fallback=now)
if last_active.tzinfo is None:
last_active = last_active.replace(tzinfo=UTC)
location = LocationContext(
timezone=timezone or settings.user_timezone,
city=settings.default_city,
locale=settings.default_locale,
)
inp = SnapshotInput(
now=now,
timezone=timezone or settings.user_timezone,
location=location,
anniversaries=anniv_refs,
acquaintance_anchor=anchor,
last_active_at=last_active,
home_timezone=settings.home_timezone,
offline_thresholds=_thresholds_from_config(),
)
# v1 阈值可调(设计 §7.3):ModulationInput 阈值默认 None,此处从 settings 注入;
# 保持 modules/time_sense 纯函数(不读 settings),测试可显式传值覆盖。
if signals.jetlag_threshold_minutes is None:
signals = signals.model_copy(
update={"jetlag_threshold_minutes": settings.jetlag_threshold_minutes}
)
if signals.late_night_min_turns is None:
signals = signals.model_copy(
update={"late_night_min_turns": settings.late_night_min_turns}
)
snapshot = compute_snapshot(inp)
factors = compute_modulation(snapshot, signals)
context_text = render_temporal_context(snapshot, factors)
# 流 C 事件触发(Task 4 在此扩展)
await _trigger_events(
store,
snapshot,
factors,
signals,
character.id,
emitted,
anchor,
run_id=run_id,
)
return LiveSnapshotResult(
snapshot=snapshot,
factors=factors,
context_text=context_text,
emitted_events=emitted,
)
except Exception:
# 降级:最小快照(设计 §12)——记日志便于排查,辰正永不阻塞对话
logger.exception("辰正 compute_live_snapshot 降级 character_id={}", character_id)
return LiveSnapshotResult(
snapshot=None,
factors=None,
context_text=f"[时态上下文暂不可用] 当前 UTC:{now.astimezone(UTC).isoformat()}",
emitted_events=emitted,
)
async def _trigger_events(
store: EventStore,
snapshot: TemporalSnapshot,
factors: ModulationFactors,
signals: ModulationInput, # noqa: ARG001 v1 仅 care 用 factors;signals 留 v2 内容信号扩展
character_id: str,
emitted: list[Event],
anchor: datetime, # noqa: ARG001 留 v2 认识天数/里程碑锚点事件
run_id: str | None = None,
) -> None:
"""流 C:care / sentiment / reunion 检测 + emit。"""
# 1. 关怀(基于 compute_modulation 的 care_kind)
if factors.care_kind is not None and settings.care_enabled:
ev = await emit_time_event(
store,
"time.user_care",
character_id,
{
"care_kind": factors.care_kind,
"care_suggest": factors.care_suggest,
"late_night_fragility": factors.late_night_fragility,
"day_period": snapshot.day_period,
"local_hour": snapshot.local_hour,
},
run_id=run_id,
)
emitted.append(ev)
# 2. 感慨(5 触发)
is_reunion = snapshot.offline.band == "reunion_impact"
next_milestone_days = snapshot.next_milestone.days_until if snapshot.next_milestone else None
sentiment_signals = SentimentInput(
duration_days=snapshot.acquaintance.total_days,
next_milestone_days=next_milestone_days,
has_active_milestone=bool(snapshot.active_milestones),
is_reunion=is_reunion,
has_reunion_memory=is_reunion, # v1 简化:重逢即假定有临别记忆;v2 从息壤查
strong_solar_terms=settings.strong_solar_terms,
prewarm_days=settings.sentiment_prewarm_days,
duration_nodes=settings.duration_nodes,
)
trigger = detect_sentiment(snapshot, sentiment_signals)
if trigger is not None:
ev = await emit_time_event(
store,
"time.sentiment",
character_id,
{
"trigger_type": trigger,
"duration_days": snapshot.acquaintance.total_days,
"day_period": snapshot.day_period,
},
run_id=run_id,
)
emitted.append(ev)
# 3. 重逢
if is_reunion:
ev = await emit_time_event(
store,
"time.reunion",
character_id,
{
"offline_minutes": round(snapshot.offline.minutes, 1),
"reunion_intensity": factors.reunion_intensity,
"offline_band": snapshot.offline.band,
},
sensitivity="medium", # 重逢涉及久别记忆,标 medium
run_id=run_id,
)
emitted.append(ev)
# ---- service 包装函数(薄 endpoint 调用;session 在此自管)----
async def get_live_snapshot(
store: EventStore, owner_scope: str = settings.default_owner_scope
) -> LiveSnapshotResult:
"""GET /time/snapshot:当前 active 人物的实时快照。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
return await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
owner_scope=owner_scope,
)
async def list_anniversaries_for_active(
owner_scope: str = settings.default_owner_scope,
) -> list[TimeAnniversary]:
"""GET /time/anniversaries:当前 active 人物的纪念日列表。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
return await list_anniversaries(session, character.id)
async def create_anniversary_for_active(
payload: AnniversaryCreateRequest, owner_scope: str = settings.default_owner_scope
) -> TimeAnniversary:
"""POST /time/anniversaries:为当前 active 人物创建纪念日。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
anni = TimeAnniversary(
character_id=character.id,
name=payload.name,
calendar=payload.calendar,
date=payload.date,
recurs_yearly=payload.recurs_yearly,
kind=payload.kind,
subject_type=payload.subject_type,
source=payload.source,
)
created = await create_anniversary(session, anni)
await session.commit()
return created
async def get_anniversary_or_404(
anniv_id: str,
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
) -> TimeAnniversary:
"""GET /time/anniversaries/{id}:取单个,不存在抛 AppError(not_found)。"""
async with AsyncSessionLocal() as session:
anni = await get_anniversary(session, anniv_id)
if anni is None:
raise AppError("not_found", "纪念日不存在。")
return anni
async def update_anniversary_or_404(
anniv_id: str,
payload: AnniversaryUpdateRequest,
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
) -> TimeAnniversary:
"""PATCH /time/anniversaries/{id}:部分更新,不存在抛 AppError(not_found)。"""
async with AsyncSessionLocal() as session:
fields = payload.model_dump(exclude_none=True)
updated = await update_anniversary(session, anniv_id, **fields)
if updated is None:
raise AppError("not_found", "纪念日不存在。")
await session.commit()
return updated
async def delete_anniversary_or_404(
anniv_id: str,
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
) -> None:
"""DELETE /time/anniversaries/{id}:删除,不存在抛 AppError(not_found)。"""
async with AsyncSessionLocal() as session:
ok = await delete_anniversary(session, anniv_id)
if not ok:
raise AppError("not_found", "纪念日不存在。")
await session.commit()
async def get_acquaintance_for_active(
store: EventStore, owner_scope: str = settings.default_owner_scope
) -> AcquaintanceDuration:
"""GET /time/acquaintance:当前 active 人物的认识时长。snapshot 降级时抛 503。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
result = await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
owner_scope=owner_scope,
)
if result.snapshot is None:
# 契约 §15 / 设计 §12:time_context_missing → invalid_request(默认 400)
raise AppError("time_context_missing", "时态上下文不可用。")
return result.snapshot.acquaintance
+198
View File
@@ -0,0 +1,198 @@
"""世界模型服务(河图枢运行时,api-spec §12)。
按人物视角隔离:所有读写经 ``resolve_active_character`` 绑定当前激活人物。
自动抽取(世界引擎)属 v1 半自动留后项,本层先承载手动 CRUD。
"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.world import CharacterWorldEntity, WorldRelation
from app.repositories import world as world_repo
from app.repositories.json_utils import dumps
from app.schemas.common import Page
from app.schemas.world import (
WorldEntityCreateRequest,
WorldEntityResponse,
WorldGraphResponse,
WorldRelationCreateRequest,
WorldRelationResponse,
)
from app.services.audit import record_audit_event
from app.services.characters import resolve_active_character
from app.services.serializers import (
character_world_entity_to_response,
world_relation_to_response,
)
async def list_entities_page(
character_id: str | None = None,
*,
entity_type: str | None = None,
page: int = 1,
page_size: int = 20,
owner_scope: str = settings.default_owner_scope,
) -> Page[WorldEntityResponse]:
async with AsyncSessionLocal() as session:
character = await resolve_active_character(session, character_id, owner_scope=owner_scope)
safe_page = max(page, 1)
safe_size = min(max(page_size, 1), 100)
items, total = await world_repo.list_entities(
session,
character.id,
entity_type=entity_type,
page=safe_page,
page_size=safe_size,
)
return Page(
items=[character_world_entity_to_response(item) for item in items],
page=safe_page,
page_size=safe_size,
total=total,
)
async def get_entity_by_id(
entity_id: str, owner_scope: str = settings.default_owner_scope
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
character = await resolve_active_character(session, None, owner_scope=owner_scope)
entity = await world_repo.get_entity(session, entity_id)
if entity is None or entity.character_id != character.id:
raise AppError("not_found", "世界实体不存在。")
return character_world_entity_to_response(entity)
async def create_entity(
payload: WorldEntityCreateRequest, owner_scope: str = settings.default_owner_scope
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
character = await resolve_active_character(
session, payload.character_id, owner_scope=owner_scope
)
entity = CharacterWorldEntity(
character_id=character.id,
type=payload.type,
name=payload.name,
attributes_json=dumps(payload.attributes),
state_type=payload.state_type,
confidence=payload.confidence,
evidence_ids_json=dumps(payload.evidence_ids),
sources_json=dumps(payload.sources),
)
await world_repo.add_entity(session, entity)
await _audit(session, character.id, "world.entity.created", entity.name, entity.id)
await session.commit()
await session.refresh(entity)
return character_world_entity_to_response(entity)
async def list_relations_page(
character_id: str | None = None,
*,
page: int = 1,
page_size: int = 20,
owner_scope: str = settings.default_owner_scope,
) -> Page[WorldRelationResponse]:
async with AsyncSessionLocal() as session:
character = await resolve_active_character(session, character_id, owner_scope=owner_scope)
safe_page = max(page, 1)
safe_size = min(max(page_size, 1), 100)
items, total = await world_repo.list_relations(
session, character.id, page=safe_page, page_size=safe_size
)
return Page(
items=[world_relation_to_response(item) for item in items],
page=safe_page,
page_size=safe_size,
total=total,
)
async def create_relation(
payload: WorldRelationCreateRequest, owner_scope: str = settings.default_owner_scope
) -> WorldRelationResponse:
async with AsyncSessionLocal() as session:
character = await resolve_active_character(
session, payload.character_id, owner_scope=owner_scope
)
subject = await _ensure_owned_entity(session, character.id, payload.subject_id, "主体")
obj = await _ensure_owned_entity(session, character.id, payload.object_id, "客体")
relation = WorldRelation(
character_id=character.id,
subject_id=subject.id,
predicate=payload.predicate,
object_id=obj.id,
confidence=payload.confidence,
evidence_ids_json=dumps(payload.evidence_ids),
)
await world_repo.add_relation(session, relation)
await _audit(
session, character.id, "world.relation.created", relation.predicate, relation.id
)
await session.commit()
await session.refresh(relation)
return world_relation_to_response(relation)
async def delete_relation(
relation_id: str, owner_scope: str = settings.default_owner_scope
) -> None:
"""删除世界关系。"""
async with AsyncSessionLocal() as session:
character = await resolve_active_character(session, None, owner_scope=owner_scope)
relation = await world_repo.get_relation(session, relation_id)
if relation is None or relation.character_id != character.id:
raise AppError("not_found", "关系不存在。")
await world_repo.delete_relation(session, relation_id)
await _audit(
session, character.id, "world.relation.deleted", relation.predicate, relation_id
)
await session.commit()
async def get_graph(
character_id: str | None = None,
*,
owner_scope: str = settings.default_owner_scope,
) -> WorldGraphResponse:
"""返回人物的完整世界图谱(无分页,所有节点与边),供前端渲染力导向图。"""
async with AsyncSessionLocal() as session:
character = await resolve_active_character(session, character_id, owner_scope=owner_scope)
entities = await world_repo.list_all_entities(session, character.id)
relations = await world_repo.list_all_relations(session, character.id)
return WorldGraphResponse(
nodes=[character_world_entity_to_response(e) for e in entities],
edges=[world_relation_to_response(r) for r in relations],
)
async def _ensure_owned_entity(
session: AsyncSession, character_id: str, entity_id: str, role: str
) -> CharacterWorldEntity:
entity = await world_repo.get_entity(session, entity_id)
if entity is None or entity.character_id != character_id:
raise AppError("not_found", f"关系{role}实体不存在。")
return entity
async def _audit(
session: AsyncSession,
character_id: str,
action: str,
summary: str,
resource_id: str,
) -> None:
await record_audit_event(
session,
action=action,
character_id=character_id,
actor="user",
decision="auto",
input_summary=summary,
resource_type="world_entity" if "entity" in action else "world_relation",
resource_id=resource_id,
)