diff --git a/README.md b/README.md index 228823b..e19047b 100644 --- a/README.md +++ b/README.md @@ -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` 及状态变化,不复制正文。合并来源节点跳过,保留目标按自身来源与创建时间判断;“可能重复”仍需按原规则处理。 @@ -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。 diff --git a/src/iris/goals.py b/src/iris/goals.py index b9093ff..0d00d22 100644 --- a/src/iris/goals.py +++ b/src/iris/goals.py @@ -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): @@ -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) @@ -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: diff --git a/src/iris/settings_api.py b/src/iris/settings_api.py index 868323f..65e772b 100644 --- a/src/iris/settings_api.py +++ b/src/iris/settings_api.py @@ -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 @@ -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): diff --git a/tests/test_goals_api.py b/tests/test_goals_api.py index 472e9c3..0ff6ddf 100644 --- a/tests/test_goals_api.py +++ b/tests/test_goals_api.py @@ -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 @@ -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')")] diff --git a/tests/test_question_expiry_settings.py b/tests/test_question_expiry_settings.py new file mode 100644 index 0000000..393b904 --- /dev/null +++ b/tests/test_question_expiry_settings.py @@ -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 diff --git a/tests/test_question_goal_expiry.py b/tests/test_question_goal_expiry.py index b25af0f..e15435e 100644 --- a/tests/test_question_goal_expiry.py +++ b/tests/test_question_goal_expiry.py @@ -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]) == {