ai_co_discuss 0.2.47 → 0.2.49

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.
@@ -553,6 +553,39 @@ def after_free_message(connection, room_id, message_id, actor, payload):
553
553
  settle_batches(connection, room_id)
554
554
 
555
555
 
556
+ def discussion_host_handles(connection, room):
557
+ """A running discussion already wakes its host for every user message, so a question to the host needs no request of its own."""
558
+ return room["phase"] not in ("paused", "concluded") and bool(connection.execute(
559
+ "SELECT 1 FROM discussion_runtime WHERE room_id=? AND status='running'", (room["id"],)).fetchone())
560
+
561
+
562
+ def checked_asks(connection, room, payload):
563
+ """Validate the user's direct questions to members of a discussion before the message is stored."""
564
+ names = payload.get("to")
565
+ if names is None:
566
+ return []
567
+ return named_members(active_members(connection, room["id"]), names)
568
+
569
+
570
+ def ask_members(connection, room, message_id, member_ids):
571
+ """Queue one 'ask' request per named member, outside the discussion's turns, rounds and limits."""
572
+ host_busy = discussion_host_handles(connection, room)
573
+ for member_id in member_ids:
574
+ row = connection.execute("SELECT name FROM room_members WHERE id=?", (member_id,)).fetchone()
575
+ if host_busy and row["name"] == room["host"]:
576
+ continue
577
+ insert_request(connection, room["id"], member_id, message_id, message_id, "ask", "user")
578
+
579
+
580
+ def asks_view(connection, room_id):
581
+ """Direct questions of a discussion room: recent requests with their member, for status badges beside messages."""
582
+ names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
583
+ requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
584
+ """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by FROM member_requests
585
+ WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
586
+ return {"requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id)}
587
+
588
+
556
589
  def expire_executions(connection, room_id):
557
590
  """Fail executions whose lease lapsed; their requests stay visible as failed, never silently retried."""
558
591
  now = int(time.time())
@@ -589,15 +622,16 @@ def inbox(connection, room_id, token):
589
622
  expire_executions(connection, room_id)
590
623
  release_recommendations(connection, room_id)
591
624
  archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
592
- members = []
593
- for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status='active' AND access='runner' ORDER BY id", (room_id,)):
625
+ members, observers = [], []
626
+ # Manual and online members are never delivered to; the runner only reads them so smart scheduling can route to them too.
627
+ for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status='active' ORDER BY id", (room_id,)):
594
628
  pending = [dict(item) for item in connection.execute(
595
629
  "SELECT id,message_id,purpose FROM member_requests WHERE member_id=? AND status='pending' ORDER BY id", (row["id"],))]
596
- members.append({**{key: row[key] for key in MEMBER_FIELDS}, "pending": pending})
630
+ (members if row["access"] == "runner" else observers).append({**{key: row[key] for key in MEMBER_FIELDS}, "pending": pending})
597
631
  executions = [{**public_execution(row), "member": row["name"]} for row in connection.execute(
598
632
  """SELECT member_executions.*,room_members.name FROM member_executions JOIN room_members ON room_members.id=member_id
599
633
  WHERE member_executions.room_id=? AND member_executions.status IN ('dispatched','started') ORDER BY member_executions.id""", (room_id,))]
600
- return {"room_id": room_id, "archived": bool(archived), "members": members, "executions": executions,
634
+ return {"room_id": room_id, "archived": bool(archived), "members": members, "observers": observers, "executions": executions,
601
635
  "execution_minutes": execution_minutes(connection, room_id)}
602
636
 
603
637
 
@@ -618,9 +652,9 @@ def recommend_member(connection, room_id, token, payload):
618
652
  if not route or route["room_id"] != room_id:
619
653
  raise LookupError("只能为交给主持人的未点名消息给出推荐")
620
654
  target = connection.execute("""SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'
621
- AND access='runner' AND id<>?""", (room_id, payload.get("member_id"), route["member_id"])).fetchone()
655
+ AND id<>?""", (room_id, payload.get("member_id"), route["member_id"])).fetchone()
622
656
  if not target:
623
- raise LookupError("推荐对象须为在场、由本机唤醒的其他成员")
657
+ raise LookupError("推荐对象须为在场的其他成员")
624
658
  now = int(time.time())
625
659
  connection.execute("""INSERT OR IGNORE INTO member_recommendations (message_id,room_id,member_id,provider,confidence,created_at,expires_at)
626
660
  VALUES (?,?,?,?,?,?,?)""", (message_id, room_id, target["id"], payload["provider"], float(confidence), now, now + RECOMMEND_SECONDS))
