Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,12 @@ PUT 的活动经去除首尾空白后,按区分大小写的完整字符串判

目标属于角色,所有入口共享,入口只记录产生位置;它是待办意图,不是当前活动、记忆或 persona。学习仍可同时保存承诺记忆与内部目标,目标本身不加入学习材料,也不会因聊天中的含糊说法自动完成。询问为 `kind=question`,没有截止时间或提醒,宿主可直接注入,并决定何时问。

学习产生的内部询问(`origin=internal`、`kind=question`)超过保留天数仍为 `open` 时,由每日维护自动结束为 `abandoned`,`closed_by=maintenance`。保留天数使用运行设置 `goals.internal_question_expiry_days`,缺省 7,须为 1—365 的整数;当前只可通过 `Goals.configure_question_expiry(days)` 内部配置,管理设置接口与页面尚未开放。已有设置保存会保留此键。

天数按维护任务开始时的角色时区计算自然日:创建当天为第 0 天,第 N 天仍保留,进入第 N+1 天后的首次维护才结束。例如 N=7、10 月 10 日创建,10 月 17 日仍保留,10 月 18 日起可结束。续跑使用该维护任务的开始时间与时区判断到期,结束时间记录实际执行时间;写入前核对修订号,冲突留到下一次维护。此清理不依赖模型整理/目标依据复核开关,不发通知或提醒,也不改变承诺记忆。维护报告列出数量与目标 ID,目标修订记录固定原因 `question_expired` 及状态变化,不复制正文。合并来源节点跳过,保留目标按自身来源与创建时间判断;“可能重复”仍需按原规则处理。

宿主或管理员创建的询问,以及所有 `kind=normal` 目标,不适用自动到期结束。宿主接口字段保持不变,可通过原有目标状态查询观察结束结果。

| 方法与路径 | 内容 |
| --- | --- |
| `GET /api/v1/goals` | `state`、`kind`、`overdue`、`due_soon` 筛选,`limit`(1—100,默认 30)、`offset` 分页 |
Expand All @@ -475,7 +481,7 @@ PUT 的活动经去除首尾空白后,按区分大小写的完整字符串判

合并保留较早目标,原目标及其依据保留可追溯,占位的 `merged_into` 指向保留项;来源消息、同证据角色记忆与未取提醒归入保留项。明确截止时间补入空值;两个明确提前量不同则采用较早提醒(较大分钟数),原值仍在被合并项中。合并后的同阶段待取提醒只留一条,其余保留为取消记录。相同 `host_key` 的重试返回首次创建的完整回执快照,即使后来目标已修改;读取最新状态用列表。修改已合并 ID 返回 409 `goal_merged` 和 `canonical_id`,需要明确选择保留项再提交。

有期限的普通目标在临近、到期时发布提醒;创建/修改时已经错过提醒时刻则立即发布一条,未来的到期提醒仍保留。服务恢复时同一目标错过的阶段只发布一条即时提醒,不逐条补发。提前量为 0 时,临近和到期同刻,合为一条。过期后的周期提醒按角色时区每天最多一次,内容请宿主选择放弃或修改期限;停机多日只生成当前一天的一条。系统不自动失败、放弃或顺延。
有期限的普通目标在临近、到期时发布提醒;创建/修改时已经错过提醒时刻则立即发布一条,未来的到期提醒仍保留。服务恢复时同一目标错过的阶段只发布一条即时提醒,不逐条补发。提前量为 0 时,临近和到期同刻,合为一条。过期后的周期提醒按角色时区每天最多一次,内容请宿主选择放弃或修改期限;停机多日只生成当前一天的一条。除上述学习产生的内部询问到期规则外,系统不自动失败、放弃或顺延。

通知的 `id` 是实际发布时分配的单调游标,未来计划不提前占用游标。目标提醒每条有 `kind=goal_reminder`、`reminder_kind=soon/due/overdue/immediate`、`goal_id`、`content`、发布时的期限 `deadline_at`、计划/发布时间、`status=pending/taken/cancelled` 及取走/取消时间。取走仅表示宿主读到;相同游标可重放已取记录,方便恢复丢失的 HTTP 响应,宿主按通知 ID 去重。取走不等于送达,也不完成目标。修改期限/提前量或完成/放弃会更新未来计划、取消未取的目标提醒;已取记录和去重结果通知保留。默认提前量变动重排使用默认值的未来目标,保留已过期的待取提醒。

Expand Down
73 changes: 70 additions & 3 deletions src/iris/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
from .state import local_time, role_zone

