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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ 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)` 内部配置,管理设置接口与页面尚未开放。已有设置保存会保留此键。
学习产生的内部询问(`origin=internal`、`kind=question`)超过保留天数仍为 `open` 时,由每日维护自动结束为 `abandoned`,`closed_by=maintenance`。保留天数使用运行设置 `goals.internal_question_expiry_days`,缺省 7,须为 1—365 的严格整数。`GET /admin/api/settings` 的 `goals` 分区返回此字段;可通过 `PATCH /admin/api/settings/goals` 提交 `{"internal_question_expiry_days":14}` 修改,字段省略时保留现值,字符串、布尔值、小数、null 或越界值返回 400。可与其他目标设置一起提交,所有字段与操作记录在同一事务中保存;沿用管理员会话及 CSRF 校验。页面接入由 UX 后续完成。

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

Expand All @@ -487,7 +487,7 @@ PUT 的活动经去除首尾空白后,按区分大小写的完整字符串判

prepare 与 `search(include_goals=true)` 返回最多 10 个未结束目标/询问:临近和过期优先,其次本入口、涉及当前参与者,再按期限与创建时间。此分区不改变记忆选取。管理员接口沿用会话、CSRF 和操作记录:`GET/POST /admin/api/goals`,`GET/PATCH /admin/api/goals/{id}`;管理修改须带 `expected_revision`,可编辑正文。列表另支持 `entry_id/possible_duplicate/deadline_from/deadline_to`,日期筛选包含整天。详情含 `sources`、`promise_memories`(同证据角色记忆及当时修订,并非新增语义承诺分类)、`merged_goals`、提醒计划和通知历史。

可能重复关系通过 `POST /admin/api/goals/{id}/duplicates/{other_id}/merge` 或 `/dismiss` 处理,正文须含两方 `expected_revision/other_revision`;合并仍守住类型、人物、期限、数字与否定边界。`GET /admin/api/notifications` 按 `status/goal_id` 分页,只读不取走。`PATCH /admin/api/settings/goals` 设置 `default_reminder_minutes` 和 `overdue_reminders`(默认 true),写入操作记录。页面接入留到后续界面阶段。
可能重复关系通过 `POST /admin/api/goals/{id}/duplicates/{other_id}/merge` 或 `/dismiss` 处理,正文须含两方 `expected_revision/other_revision`;合并仍守住类型、人物、期限、数字与否定边界。`GET /admin/api/notifications` 按 `status/goal_id` 分页,只读不取走。`PATCH /admin/api/settings/goals` 设置 `default_reminder_minutes`、`overdue_reminders`(默认 true)和 `internal_question_expiry_days`(默认 7),省略的字段保留现值,写入操作记录。页面接入留到后续界面阶段。

旧状态映射:`done/completed` → completed,`cancelled/canceled/abandoned` → abandoned,其余(包括 in_progress、pending、failed 和未知值)→ open;过期/失败不推导放弃。未知旧来源映射 internal。无法确定的历史完成时间和操作者保留 null。

Expand Down
16 changes: 10 additions & 6 deletions src/iris/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,11 @@ def __init__(self, canonical_id):

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 {})}
return {**DEFAULTS, QUESTION_EXPIRY_KEY: DEFAULT_QUESTION_EXPIRY_DAYS, **(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}
return _stored_goal_settings(conn)


def _question_expiry_days(value):
Expand Down Expand Up @@ -1438,7 +1437,7 @@ def configure_dedup(self, *, pending_fallback_minutes=None, possible_min_similar
return options

def configure_question_expiry(self, days, *, actor='admin'):
"""Internal configuration; HTTP/settings UI exposure is a separate change."""
"""Set question retention without changing reminder settings."""
days = _question_expiry_days(days)
with self.store.write() as conn:
settings = _stored_goal_settings(conn)
Expand All @@ -1448,15 +1447,20 @@ def configure_question_expiry(self, days, *, actor='admin'):
actor=actor, stamp=_current(self.clock()).isoformat())
return days

def configure(self,*,default_reminder_minutes=None,overdue_reminders=None,actor='admin'):
def configure(self,*,default_reminder_minutes=None,overdue_reminders=None,
internal_question_expiry_days=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','过期提醒开关须为布尔值')
if internal_question_expiry_days is not None:
_question_expiry_days(internal_question_expiry_days)
with self.store.write() as conn:
current=_current(self.clock())
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]}
requested = {'default_reminder_minutes': default_reminder_minutes,
'overdue_reminders': overdue_reminders, QUESTION_EXPIRY_KEY: internal_question_expiry_days}
changes={k:v for k,v in requested.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),))
if 'default_reminder_minutes' in changes:
Expand Down
3 changes: 2 additions & 1 deletion src/iris/settings_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from .state import state_settings
from .service_status import model_health_snapshot
from .consolidation import consolidation_settings
from .goals import goal_settings
from .goals import DEFAULT_QUESTION_EXPIRY_DAYS, goal_settings
from .goal_dedup_judge import DEFAULTS as GOAL_JUDGE_DEFAULTS, settings as goal_judge_settings

CONNECTION_TIMEOUT = 10
Expand Down Expand Up @@ -147,6 +147,7 @@ class StateSettings(Input):
class GoalsSettings(Input):
default_reminder_minutes: int = Field(default=60, ge=0, le=525600, strict=True)
overdue_reminders: bool = Field(default=True, strict=True)
internal_question_expiry_days: int = Field(default=DEFAULT_QUESTION_EXPIRY_DAYS, ge=1, le=365, strict=True)


class Limits(Input):
Expand Down
4 changes: 2 additions & 2 deletions tests/test_goals_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def test_admin_goal_crud_filters_settings_notifications_and_audit(client, store)
assert client.get('/admin/api/goals').status_code == 409
login_admin(client)
default = client.get('/admin/api/settings').json()['goals']
assert default == {'default_reminder_minutes': 60, 'overdue_reminders': True}
assert default == {'default_reminder_minutes': 60, 'overdue_reminders': True, 'internal_question_expiry_days': 7}
response = client.post('/admin/api/goals', json={'content': '管理员安排', 'entry_id': 'A',
'deadline': '2026-10-09T05:00:00Z'})
assert response.status_code == 201, response.text
Expand All @@ -130,7 +130,7 @@ def test_admin_goal_crud_filters_settings_notifications_and_audit(client, store)
settings = client.patch('/admin/api/settings/goals', json={'default_reminder_minutes': 30,
'overdue_reminders': False})
assert settings.status_code == 200
assert settings.json()['goals'] == {'default_reminder_minutes': 30, 'overdue_reminders': False}
assert settings.json()['goals'] == {'default_reminder_minutes': 30, 'overdue_reminders': False, 'internal_question_expiry_days': 7}
assert client.patch('/admin/api/settings/goals', json={}).json()['goals'] == settings.json()['goals']
with store.read() as conn:
operations = [dict(row) for row in conn.execute("SELECT * FROM admin_operations WHERE object_type IN ('goal','settings')")]
Expand Down
166 changes: 166 additions & 0 deletions tests/test_question_expiry_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Admin exposure of question retention preserves partial saves and audit atomicity."""
import json
import sqlite3

import pytest
from fastapi.testclient import TestClient

from conftest import login_admin
from iris.api import create_app
from iris.goals import Goals, question_expiry_days


KEY = 'internal_question_expiry_days'
PATH = '/admin/api/settings/goals'


@pytest.fixture
def client(store):
with TestClient(create_app(store=store, configs={}), base_url='http://127.0.0.1',
client=('127.0.0.1', 1000)) as client:
client.app.state.scheduler.stop()
login_admin(client)
yield client


def operations(store):
with store.read() as conn:
return [dict(row) for row in conn.execute(
"SELECT * FROM admin_operations WHERE object_type='settings' AND object_id='goals' ORDER BY id")]


@pytest.mark.parametrize('stored', [None, {}, {'default_reminder_minutes': 90, 'overdue_reminders': False}])
def test_get_missing_expiry_uses_default_without_writing(client, store, stored):
if stored is None:
with store.write() as conn:
conn.execute("DELETE FROM runtime_settings WHERE key='goals'")
else:
store.set_setting('goals', stored)
before = operations(store)
response = client.get('/admin/api/settings')
assert response.status_code == 200
assert response.json()['goals'] == {
'default_reminder_minutes': 60, 'overdue_reminders': True, **(stored or {}), KEY: 7}
assert store.setting('goals') == stored
assert operations(store) == before


@pytest.mark.parametrize('days', [1, 7, 365])
def test_get_exposes_existing_internal_setting(client, store, days):
Goals(store).configure_question_expiry(days)
before = operations(store)
response = client.get('/admin/api/settings')
assert response.status_code == 200
assert response.json()['goals'][KEY] == days
assert operations(store) == before


@pytest.mark.parametrize('days', [1, 7, 365])
def test_patch_expiry_alone_preserves_reminder_settings(client, store, days):
store.set_setting('goals', {'default_reminder_minutes': 90, 'overdue_reminders': False, KEY: 21})
before = operations(store)
response = client.patch(PATH, json={KEY: days})
assert response.status_code == 200
expected = {'default_reminder_minutes': 90, 'overdue_reminders': False, KEY: days}
assert response.json()['goals'] == expected
assert store.setting('goals') == expected
with store.read() as conn:
assert question_expiry_days(conn) == days
after = operations(store)
assert len(after) == len(before) + 1
assert after[-1]['actor'] == 'admin'
assert after[-1]['action'] == 'settings_goals'
assert json.loads(after[-1]['details_json']) == {KEY: days}


def test_patch_combined_settings_returns_saved_values_and_one_audit(client, store):
store.set_setting('goals', {'default_reminder_minutes': 90, 'overdue_reminders': False, KEY: 21})
before = operations(store)
changes = {'default_reminder_minutes': 30, 'overdue_reminders': True, KEY: 14}
response = client.patch(PATH, json=changes)
assert response.status_code == 200
assert response.json()['goals'] == changes
assert store.setting('goals') == changes
assert client.get('/admin/api/settings').json()['goals'] == changes
after = operations(store)
assert len(after) == len(before) + 1
assert after[-1]['actor'] == 'admin'
assert after[-1]['action'] == 'settings_goals'
assert json.loads(after[-1]['details_json']) == changes
assert after[-1]['id'] in [row['id'] for row in response.json()['operations']]


@pytest.mark.parametrize('changes', [
{'default_reminder_minutes': 30}, {'overdue_reminders': False},
{'default_reminder_minutes': 30, 'overdue_reminders': False}, {},
])
def test_omitted_expiry_preserves_saved_value_and_other_keys(client, store, changes):
original = {'default_reminder_minutes': 60, 'overdue_reminders': True, KEY: 21,
'future_option': {'enabled': True}}
store.set_setting('goals', original)
response = client.patch(PATH, json=changes)
assert response.status_code == 200
assert response.json()['goals'][KEY] == 21
assert store.setting('goals') == {**original, **changes}
assert json.loads(operations(store)[-1]['details_json']) == changes


@pytest.mark.parametrize('value', [0, -1, 366, True, False, 7.0, 7.5, '7', None, [], {}])
def test_invalid_expiry_returns_400_without_partial_save_or_audit(client, store, value):
original = {'default_reminder_minutes': 60, 'overdue_reminders': True, KEY: 21}
store.set_setting('goals', original)
before = operations(store)
response = client.patch(PATH, json={KEY: value, 'default_reminder_minutes': 30, 'overdue_reminders': False})
assert response.status_code == 400
assert any(item['field'] == 'body.' + KEY for item in response.json()['error']['fields'])
assert store.setting('goals') == original
assert operations(store) == before


@pytest.mark.parametrize('invalid', [
{'default_reminder_minutes': True}, {'default_reminder_minutes': -1}, {'overdue_reminders': 'false'},
])
def test_invalid_existing_field_does_not_save_valid_expiry(client, store, invalid):
original = {'default_reminder_minutes': 60, 'overdue_reminders': True, KEY: 21}
store.set_setting('goals', original)
before = operations(store)
response = client.patch(PATH, json={KEY: 14, **invalid})
assert response.status_code == 400
assert store.setting('goals') == original
assert operations(store) == before


def test_audit_failure_rolls_back_all_settings(client, store):
original = {'default_reminder_minutes': 60, 'overdue_reminders': True, KEY: 21}
store.set_setting('goals', original)
before = operations(store)
with store.write() as conn:
conn.execute("""CREATE TRIGGER reject_goal_settings_audit BEFORE INSERT ON admin_operations
WHEN NEW.action='settings_goals'
BEGIN SELECT RAISE(ABORT, 'test audit failure'); END""")
with pytest.raises(sqlite3.IntegrityError, match='test audit failure'):
client.patch(PATH, json={KEY: 14, 'default_reminder_minutes': 30, 'overdue_reminders': False})
assert store.setting('goals') == original
assert operations(store) == before


@pytest.mark.parametrize('protection', ['session', 'csrf', 'origin', 'content_type', 'host'])
def test_expiry_patch_retains_admin_write_protection(client, store, protection):
original = store.setting('goals')
before = operations(store)
headers = {}
if protection == 'session':
client.cookies.clear()
assert client.get('/admin/api/settings').status_code == 401
elif protection == 'csrf':
del client.headers['X-Iris-CSRF']
elif protection == 'origin':
headers['Origin'] = 'https://evil.example'
elif protection == 'content_type':
headers['Content-Type'] = 'text/plain'
elif protection == 'host':
headers['Host'] = 'evil.example'
response = client.patch(PATH, json={KEY: 14}, headers=headers)
assert response.status_code in (400, 401, 403)
assert store.setting('goals') == original
assert operations(store) == before
4 changes: 2 additions & 2 deletions tests/test_question_goal_expiry.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ def test_setting_write_persists_and_existing_admin_patch_preserves_internal_key(
login_admin(client)
response = client.patch('/admin/api/settings/goals', json={'default_reminder_minutes': 90, 'overdue_reminders': False})
assert response.status_code == 200
assert response.json()['goals'] == {'default_reminder_minutes': 90, 'overdue_reminders': False}
assert client.patch('/admin/api/settings/goals', json={KEY: days}).status_code == 400
assert response.json()['goals'] == {'default_reminder_minutes': 90, 'overdue_reminders': False, KEY: days}
assert client.patch('/admin/api/settings/goals', json={KEY: days}).status_code == 200
with store.read() as conn:
assert question_expiry_days(conn) == days
assert json.loads(conn.execute("SELECT value_json FROM runtime_settings WHERE key='goals'").fetchone()[0]) == {
Expand Down