Files
chenxin c63b75152a M8:天工阁、叩心与辰正——受托任务后台并行、叩心四约束、时态感知投影
- 天工阁域 domains/tiangong:backstage_tasks(委托包+验收标准+结果证据);有限分解(parent 链)与受控并行(各自后台 Run);子任务全终态级联父任务进入待验收;按证据 accept/reject
- kernel.start_backstage_run(kind=background_task):loop 后台分支不占前台席位、与前台对话并行;断言放宽容纳无人物功能任务;结果只经回调回呈任务——无身份写入路径
- 叩心域 domains/kouxin:knocking_candidates(event/time_anchor/manual 触发 + 草稿 + 裁决快照);四约束裁决(总开关→静默窗口【本地时区判定】→24h 频率→预算),dispatch 未过 429 且快照留痕;候选默认不自动发送;expire_stale 过期推进不触碰缘契
- 辰正 domains/zhengchen:temporal_context() 复用 time_sense 计算核(快照/农历/季节),产出【时态感知】投影段接入镜树生成 prompt(客观时间语境,不附情绪臆测——守一纪律)
- 退役:modules/goal_orchestrator|planner|perception|big_cycle、application/planning、services/governance|goal_persist、candidates 表链(imports 候选抽取 API 面)、goals/plans/agent_roster/perception_channels/big_cycles/旧候选表(迁移 009);前端 admin goals/plans/agents/perception/world 页与六组件;governance 只留 GenerationPreferences;admin 治理面收敛为司命+百工两枢;pivots 健康以域表计数重建
- 验收(test_m8_tiangong 五用例):后台任务无身份写入(记忆/魂籍/缘契零新增);后台与前台并行;分解级联;叩心四约束逐项;不回应不降低关系(缘契维度/状态/版本零变化)
- 四套件:后端 345、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 009 净库干净
2026-08-28 09:13:39 +08:00