DEFAULTS = {'default_reminder_minutes': 60, 'overdue_reminders': True}
QUESTION_EXPIRY_KEY = 'internal_question_expiry_days'
DEFAULT_QUESTION_EXPIRY_DAYS = 7
ALL_ENTRIES = Scope(kind='all')
REVIEW_RETRY_DELAYS = (30, 120, 600)

Expand All @@ -49,11 +51,26 @@ def __init__(self, canonical_id):
super().__init__('目标已合并')


def goal_settings(conn):
def _stored_goal_settings(conn):
row = conn.execute("SELECT value_json FROM runtime_settings WHERE key='goals'").fetchone()
return {**DEFAULTS, **(json.loads(row[0]) if row else {})}


def goal_settings(conn):
# Keep the existing settings API projection until its separate rollout.
return {key: value for key, value in _stored_goal_settings(conn).items() if key != QUESTION_EXPIRY_KEY}


def _question_expiry_days(value):
if type(value) is not int or not 1 <= value <= 365:
raise GoalError(QUESTION_EXPIRY_KEY, '内部询问保留天数须为 1—365 的整数')
return value


def question_expiry_days(conn):
return _question_expiry_days(_stored_goal_settings(conn).get(QUESTION_EXPIRY_KEY, DEFAULT_QUESTION_EXPIRY_DAYS))


def _current(value=None):
return (value or utc_now()).astimezone(timezone.utc)

Expand Down Expand Up @@ -342,6 +359,45 @@ def _basis_observations(conn, goal_id):
yield row['memory_id'],row['memory_revision'],observed,changes


def expire_internal_question(conn, current, *, goal_id, expected_revision, as_of, zone):
"""Close one expired question in the maintenance item's write transaction.

Creation day is day 0 in the run's role timezone; day N remains open.
The run's cutoff is stable across restarts, while closed_at is execution time.
"""
if not conn.in_transaction:
raise ValueError('question expiry requires a write transaction')
row = _row(conn, goal_id)
if row['revision'] != expected_revision:
raise GoalConflict()
if (row['origin'] != 'internal' or row['kind'] != 'question'
or row['state'] != 'open' or row['merged_into'] is not None):
return False
days = question_expiry_days(conn)
created = datetime.fromisoformat(row['created_at'])
if created.tzinfo is None:
raise GoalError('created_at', '创建时间须带时区')
age = (as_of.astimezone(zone).date() - created.astimezone(zone).date()).days
if age <= days:
return False
current = _current(current)
before = _snapshot(conn, goal_id)
changed = conn.execute("""UPDATE goals SET state='abandoned',closed_at=?,closed_by='maintenance',
revision=revision+1,updated_at=? WHERE id=? AND revision=? AND state='open' AND merged_into IS NULL""",
(current.isoformat(), current.isoformat(), goal_id, expected_revision)).rowcount
if changed != 1:
raise GoalConflict()
# A pending/in-flight dedup review cannot revive or merge this closed goal.
# Cancellation does not publish a notification or alter duplicate receipts.
_cancel_review(conn, goal_id, current, {'status':'created','target_id':None,'reason':'goal_closed'})
sid = _record_snapshot(conn, goal_id, before, actor='maintenance', action='expire_question',
reason='question_expired', current=current)
operation(conn, 'goal_question_expired', 'goal', goal_id,
{'revision_before':expected_revision, 'snapshot_id':sid, QUESTION_EXPIRY_KEY:days},
actor='maintenance', stamp=current.isoformat())
return True