@@ -693,6 +727,9 @@ def reserve_execution(connection, room_id, token, payload):
693
727
  if connection.execute("SELECT 1 FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
694
728
  (member["id"],)).fetchone():
695
729
  raise FileExistsError("该成员已有进行中的执行;同一会话不并发续接")
730
+ if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND participant=? AND status IN ('dispatched','started')",
731
+ (room_id, member["name"])).fetchone():
732
+ raise FileExistsError("该成员正在处理讨论安排;同一会话不并发续接")
696
733
  if not connection.execute("SELECT 1 FROM member_requests WHERE member_id=? AND status='pending'", (member["id"],)).fetchone():
697
734
  raise FileExistsError("该成员没有待投递的请求")
698
735
  now = int(time.time())
@@ -812,45 +849,8 @@ def save_discussion_experts(connection, room_id, experts):
812
849
  connection.execute("UPDATE topic_settings SET experts=? WHERE room_id=?", (json.dumps(experts, ensure_ascii=False), room_id))
813
850
 
814
851
 
815
- def current_round_speakers(connection, room_id, experts):
816
- """Experts who have given an opinion in the latest unfinished round (empty when it just completed)."""
817
- spoken = set()
818
- rows = connection.execute("""SELECT messages.author FROM messages
819
- JOIN message_details AS details ON details.message_id=messages.id AND details.kind='opinion'
820
- WHERE messages.room_id=? ORDER BY messages.id""", (room_id,))
821
- for row in rows:
822
- if row["author"] not in experts:
823
- continue
824
- spoken.add(row["author"])
825
- if len(spoken) == len(experts):
826
- spoken = set()
827
- return spoken
828
-
829
-
830
- def place_invited_expert(connection, room_id, name):
831
- """Move the just-queued newcomer to where the current round completes: after the last queued expert who
832
- has not spoken this round, before the first one who would speak again. Rows keep their ids; only who and what shift."""
833
- rows = connection.execute("SELECT id,expert,question FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id", (room_id,)).fetchall()
834
- if len(rows) < 2 or rows[-1]["expert"] != name:
835
- return
836
- _, experts, _ = discussion_roster(connection, room_id)
837
- spoken = current_round_speakers(connection, room_id, experts)
838
- open_turn = connection.execute("SELECT expert FROM discussion_turns WHERE room_id=? AND status IN ('pending','notified')", (room_id,)).fetchone()
839
- if open_turn:
840
- spoken.add(open_turn["expert"])
841
- slot = len(rows) - 1
842
- for index, row in enumerate(rows[:-1]):
843
- if row["expert"] in spoken:
844
- slot = index
845
- break
846
- spoken.add(row["expert"])
847
- ordered = rows[:slot] + [rows[-1]] + rows[slot:-1]
848
- for target, source in zip(rows, ordered):
849
- connection.execute("UPDATE discussion_turns SET expert=?,question=? WHERE id=? AND status='queued'", (source["expert"], source["question"], target["id"]))
850
-
851
-
852
852
  def queue_invited_expert(connection, room_id, name, now):
853
- """Queue a new expert in an existing live plan at its round-completing slot, without changing the current speaker or hold.
853
+ """Queue a new expert at the tail of an existing live plan, without changing the current speaker or hold.
854
854
 
855
855
  A manual expert is queued only once it has registered (the invite alone does not), and only once per invitation.
856
856
  """
@@ -865,10 +865,8 @@ def queue_invited_expert(connection, room_id, name, now):
865
865
  FROM topic_settings AS settings JOIN room_members AS member ON member.room_id=settings.room_id
866
866
  WHERE settings.room_id=? AND member.name=? AND member.status='active'
867
867
  AND settings.phase NOT IN ('paused','concluded')
868
- AND NOT EXISTS (SELECT 1 FROM discussion_limits WHERE room_id=settings.room_id AND closing_at>0)
869
868
  AND EXISTS (SELECT 1 FROM discussion_turns WHERE room_id=settings.room_id AND status IN ('pending','notified','queued'))""",
870
869
  (now, room_id, name))
871
- place_invited_expert(connection, room_id, name)
872
870
 
873
871
 
874
872
  def add_discussion_member(connection, room_id, token, payload, now):
@@ -899,15 +897,11 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
899
897
  """Owner sends an expert away (keeping its seat), brings it back with a new credential, or removes it.
900
898
 
901
899
  Leaving cancels the expert's open assignments; the host keeps its role and cannot leave, and an
902
- expert with an open assignment can only leave while the discussion is paused. While paused,
903
- `redo` voids the expert's latest opinion by deleting it so the expert counts as not yet having spoken.
900
+ expert with an open assignment can only leave while the discussion is paused.
904
901
  """
905
902
  member = owned_member(connection, room_id, member_id, token)