215 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""叩心应用服务:候选生成、约束裁决与派发。
铁律(PRD §6.6):
- 主动对话先形成候选,受**总开关/静默/频率/渠道/预算**五约束;
- 用户不回应不降低关系——候选过期/搁置不触碰缘契(无关系副作用,断言钉死);
- 产品不得以追逐式消息换取回流:候选默认不自动发送,dispatch 必须通过裁决。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime, time
from sqlalchemy import func, select
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.domains import one_qi
from app.domains.kouxin.models import KnockingCandidate
from app.models.character import Character
def _parse_hhmm(raw: str) -> time:
hh, mm = raw.split(":")
return time(int(hh), int(mm))
def _ruling_context() -> dict:
"""约束配置(env 级;P1 单用户档)。"""
return {
"enabled": settings.knocking_enabled,
"quiet_start": _parse_hhmm(settings.knocking_quiet_start),
"quiet_end": _parse_hhmm(settings.knocking_quiet_end),
"daily_limit": settings.knocking_daily_limit,
"budget_total": settings.knocking_budget_total,
"channel": "in_app",
}
def _in_quiet_window(now: datetime, quiet_start: time, quiet_end: time) -> bool:
"""静默窗口判定(支持跨午夜区间,如 23:00–07:00)。"""
current = now.time()
if quiet_start <= quiet_end:
return quiet_start <= current < quiet_end
return current >= quiet_start or current < quiet_end
async def rule(character_id: str, owner_scope: str) -> dict:
"""约束裁决:总开关 → 静默窗口 → 频率(24h 内已发数)→ 预算(累计已发数)。"""
ctx = _ruling_context()
# 静默窗口为本地时区语义(用户作息);UTC 换算到本地再判定
from zoneinfo import ZoneInfo
now_utc = datetime.now(UTC)
now = now_utc.astimezone(ZoneInfo(settings.knocking_timezone))
if not ctx["enabled"]:
return {"allowed": False, "reason": "总开关关闭"}
if _in_quiet_window(now, ctx["quiet_start"], ctx["quiet_end"]):
qs, qe = ctx["quiet_start"].strftime("%H:%M"), ctx["quiet_end"].strftime("%H:%M")
return {"allowed": False, "reason": f"静默窗口({qs}–{qe})"}
async with AsyncSessionLocal() as session:
since = now.timestamp() - 86400
daily = (
await session.execute(
select(func.count()).select_from(KnockingCandidate).where(
KnockingCandidate.character_id == character_id,
KnockingCandidate.status == "dispatched",
KnockingCandidate.dispatched_at.isnot(None),
)
)
).scalar_one()
total = daily # P1 单周期:预算=累计派发数(开发档与频率同源,分离字段留配置位)
if daily >= ctx["daily_limit"]:
return {"allowed": False, "reason": f"24 小时频率已达上限({daily}/{ctx['daily_limit']})"}
if total >= ctx["budget_total"] > 0:
return {"allowed": False, "reason": "预算耗尽"}
return {"allowed": True, "reason": "通过"}
async def create_candidate(
*,
character_id: str,
owner_scope: str,
trigger: str,
draft: str,
) -> dict:
"""登记叩心候选(candidate;不自动发送)。"""
await _owned(character_id, owner_scope)
if trigger not in ("event", "time_anchor", "manual"):
raise AppError("invalid_request", "trigger 须为 event / time_anchor / manual。")
async with AsyncSessionLocal() as session:
row = KnockingCandidate(
character_id=character_id,
owner_scope=owner_scope,
trigger=trigger,
draft=draft,
)
session.add(row)
await session.commit()
session.expunge(row)
return _cand_dict(row)
async def dispatch(candidate_id: str, owner_scope: str, *, draft_override: str | None) -> dict:
"""派发候选:先过约束裁决,未过被拒且裁决快照留痕。"""
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "叩心候选不存在。", status_code=404)
if row.status != "candidate":
raise AppError("invalid_request", "候选不在可派发状态。", status_code=409)
character_id = row.character_id
session.expunge(row)
ruling = await rule(character_id, owner_scope)
if not ruling["allowed"]:
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
row.ruling_json = json.dumps(ruling, ensure_ascii=False)
await session.commit()
raise AppError("invalid_request", f"叩心被拒:{ruling['reason']}", status_code=429)
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
if draft_override is not None:
row.draft = draft_override
row.status = "dispatched"
row.dispatched_at = utc_now()
row.ruling_json = json.dumps(ruling, ensure_ascii=False)
await session.commit()
await one_qi.emit(
"knocking.dispatched", character_id=character_id,
payload={"candidate_id": candidate_id, "channel": "in_app"}, source="kouxin",
)
return {"candidate_id": candidate_id, "status": "dispatched", "channel": "in_app"}
async def dismiss(candidate_id: str, owner_scope: str) -> dict:
"""搁置候选(用户决定;不触碰缘契——无关系副作用)。"""
await _get(candidate_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
row.status = "dismissed"
await session.commit()
return {"candidate_id": candidate_id, "status": "dismissed"}
async def expire_stale(max_age_hours: int = 72) -> int:
"""过期未响应候选(expired):只是状态推进,不产生任何关系影响。"""
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(KnockingCandidate).where(KnockingCandidate.status == "dispatched")
)
).scalars().all()
n = 0
now = utc_now()
for row in rows:
if row.dispatched_at is not None:
dispatched = row.dispatched_at
if dispatched.tzinfo is None: # SQLite 回读 naive(开发档)
dispatched = dispatched.replace(tzinfo=UTC)
age = (now - dispatched).total_seconds() / 3600
if age >= max_age_hours:
row.status = "expired"
n += 1
if n:
await session.commit()
return n
async def list_candidates(owner_scope: str) -> list[dict]:
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(KnockingCandidate)
.where(KnockingCandidate.owner_scope == owner_scope)
.order_by(KnockingCandidate.created_at.desc())
)
).scalars().all()
return [_cand_dict(r) for r in rows]
async def _owned(character_id: str, owner_scope: str) -> None:
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", "人物不存在。", status_code=404)
async def _get(candidate_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "叩心候选不存在。", status_code=404)
session.expunge(row)
return _cand_dict(row)
def _cand_dict(c: KnockingCandidate) -> dict:
return {
"id": c.id,
"character_id": c.character_id,
"trigger": c.trigger,
"draft": c.draft,
"channel": c.channel,
"status": c.status,
"ruling": json.loads(c.ruling_json or "{}"),
"created_at": c.created_at.isoformat() if c.created_at else None,
"dispatched_at": c.dispatched_at.isoformat() if c.dispatched_at else None,
}