ai_co_discuss 0.2.48 → 0.2.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,9 @@ import uuid
7
7
 
8
8
  LEASE_SECONDS = 120
9
9
  MAX_RUN_SECONDS = 86400
10
- LIMIT_DEFAULTS = {"max_rounds": 0, "max_minutes": 0, "execution_minutes": 0,
11
- "on_limit": "pause", "summary_minutes": 5}
10
+ LIMIT_DEFAULTS = {"max_rounds": 0, "max_minutes": 0, "execution_minutes": 0}
11
+ # Older clients may still send these; reaching a limit always pauses, so only the pause value is accepted and the reserve is ignored.
12
+ LEGACY_LIMIT_FIELDS = ("on_limit", "summary_minutes")
12
13
  PUBLIC_EVENT_FIELDS = ("id", "room_id", "run_id", "generation", "cause", "participant",
13
14
  "role", "turn_id", "status", "session_id", "lease_until", "result", "created_at")
14
15
  LIMITS_SQL = """SELECT limits.*,COALESCE(pauses.paused_seconds,0)+CASE WHEN COALESCE(pauses.paused_at,0)>0
@@ -39,18 +40,15 @@ def begin_write(connection):
39
40
 
40
41
  def limit_values(payload, current=None):
41
42
  """Validate bounded discussion settings; zero means no round/time preset."""
42
- if not isinstance(payload, dict) or set(payload) - set(LIMIT_DEFAULTS):
43
+ if not isinstance(payload, dict) or set(payload) - set(LIMIT_DEFAULTS) - set(LEGACY_LIMIT_FIELDS):
43
44
  raise ValueError("limits 仅接受讨论上限配置字段")
45
+ if payload.get("on_limit", "pause") != "pause":
46
+ raise ValueError("到限后只会暂停;需要总结时请主持人在暂停后按你的要求写出")
44
47
  result = {key: (current or {}).get(key, value) for key, value in LIMIT_DEFAULTS.items()}
45
- result.update(payload)
46
- for key, minimum, maximum in (("max_rounds", 0, 100), ("max_minutes", 0, 1440),
47
- ("execution_minutes", 0, 60), ("summary_minutes", 1, 60)):
48
+ result.update({key: value for key, value in payload.items() if key in LIMIT_DEFAULTS})
49
+ for key, minimum, maximum in (("max_rounds", 0, 100), ("max_minutes", 0, 1440), ("execution_minutes", 0, 60)):
48
50
  if type(result[key]) is not int or not minimum <= result[key] <= maximum:
49
51
  raise ValueError(f"{key} 须为 {minimum}–{maximum} 的整数")
50
- if result["on_limit"] not in ("pause", "summarize"):
51
- raise ValueError("on_limit 须为 pause 或 summarize")
52
- if result["on_limit"] == "summarize" and result["max_minutes"] and result["summary_minutes"] >= result["max_minutes"]:
53
- raise ValueError("收束预留时间必须小于讨论总时长")
54
52
  return result
55
53
 
56
54
 
@@ -63,10 +61,6 @@ def limits_view(row, room):
63
61
  if room.get("runtime", {}).get("deadline") and (room["runtime"]["status"] == "running" or result["stop_reason"]):
64
62
  runtime_deadline = room["runtime"]["deadline"]
65
63
  deadline = min(deadline or runtime_deadline, runtime_deadline)
66
- if result["closing_at"]:
67
- closing_deadline = result["closing_at"] + result["summary_minutes"] * 60
68
- deadline = min(deadline or closing_deadline, closing_deadline)
69
- cutoff = deadline - result["summary_minutes"] * 60 if deadline and result["on_limit"] == "summarize" and not result["closing_at"] else deadline
70
64
  reason = ""
71
65
  budget = room.get("round_budget")
72
66
  experts = len(room.get("experts") or {})
@@ -78,14 +72,9 @@ def limits_view(row, room):
78
72
  round_cap = (budget["cap"] or result["max_rounds"]) if budget else result["max_rounds"]
79
73
  if deadline and now >= deadline:
80
74
  reason = "time_limit"
81
- elif result["closing_at"]:
82
- reason = result["stop_reason"]
83
75
  elif round_cap and segment_rounds >= round_cap:
84
76
  reason = "round_limit"
85
- elif cutoff and now >= cutoff:
86
- reason = "summary_reserved" if result["on_limit"] == "summarize" else "time_limit"
87
- result.update(deadline=deadline, cutoff=cutoff, reached=bool(reason), reason=reason,
88
- closing=bool(result["closing_at"]) and room["phase"] not in ("paused", "concluded"), completed_rounds=room["completed_rounds"],
77
+ result.update(deadline=deadline, reached=bool(reason), reason=reason, completed_rounds=room["completed_rounds"],
89
78
  segment_rounds=segment_rounds, round_cap=round_cap, extra_rounds=budget["extra"] if budget else 0,
90
79
  remaining_seconds=max(0, deadline - now) if deadline else None,
91
80
  elapsed_seconds=max(0, now - result["started_at"] - idle) if result["started_at"] else 0)
@@ -95,7 +84,7 @@ def limits_view(row, room):
95
84
  def require_within_limits(room):
96
85
  """Stop new expert work at the configured boundary, preserving user notes."""
97
86
  if room["limits"]["reached"]:
98
- raise FileExistsError("已达到讨论上限;请检查上限并暂停或由主持人总结")
87
+ raise FileExistsError("已达到讨论上限;请检查上限并暂停,由用户决定追加轮次或结束")
99
88
 
100
89
 
101
90
  def execution_seconds(connection, room_id):
@@ -191,12 +180,6 @@ def authorize_participant(connection, room_id, identity, operation, payload):
191
180
  (room_id, author)).fetchone()
192
181
  if author != actor and not (online and identity["role"] in ("owner", "host")):
193
182
  raise PermissionError("只能提交本人观点,或由房主、主持人代发在线成员原文")
194
- elif operation == "floor":
195
- speaker = payload.get("speaker", actor)
196
- online = connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND name=? AND access='online' AND status='active'",
197
- (room_id, speaker)).fetchone()
198
- if identity["role"] != "owner" and speaker != actor and not (identity["role"] == "host" and online):
199
- raise PermissionError("只能操作本人发言权")
200
183
  elif operation != "attachments" and identity["role"] != "owner":
201
184
  if identity["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
202
185
  raise PermissionError("本成员没有此管理权限")
@@ -235,11 +218,14 @@ def authorize_write(connection, room_id, token, operation, payload):
235
218
  if operation == "messages":
236
219
  if payload.get("author") != actor:
237
220
  raise PermissionError("自动执行不能代发其他参与者")
238
- if event["role"] == "expert" and payload.get("turn_id") != event["turn_id"]:
221
+ if event["role"] == "expert" and payload.get("turn_id") not in (None, event["turn_id"]):
239
222
  raise PermissionError("回答必须关联本次安排")
240
- elif operation == "floor":
241
- if payload.get("speaker", actor) != actor:
242
- raise PermissionError("自动执行只能操作本人发言权")
223
+ if event["role"] == "expert" and payload.get("turn_id") is None:
224
+ # Without a named arrangement the answer belongs to the expert's open one, which must be this execution's.
225
+ open_turn = connection.execute("SELECT id FROM discussion_turns WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
226
+ (room_id, actor)).fetchone()
227
+ if not open_turn or open_turn["id"] != event["turn_id"]:
228
+ raise PermissionError("回答必须关联本次安排")
243
229
  elif operation == "attachments":
244
230
  pass
245
231
  elif event["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
@@ -292,10 +278,6 @@ def validate_start(connection, room_id, payload):
292
278
  limits = connection.execute(LIMITS_SQL, (room_id,)).fetchone()
293
279
  configured = ((limits["started_at"] or int(time.time())) + limits["max_minutes"] * 60 + limits["idle_seconds"]
294
280
  if limits["max_minutes"] else 0)
295
- if limits["closing_at"]:
296
- closing_deadline = limits["closing_at"] + limits["summary_minutes"] * 60
297
- previous_deadline = runtime_view(connection, room_id).get("deadline", 0)
298
- configured = min(value for value in (configured, closing_deadline, previous_deadline) if value)
299
281
  deadline = payload.get("deadline", configured)
300
282
  if type(deadline) is int and configured:
301
283
  deadline = min(deadline or configured, configured)
@@ -379,6 +361,10 @@ def reserve_event(connection, event, payload):
379
361
  if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')",
380
362
  (event["room_id"],)).fetchone():
381
363
  raise FileExistsError("已有在途执行")
364
+ if connection.execute("""SELECT 1 FROM member_executions JOIN room_members ON room_members.id=member_executions.member_id
365
+ WHERE member_executions.room_id=? AND room_members.name=? AND member_executions.status IN ('dispatched','started')""",
366
+ (event["room_id"], event["participant"])).fetchone():
367
+ raise FileExistsError("该参与者正在回答用户的直接提问;同一会话不并发续接")
382
368
  connection.execute("UPDATE discussion_events SET status='dispatched',token_hash=? WHERE id=?", (hashed, event["id"]))
383
369
  if event["role"] == "host":
384
370
  connection.execute("UPDATE discussion_events SET status='completed',result=? WHERE room_id=? AND role='host' AND status='pending' AND id<?",
@@ -144,27 +144,9 @@ CREATE TABLE IF NOT EXISTS room_floor (
144
144
  next_speaker TEXT NOT NULL,
145
145
  revision INTEGER NOT NULL DEFAULT 0
146
146
  );
147
- INSERT OR IGNORE INTO room_floor (room_id,holder,next_speaker)
148
- SELECT rooms.id,CASE WHEN turns.id IS NULL THEN COALESCE(settings.host,'用户') END,
149
- COALESCE(turns.expert,settings.host,'用户')
150
- FROM rooms LEFT JOIN topic_settings AS settings ON settings.room_id=rooms.id
151
- LEFT JOIN discussion_turns AS turns ON turns.room_id=rooms.id AND turns.status IN ('pending','notified');
152
- CREATE TRIGGER IF NOT EXISTS initialize_room_floor AFTER INSERT ON topic_settings
153
- BEGIN
154
- INSERT INTO room_floor (room_id,holder,next_speaker) VALUES (NEW.room_id,NEW.host,NEW.host)
155
- ON CONFLICT(room_id) DO UPDATE SET
156
- holder=CASE WHEN holder='用户' THEN NEW.host ELSE holder END,
157
- next_speaker=CASE WHEN next_speaker='用户' THEN NEW.host ELSE next_speaker END,
158
- revision=revision+1;
159
- END;
160
- CREATE TRIGGER IF NOT EXISTS transfer_host_floor AFTER UPDATE OF host ON topic_settings
161
- WHEN OLD.host<>NEW.host
162
- BEGIN
163
- UPDATE room_floor SET holder=CASE WHEN holder=OLD.host THEN NEW.host ELSE holder END,
164
- next_speaker=CASE WHEN next_speaker=OLD.host THEN NEW.host ELSE next_speaker END,
165
- revision=revision+1 WHERE room_id=NEW.room_id
166
- AND NOT EXISTS (SELECT 1 FROM discussion_turns WHERE room_id=NEW.room_id AND status IN ('pending','notified') AND expert=OLD.host);
167
- END;
147
+ -- The speaking floor is retired: the table stays only so old databases and backups load, and nothing reads or writes it.
148
+ DROP TRIGGER IF EXISTS initialize_room_floor;
149
+ DROP TRIGGER IF EXISTS transfer_host_floor;
168
150
  -- Site administrator account (single user) and its login sessions. Raw tokens and passwords are never stored.
169
151
  CREATE TABLE IF NOT EXISTS site_admin (
170
152
  id INTEGER PRIMARY KEY CHECK(id=1),
@@ -186,16 +168,12 @@ CREATE TABLE IF NOT EXISTS room_modes (
186
168
  mode TEXT NOT NULL CHECK(mode IN ('free','brainstorm')),
187
169
  control_hash TEXT NOT NULL DEFAULT ''
188
170
  );
189
- -- Replaced on every start so existing databases drop the floor rule for free rooms.
171
+ -- Replaced on every start so existing databases drop every speaking-floor rule.
190
172
  DROP TRIGGER IF EXISTS require_message_floor;
191
- CREATE TRIGGER require_message_floor BEFORE INSERT ON messages
192
- WHEN NEW.author<>'用户'
193
- AND NOT EXISTS (SELECT 1 FROM messages WHERE room_id=NEW.room_id AND submission_id=NEW.submission_id)
194
- AND NOT EXISTS (SELECT 1 FROM room_floor WHERE room_id=NEW.room_id AND holder=NEW.author)
195
- AND NOT EXISTS (SELECT 1 FROM room_modes WHERE room_id=NEW.room_id AND mode='free')
196
- BEGIN
197
- SELECT RAISE(ABORT, 'floor_required');
198
- END;
173
+ DROP TRIGGER IF EXISTS return_message_floor;
174
+ DROP TRIGGER IF EXISTS require_host_floor;
175
+ DROP TRIGGER IF EXISTS nominate_expert_floor;
176
+ DROP TRIGGER IF EXISTS cancel_turn_floor;
199
177
  CREATE TRIGGER IF NOT EXISTS require_opinion_turn BEFORE INSERT ON message_details
200
178
  WHEN NEW.kind='opinion' AND NEW.turn_id IS NULL
201
179
  AND NOT EXISTS (SELECT 1 FROM message_details WHERE message_id=NEW.message_id)
@@ -203,35 +181,6 @@ WHEN NEW.kind='opinion' AND NEW.turn_id IS NULL
203
181
  BEGIN
204
182
  SELECT RAISE(ABORT, 'floor_turn_required');
205
183
  END;
206
- CREATE TRIGGER IF NOT EXISTS return_message_floor AFTER INSERT ON message_details
207
- WHEN EXISTS (SELECT 1 FROM messages WHERE id=NEW.message_id AND author<>'用户')
208
- BEGIN
209
- UPDATE room_floor SET holder=NULL,
210
- next_speaker=COALESCE((SELECT host FROM topic_settings WHERE room_id=room_floor.room_id),'用户'),
211
- revision=revision+1 WHERE room_id=(SELECT room_id FROM messages WHERE id=NEW.message_id);
212
- END;
213
- DROP TRIGGER IF EXISTS require_host_floor;
214
- -- Queued turns of a round plan are inserted after the first one has already nominated an expert.
215
- CREATE TRIGGER require_host_floor BEFORE INSERT ON discussion_turns
216
- WHEN NEW.status<>'queued' AND NOT EXISTS (SELECT 1 FROM discussion_turns WHERE room_id=NEW.room_id AND submission_id=NEW.submission_id)
217
- AND NOT EXISTS (SELECT 1 FROM room_floor WHERE room_id=NEW.room_id
218
- AND holder=COALESCE((SELECT host FROM topic_settings WHERE room_id=NEW.room_id),'用户'))
219
- BEGIN
220
- SELECT RAISE(ABORT, 'floor_required');
221
- END;
222
- DROP TRIGGER IF EXISTS nominate_expert_floor;
223
- CREATE TRIGGER nominate_expert_floor AFTER INSERT ON discussion_turns
224
- WHEN NEW.status<>'queued'
225
- BEGIN
226
- UPDATE room_floor SET holder=NULL,next_speaker=NEW.expert,revision=revision+1 WHERE room_id=NEW.room_id;
227
- END;
228
- CREATE TRIGGER IF NOT EXISTS cancel_turn_floor AFTER UPDATE OF status ON discussion_turns
229
- WHEN NEW.status='cancelled' AND OLD.status<>NEW.status
230
- BEGIN
231
- UPDATE room_floor SET holder=NULL,
232
- next_speaker=COALESCE((SELECT host FROM topic_settings WHERE room_id=NEW.room_id),'用户'),
233
- revision=revision+1 WHERE room_id=NEW.room_id;
234
- END;
235
184
 
236
185
  -- Runtime control is opt-in. Legacy rooms have no runtime row and keep manual entry.
237
186
  CREATE TABLE IF NOT EXISTS discussion_limits (
@@ -369,7 +318,6 @@ DROP TRIGGER IF EXISTS relay_turn_promoted;
369
318
  CREATE TRIGGER relay_turn_promoted AFTER UPDATE OF status ON discussion_turns
370
319
  WHEN OLD.status='queued' AND NEW.status='pending'
371
320
  BEGIN
372
- UPDATE room_floor SET holder=NULL,next_speaker=NEW.expert,revision=revision+1 WHERE room_id=NEW.room_id;
373
321
  UPDATE turn_relay SET held_since=0 WHERE room_id=NEW.room_id
374
322
  AND NOT EXISTS(SELECT 1 FROM messages m JOIN discussion_events e ON e.room_id=m.room_id AND e.role='host' AND e.status='started'
375
323
  WHERE m.room_id=NEW.room_id AND m.author='用户' AND CAST(strftime('%s',m.created_at) AS INTEGER)>e.started_at);
@@ -679,3 +627,47 @@ CREATE TABLE IF NOT EXISTS room_links (
679
627
  parent_id TEXT NOT NULL,
680
628
  created_at TEXT NOT NULL
681
629
  );
630
+
631
+ -- A discussion is a switch on a room, not a room type: one thin session per started discussion. Its status mirrors the legacy
632
+ -- topic phase until the phase machinery is retired. Free rooms have none.
633
+ CREATE TABLE IF NOT EXISTS discussion_sessions (
634
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
635
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
636
+ goal TEXT NOT NULL,
637
+ status TEXT NOT NULL DEFAULT 'paused' CHECK(status IN ('active','paused','concluded')),
638
+ started_message_id INTEGER NOT NULL DEFAULT 0,
639
+ ended_message_id INTEGER NOT NULL DEFAULT 0,
640
+ created_at TEXT NOT NULL
641
+ );
642
+ CREATE UNIQUE INDEX IF NOT EXISTS one_open_session ON discussion_sessions(room_id) WHERE status IN ('active','paused');
643
+ -- Rooms that predate sessions: every non-free room with topic settings gets one, mapped from its phase. Safe to repeat.
644
+ INSERT INTO discussion_sessions (room_id,goal,status,started_message_id,ended_message_id,created_at)
645
+ SELECT rooms.id,rooms.goal,
646
+ CASE WHEN topic_settings.phase='concluded' THEN 'concluded' WHEN topic_settings.phase IN ('preparing','paused') THEN 'paused' ELSE 'active' END,
647
+ 0,CASE WHEN topic_settings.phase='concluded' THEN COALESCE((SELECT MAX(id) FROM messages WHERE room_id=rooms.id),0) ELSE 0 END,rooms.created_at
648
+ FROM rooms JOIN topic_settings ON topic_settings.room_id=rooms.id
649
+ WHERE NOT EXISTS (SELECT 1 FROM room_modes WHERE room_id=rooms.id AND mode='free')
650
+ AND NOT EXISTS (SELECT 1 FROM discussion_sessions WHERE room_id=rooms.id);
651
+ DROP TRIGGER IF EXISTS session_for_new_discussion;
652
+ CREATE TRIGGER session_for_new_discussion AFTER INSERT ON room_modes
653
+ WHEN NEW.mode='brainstorm' AND NOT EXISTS (SELECT 1 FROM discussion_sessions WHERE room_id=NEW.room_id)
654
+ BEGIN
655
+ INSERT INTO discussion_sessions (room_id,goal,status,created_at)
656
+ SELECT rooms.id,rooms.goal,
657
+ COALESCE((SELECT CASE WHEN phase='concluded' THEN 'concluded' WHEN phase IN ('preparing','paused') THEN 'paused' ELSE 'active' END
658
+ FROM topic_settings WHERE room_id=rooms.id),'paused'),rooms.created_at
659
+ FROM rooms WHERE rooms.id=NEW.room_id;
660
+ END;
661
+ DROP TRIGGER IF EXISTS session_follows_phase_insert;
662
+ CREATE TRIGGER session_follows_phase_insert AFTER INSERT ON topic_settings
663
+ BEGIN
664
+ UPDATE discussion_sessions SET status=CASE WHEN NEW.phase='concluded' THEN 'concluded' WHEN NEW.phase IN ('preparing','paused') THEN 'paused' ELSE 'active' END
665
+ WHERE id=(SELECT MAX(id) FROM discussion_sessions WHERE room_id=NEW.room_id);
666
+ END;
667
+ DROP TRIGGER IF EXISTS session_follows_phase;
668
+ CREATE TRIGGER session_follows_phase AFTER UPDATE OF phase ON topic_settings
669
+ BEGIN
670
+ UPDATE discussion_sessions SET status=CASE WHEN NEW.phase='concluded' THEN 'concluded' WHEN NEW.phase IN ('preparing','paused') THEN 'paused' ELSE 'active' END,
671
+ ended_message_id=CASE WHEN NEW.phase='concluded' THEN COALESCE((SELECT MAX(id) FROM messages WHERE room_id=NEW.room_id),0) ELSE 0 END
672
+ WHERE id=(SELECT MAX(id) FROM discussion_sessions WHERE room_id=NEW.room_id);
673
+ END;