def review_goal_basis(conn, current, *, goal_id=None, expected_revision=None):
"""Review current basis inside the caller's write transaction; no model/history reads.

Expand Down Expand Up @@ -1381,14 +1437,25 @@ def configure_dedup(self, *, pending_fallback_minutes=None, possible_min_similar
actor=actor,stamp=_current(self.clock()).isoformat())
return options

def configure_question_expiry(self, days, *, actor='admin'):
"""Internal configuration; HTTP/settings UI exposure is a separate change."""
days = _question_expiry_days(days)
with self.store.write() as conn:
settings = _stored_goal_settings(conn)
settings[QUESTION_EXPIRY_KEY] = days
conn.execute("INSERT OR REPLACE INTO runtime_settings VALUES('goals',?)", (dumps(settings),))
operation(conn, 'settings_goals', 'settings', 'goals', {QUESTION_EXPIRY_KEY:days},
actor=actor, stamp=_current(self.clock()).isoformat())
return days

def configure(self,*,default_reminder_minutes=None,overdue_reminders=None,actor='admin'):
if default_reminder_minutes is not None:
_lead(default_reminder_minutes)
if overdue_reminders is not None and type(overdue_reminders) is not bool:
raise GoalError('overdue_reminders','过期提醒开关须为布尔值')
with self.store.write() as conn:
current=_current(self.clock())
settings=goal_settings(conn)
settings=_stored_goal_settings(conn)
changes={k:v for k,v in {'default_reminder_minutes':default_reminder_minutes,'overdue_reminders':overdue_reminders}.items() if v is not None and v!=settings[k]}
settings.update(changes)
conn.execute("INSERT INTO runtime_settings(key,value_json) VALUES('goals',?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json",(dumps(settings),))
Expand All @@ -1409,4 +1476,4 @@ def configure(self,*,default_reminder_minutes=None,overdue_reminders=None,actor=
for row in _rows(conn,"state='open' AND merged_into IS NULL AND deadline_at IS NOT NULL AND deadline_at<=?",(current.isoformat(),)):
_next_overdue(conn,row,current)
operation(conn,'settings_goals','settings','goals',changes,actor=actor,stamp=current.isoformat())
return settings
return goal_settings(conn)
21 changes: 15 additions & 6 deletions src/iris/maintenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,11 @@ def _candidates(self, run, phase):
from .consolidation import run_settings
with self.store.read() as conn:
settings = run_settings(conn, run['id'])
if not settings['enabled'] or not settings['goal_review_enabled']:
return []
basis_review = settings['enabled'] and settings['goal_review_enabled']
return [dict(r) for r in conn.execute("""SELECT id,id AS cursor_id,revision,CAST(id AS TEXT) AS item_key
FROM goals WHERE id>? AND id<=? AND state='open' AND merged_into IS NULL ORDER BY id LIMIT 100""",
(run['cursor_id'],run['goal_through']))]
FROM goals WHERE id>? AND id<=? AND state='open' AND merged_into IS NULL
AND (? OR (origin='internal' AND kind='question')) ORDER BY id LIMIT 100""",
(run['cursor_id'],run['goal_through'],basis_review))]
if phase in ("decay", "expiry"):
where = "m.id>? AND m.id<=? AND m.lifecycle!='deleted'"
params = [run["cursor_id"], run["memory_through"]]
Expand Down Expand Up @@ -324,8 +324,15 @@ def _apply(self, conn, run, phase, candidate):
deleted = delete_unreferenced_file(self.store, conn, mid, current)
return ("media_deleted", None, {}) if deleted else ("skipped", "media_referenced_or_not_due", {})
if phase == "goals":
from .goals import review_goal_basis, GoalConflict
from .goals import expire_internal_question, review_goal_basis, GoalConflict
from .consolidation import run_settings
try:
if expire_internal_question(conn, executed_at, goal_id=mid, expected_revision=candidate['revision'],
as_of=current, zone=ZoneInfo(run['timezone'])):
return 'questions_expired', 'question_expired', {}
settings = run_settings(conn, run['id'])
if not settings['enabled'] or not settings['goal_review_enabled']:
return 'checked', None, {}
# Include the review's changes, report and cursor in this one
# small transaction. The standalone paging helper cannot commit
# our cursor/report atomically, so use its single-item API.
Expand Down Expand Up @@ -424,7 +431,7 @@ def _apply(self, conn, run, phase, candidate):
def _summary(items, progress):
result = {name: {"count": 0, "memory_ids": [], "object_ids": []} for name in (
"decayed", "checked", "forgotten", "restored", "deleted", "dependencies_weakened",
"messages_deleted", "batches_retried", "skipped", "failed")}
"messages_deleted", "batches_retried", "questions_expired", "skipped", "failed")}
for item in items:
names = [] if item["outcome"] in ("checked", "skipped") else [item["outcome"]]
transition = item["details"].get("transition")
Expand Down Expand Up @@ -471,6 +478,8 @@ def report(self, run_id):
items.append(item)
report["items"] = items
report["summary"] = self._summary(items, progress)
expired = report['summary']['questions_expired']
report['question_expiry'] = {'count':expired['count'], 'goal_ids':expired['object_ids']}
model = conn.execute("SELECT * FROM consolidation_runs WHERE run_id=?", (run_id,)).fetchone()
if model:
rows = conn.execute("SELECT outcome,reason,details_json FROM consolidation_run_work WHERE run_id=?", (run_id,)).fetchall()
Expand Down
Loading