初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1 @@
|
||||
"""业务服务层。"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 "",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user