906
903
  host, experts, phase = discussion_roster(connection, room_id)
907
904
  action = payload.get("action")
908
- if action == "redo":
909
- void_latest_opinion(connection, room_id, member["name"], phase, payload.get("latest_id"))
910
- return
911
905
  if action in ("away", "remove"):
912
906
  if member["name"] == host:
913
907
  raise ValueError("主持人不能离开或移除;请先更换主持人")
@@ -934,7 +928,7 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
934
928
  adopt_manual_member(connection, room_id, member, payload)
935
929
  return
936
930
  else:
937
- raise ValueError("action 须为 away、active、remove、redo 或 adopt")
931
+ raise ValueError("action 须为 away、active、remove 或 adopt")
938
932
  save_discussion_experts(connection, room_id, experts)
939
933
 
940
934
 
@@ -955,30 +949,6 @@ def adopt_manual_member(connection, room_id, member, payload):
955
949
  connection.execute("DELETE FROM member_sessions WHERE member_id=?", (member["id"],))
956
950
 
957
951
 
958
- def void_latest_opinion(connection, room_id, name, phase, seen_latest_id):
959
- """Delete an expert's latest opinion while paused; its assignment is cancelled and the round no longer counts it.
960
-
961
- The caller states the latest message id it saw, so a stale page cannot void an opinion that is no longer the one on screen.
962
- """
963
- if phase != "paused":
964
- raise ValueError("请先暂停讨论,再作废专家的发言")
965
- if seen_latest_id is None:
966
- raise ValueError("作废发言须带上所见的最新消息编号 latest_id")
967
- if seen_latest_id != connection.execute("SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=?", (room_id,)).fetchone()[0]:
968
- raise FileExistsError("房间已有新内容,请刷新后再作废")
969
- row = connection.execute("""SELECT messages.id,details.turn_id FROM messages JOIN message_details AS details ON details.message_id=messages.id
970
- WHERE messages.room_id=? AND messages.author=? AND details.kind='opinion' ORDER BY messages.id DESC LIMIT 1""",
971
- (room_id, name)).fetchone()
972
- if not row:
973
- raise LookupError(f"{name} 还没有可作废的发言")
974
- message_id = row["id"]
975
- connection.execute("UPDATE discussion_turns SET status='cancelled',answer_id=NULL WHERE answer_id=?", (message_id,))
976
- connection.execute("UPDATE messages SET reply_to=NULL WHERE reply_to=?", (message_id,))
977
- for table in ("message_timings", "message_summaries", "message_details"):
978
- connection.execute(f"DELETE FROM {table} WHERE message_id=?", (message_id,))
979
- connection.execute("DELETE FROM messages WHERE id=?", (message_id,))
980
-
981
-
982
952
  def require_fresh_member_token(connection, room_id, token):
983
953
  """Prevent owner or another member's credential from being reused for a new identity."""
984
954
  hashed = token_hash(token)
@@ -115,7 +115,7 @@ def history_statements(tables, room_id, maps):
115
115
 
116
116
 
117
117
  def settings_statements(tables, room, maps, now):
118
- """Topic, floor, limits and idle clock; an active brainstorm arrives paused because it has no run."""
118
+ """Topic, limits and idle clock (a backup's retired speaking floor is ignored); an active brainstorm arrives paused because it has no run."""
119
119
  room_id, messages, statements = room["id"], maps["messages"], []
120
120
  free = any(row.get("mode") == "free" for row in tables.get("room_modes", []))
121
121
  for row in tables.get("topic_settings", [])[:1]:
@@ -125,10 +125,6 @@ def settings_statements(tables, room, maps, now):
125
125
  row["experts"], phase, remap_text(row.get("focus"), messages))),
126
126
  ("INSERT INTO discussion_pauses (room_id,paused_at) VALUES (?,?)",
127
127
  (room_id, 0 if phase in ACTIVE_PHASES else now))]
128
- for row in tables.get("room_floor", [])[:1]:
129
- statements.append(("""INSERT INTO room_floor (room_id,holder,next_speaker,revision) VALUES (?,?,?,?) ON CONFLICT(room_id)
130
- DO UPDATE SET holder=excluded.holder,next_speaker=excluded.next_speaker,revision=excluded.revision""",
131
- (room_id, row.get("holder"), row["next_speaker"], row.get("revision", 0))))
132
128
  for row in tables.get("discussion_limits", [])[:1]:
133
129
  statements.append((f"UPDATE discussion_limits SET {','.join(f'{field}=?' for field in LIMIT_FIELDS)} WHERE room_id=?",
134
130
  (*(row[field] for field in LIMIT_FIELDS), room_id)))
@@ -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;