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.
package/backend/server.py CHANGED
@@ -25,12 +25,12 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
25
25
  inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
26
26
  discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
27
27
  from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
28
- from .free_room import adopt_recommendation, recommend_member, register_member
28
+ from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member
29
29
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
30
30
  from .room_import import import_room, imported_attachment
31
31
  from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
32
32
  LIMIT_DEFAULTS, LIMITS_SQL, limit_values, limits_view, require_within_limits,
33
- list_events, require_control, runtime_view, token_hash)
33
+ list_events, require_control, require_owner, runtime_view, token_hash)
34
34
 
35
35
  ROOT = Path(__file__).resolve().parent.parent
36
36
  WEB_ROOT = ROOT / "web"
@@ -83,7 +83,7 @@ ROOM_BACKUP_QUERIES = {
83
83
  "rooms": "SELECT * FROM rooms WHERE id=?",
84
84
  **{table: f"SELECT * FROM {table} WHERE room_id=?" for table in (
85
85
  "room_directory", "room_folder_assignments", "room_creations", "room_briefs", "room_user_requests",
86
- "topic_settings", "room_floor", "discussion_limits", "discussion_options", "round_budgets")},
86
+ "topic_settings", "discussion_limits", "discussion_options", "round_budgets")},
87
87
  "scheduler_decisions": "SELECT * FROM scheduler_decisions WHERE room_id=? ORDER BY id",
88
88
  "topic_folders": "SELECT * FROM topic_folders WHERE id IN (SELECT folder_id FROM room_folder_assignments WHERE room_id=?)",
89
89
  "messages": "SELECT * FROM messages WHERE room_id=? ORDER BY id",
@@ -156,15 +156,12 @@ def seed_demo(connection):
156
156
  ("Qwen", "无联网时,可以由用户传入本轮材料。我仍可提出检索请求,例如:请提供 #2 的完整原文。", 2),
157
157
  ]
158
158
  base_time = datetime.now(timezone.utc) - timedelta(minutes=len(samples) * 3)
159
- connection.execute("INSERT INTO room_floor (room_id,holder,next_speaker) VALUES ('welcome','用户','用户')")
160
159
  for index, (author, text, reply_to) in enumerate(samples):
161
- connection.execute("UPDATE room_floor SET holder=? WHERE room_id='welcome'", (author,))
162
160
  created_at = (base_time + timedelta(minutes=index * 3)).isoformat(timespec="seconds")
163
161
  connection.execute("INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at) VALUES (?,?,?,?,?,?,?)",
164
162
  ("welcome", author, text, reply_to, "demo", f"demo-{index}", created_at))
165
163
  summary = "已达成共识\n• 完整历史只追加,支持按编号查阅和关键词检索。〔#2、#3〕\n• 本地 CLI AI 读写记录;在线 AI 只读,观点由用户代发。〔#4、#5〕\n\n待验证\n• 在线 AI 是否能直接读取和检索原文。〔#4〕\n• 复制材料与代发流程是否清楚。〔#5、#6〕"
166
164
  connection.execute("UPDATE rooms SET state_text=?,state_until=6 WHERE id='welcome'", (summary,))
167
- connection.execute("UPDATE room_floor SET holder='用户' WHERE room_id='welcome'")
168
165
 
169
166
 
170
167
  def init_database(database, seed=True):
@@ -205,7 +202,6 @@ def require_room(connection, room_id):
205
202
  "SELECT id,expert,question FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id", (room_id,))]
206
203
  held = connection.execute("SELECT held_since FROM turn_relay WHERE room_id=?", (room_id,)).fetchone()
207
204
  room["held_since"] = held[0] if held else 0
208
- room["latest_receipts"] = latest_receipts(connection, room_id)
209
205
  brief = connection.execute("SELECT text,until,updated_at FROM room_briefs WHERE room_id=?", (room_id,)).fetchone()
210
206
  room["host_brief"] = dict(brief) if brief else None
211
207
  room["rounds"] = discussion_rounds(connection, room_id, room["experts"])
@@ -215,10 +211,11 @@ def require_room(connection, room_id):
215
211
  if budget:
216
212
  room["opinion_count"] = opinion_count(connection, room_id, room["experts"])
217
213
  room["round_groups"] = budget_round_groups(connection, room_id, room["experts"], budget["base"]) if budget else room["rounds"]
218
- room["floor"] = dict(connection.execute("SELECT holder,next_speaker,revision FROM room_floor WHERE room_id=?", (room_id,)).fetchone())
219
214
  room["runtime"] = runtime_view(connection, room_id)
220
215
  room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
221
216
  room["mode"] = room_mode(connection, room_id)
217
+ session = connection.execute("SELECT id,goal,status,started_message_id,ended_message_id,created_at FROM discussion_sessions WHERE room_id=? ORDER BY id DESC LIMIT 1", (room_id,)).fetchone()
218
+ room["session"] = dict(session) if session else None
222
219
  room["members"] = [dict(row) for row in connection.execute(
223
220
  """SELECT m.id,m.name,m.adapter,m.role,m.status,m.access,m.session_mode,COALESCE(s.session_id,'') AS reported_session,COALESCE(s.client,'') AS reported_client,
224
221
  COALESCE(r.registered_at,'') AS registered_at
@@ -238,6 +235,8 @@ def require_room(connection, room_id):
238
235
  parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
239
236
  WHERE room_links.room_id=?""", (room_id,)).fetchone()
240
237
  room["parent_room"] = dict(parent) if parent else None
238
+ if room["mode"] != "free":
239
+ room["asks"] = asks_view(connection, room_id)
241
240
  if room["mode"] == "free":
242
241
  room["linked_rooms"] = [dict(row) for row in connection.execute("""SELECT rooms.id,rooms.title,COALESCE(topic_settings.phase,'preparing') AS phase
243
242
  FROM room_links JOIN rooms ON rooms.id=room_links.room_id LEFT JOIN topic_settings ON topic_settings.room_id=rooms.id
@@ -248,39 +247,6 @@ def require_room(connection, room_id):
248
247
  return room
249
248
 
250
249
 
251
- def update_floor(connection, room_id, payload):
252
- """Claim or return the nominated speaker's floor using a checked revision."""
253
- begin_write(connection)
254
- room = require_room(connection, room_id)
255
- actor = payload.get("actor")
256
- speaker = payload.get("speaker", actor)
257
- action = payload.get("action")
258
- revision = payload.get("revision")
259
- proxy = actor == room["host"] and room["experts"].get(speaker) == "online"
260
- if not is_participant(speaker) or (actor not in (speaker, "用户") and not proxy):
261
- raise ValueError("请以本人、主持人或用户身份操作被点名者的发言权")
262
- if action not in ("take", "return") or type(revision) is not int or revision < 0:
263
- raise ValueError("请提供 take/return 和最新的发言权 revision")
264
- floor = room["floor"]
265
- if revision != floor["revision"]:
266
- raise FileExistsError("发言权已变化,请重新读取")
267
- if action == "take":
268
- if floor["holder"] is not None or floor["next_speaker"] != speaker:
269
- raise FileExistsError("仅被点名的下一位可在无人持有时领取发言权")
270
- connection.execute("UPDATE room_floor SET holder=?,revision=revision+1 WHERE room_id=?", (speaker, room_id))
271
- else:
272
- if floor["holder"] != speaker:
273
- raise FileExistsError("只能归还自己持有的发言权")
274
- connection.execute("UPDATE room_floor SET holder=NULL,next_speaker=?,revision=revision+1 WHERE room_id=?", (room["host"], room_id))
275
- return require_room(connection, room_id)["floor"]
276
-
277
-
278
- def require_floor(room, speaker):
279
- """Reject a declared speaker who does not currently hold the floor."""
280
- if room["floor"]["holder"] != speaker:
281
- raise FileExistsError("请先领取本人发言权;他人持有或尚未被点名时不能发言或安排")
282
-
283
-
284
250
  def host_arrangements(connection, room_id, decisions):
285
251
  """Each plan the host created (turns saved in one batch), anchored after the latest answer before it.
286
252
 
@@ -381,17 +347,6 @@ def opinion_count(connection, room_id, experts):
381
347
  WHERE room_id=? AND COALESCE(details.kind,'opinion')='opinion' AND author IN ({marks})""", (room_id, *experts)).fetchone()[0]
382
348
 
383
349
 
384
- def latest_receipts(connection, room_id):
385
- """Return each author's latest receipt reference without loading its body."""
386
- rows = connection.execute("""SELECT messages.id,messages.author,messages.created_at,details.submitted_by
387
- FROM messages JOIN message_details AS details ON details.message_id=messages.id
388
- WHERE messages.id IN (SELECT MAX(messages.id) FROM messages
389
- JOIN message_details AS details ON details.message_id=messages.id
390
- WHERE room_id=? AND details.kind='receipt' GROUP BY author)
391
- ORDER BY messages.id""", (room_id,))
392
- return [dict(row) for row in rows]
393
-
394
-
395
350
  def turn_status_label(turn, room):
396
351
  """Describe pending work using the expert's configured participation mode."""
397
352
  if turn["status"] == "pending":
@@ -400,15 +355,6 @@ def turn_status_label(turn, room):
400
355
  return TURN_STATUSES[turn["status"]]
401
356
 
402
357
 
403
- def receipt_references_text(room):
404
- """Link to current receipt reports independently of an older host summary."""
405
- lines = ["最新运行回执(每位参与者最近一条,运行结果以原文为准):"]
406
- for receipt in room["latest_receipts"]:
407
- suffix = " · 晚于摘要" if receipt["id"] > room["state_until"] else ""
408
- lines.append(f"{receipt['author']} · {receipt['created_at']}{suffix}:/r/{room['id']}/messages/{receipt['id']}")
409
- return "\n".join(lines) if room["latest_receipts"] else "尚无运行回执。"
410
-
411
-
412
358
  def topic_context(background, user_request=None):
413
359
  """Separate explicitly marked legacy requests without guessing unmarked text."""
414
360
  if user_request is None:
@@ -451,18 +397,17 @@ def topic_values(payload, current=None):
451
397
  if changed and current["phase"] not in ("preparing", "paused"):
452
398
  raise ValueError("请先暂停讨论,再修改上限参数")
453
399
  result["save_limits"] = not current or changed
454
- result["reset_closing"] = not current or any(result["limits"][key] != current["limits"][key]
455
- for key in LIMIT_DEFAULTS if key != "execution_minutes")
400
+ result["reset_stop"] = not current or any(result["limits"][key] != current["limits"][key]
401
+ for key in LIMIT_DEFAULTS if key != "execution_minutes")
456
402
  return result
457
403
 
458
404
 
459
405
  def save_topic_settings(connection, room_id, settings):
460
406
  """Persist topic settings without modifying any original messages."""
461
407
  if settings.get("save_limits"):
462
- connection.execute("""UPDATE discussion_limits SET max_rounds=?,max_minutes=?,execution_minutes=?,on_limit=?,summary_minutes=?,
463
- closing_at=CASE WHEN ? THEN 0 ELSE closing_at END,stop_reason=CASE WHEN ? THEN '' ELSE stop_reason END,
464
- revision=revision+1 WHERE room_id=?""",
465
- [*(settings["limits"][key] for key in LIMIT_DEFAULTS), settings["reset_closing"], settings["reset_closing"], room_id])
408
+ connection.execute("""UPDATE discussion_limits SET max_rounds=?,max_minutes=?,execution_minutes=?,
409
+ stop_reason=CASE WHEN ? THEN '' ELSE stop_reason END,revision=revision+1 WHERE room_id=?""",
410
+ [*(settings["limits"][key] for key in LIMIT_DEFAULTS), settings["reset_stop"], room_id])
466
411
  values = [settings[key] for key in ("host", "background", "boundaries")]
467
412
  values.extend([json.dumps(settings["experts"], ensure_ascii=False), settings["phase"], settings["focus"]])
468
413
  connection.execute("INSERT INTO topic_settings (room_id,host,background,boundaries,experts,phase,focus) VALUES (?,?,?,?,?,?,?) ON CONFLICT(room_id) DO UPDATE SET host=excluded.host,background=excluded.background,boundaries=excluded.boundaries,experts=excluded.experts,phase=excluded.phase,focus=excluded.focus", [room_id, *values])
@@ -532,10 +477,6 @@ def update_topic(connection, room_id, payload):
532
477
  require_conclusion_ready(connection, room, payload)
533
478
  if room["phase"] == "concluded" and settings["phase"] != "concluded" and payload.get("actor") != "用户":
534
479
  raise ValueError("已收束的讨论只有用户能重新开启")
535
- limits = room["limits"]
536
- if settings["phase"] == "concluded" and room["phase"] != "concluded" and payload.get("actor") != "用户" \
537
- and limits["on_limit"] == "pause" and not limits["closing"] and (room["round_budget"] or limits["reached"]):
538
- raise ValueError("到限方式为暂停保留现场,主持人不得直接收束;目标已完成请执行 room limits --done,由用户决定追加轮次或总结")
539
480
  running = connection.execute("SELECT 1 FROM discussion_runtime WHERE room_id=? AND status='running'", (room_id,)).fetchone()
540
481
  if running and settings["experts"] != room["experts"]:
541
482
  raise ValueError("讨论运行中请在进度看板邀请、暂离或移除专家")
@@ -684,7 +625,7 @@ def manage_room(connection, room_id, action, payload):
684
625
  def delete_room_records(connection, room_id):
685
626
  """Remove a room's dependent records in the caller's transaction."""
686
627
  for table in ("member_recommendations", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
687
- "discussion_options", "round_budgets", "scheduler_decisions"):
628
+ "discussion_options", "round_budgets", "scheduler_decisions", "discussion_sessions"):
688
629
  if table == "scheduler_decisions":
689
630
  connection.execute("DELETE FROM scheduler_decision_details WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
690
631
  connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
@@ -776,6 +717,64 @@ def create_room(connection, payload, control_token=""):
776
717
  return {**require_room(connection, room_id), "duplicate": False, "submission_id": identifier}
777
718
 
778
719
 
720
+ def start_discussion(connection, room_id, token, payload):
721
+ """Turn a free room into a discussion; its AI host then pushes speakers within the limits until end_discussion.
722
+
723
+ The room, its members and history stay; only the discussion state (topic, limits, budget, session) is set up and the
724
+ run is prepared, not started: the local service starts it once it takes the room over.
725
+ """
726
+ require_owner(connection, room_id, token)
727
+ begin_write(connection)
728
+ room = require_room(connection, room_id)
729
+ if room["mode"] != "free":
730
+ raise FileExistsError("房间正在讨论中;先结束当前讨论")
731
+ if room["archived"]:
732
+ raise ValueError("房间已存档;恢复后才能开启讨论")
733
+ sync_participants(connection, room_id)
734
+ active = active_members(connection, room_id)
735
+ host = next((row for row in active.values() if row["role"] == "host"), None)
736
+ if not host or host["access"] != "runner":
737
+ raise ValueError("开启讨论需要一位由本机托管的 AI 主持人;先在成员栏指定或转为自动")
738
+ experts = {name: "online" if row["access"] == "online" else "cli" for name, row in active.items()}
739
+ values = {key: payload[key] for key in ("goal", "background", "boundaries", "focus", "user_request", "limits") if key in payload}
740
+ settings = topic_values({**values, "host": host["name"], "experts": experts, "phase": "preparing"}, {**room, "phase": "paused"})
741
+ named_followup = payload.get("named_followup", False)
742
+ if type(named_followup) is not bool:
743
+ raise ValueError("named_followup 须为布尔值")
744
+ # A fresh session first, so the phase mirror follows it and not a concluded earlier one.
745
+ if not connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND status IN ('active','paused')", (room_id,)).fetchone():
746
+ connection.execute("INSERT INTO discussion_sessions (room_id,goal,status,created_at) VALUES (?,?,'paused',?)", (room_id, settings["goal"], utc_now()))
747
+ connection.execute("UPDATE rooms SET goal=? WHERE id=?", (settings["goal"], room_id))
748
+ save_topic_settings(connection, room_id, settings)
749
+ connection.execute("UPDATE room_modes SET mode='brainstorm' WHERE room_id=?", (room_id,))
750
+ connection.execute("INSERT INTO discussion_options (room_id,named_followup) VALUES (?,?) ON CONFLICT(room_id) DO UPDATE SET named_followup=excluded.named_followup",
751
+ (room_id, int(named_followup)))
752
+ connection.execute("INSERT INTO round_budgets (room_id,base) VALUES (?,?) ON CONFLICT(room_id) DO UPDATE SET base=excluded.base,cap=0,extra=0",
753
+ (room_id, opinion_count(connection, room_id, experts)))
754
+ connection.execute("UPDATE discussion_limits SET started_at=0,stop_reason='',revision=revision+1 WHERE room_id=?", (room_id,))
755
+ connection.execute("UPDATE discussion_pauses SET paused_seconds=0,paused_at=0 WHERE room_id=?", (room_id,))
756
+ # Switching the run to automatic converts manual members that carry an adapter; a manual expert of a woken discussion is marked and stays manual.
757
+ connection.execute("UPDATE room_members SET adapter='manual' WHERE room_id=? AND access='manual' AND status='active'", (room_id,))
758
+ configure_runtime(connection, room_id, token, {"revision": runtime_view(connection, room_id)["revision"], "mode": "automatic"})
759
+ return require_room(connection, room_id)
760
+
761
+
762
+ def end_discussion(connection, room_id, token, payload):
763
+ """Conclude the discussion for good and return the room to free collaboration; history and conclusion stay."""
764
+ require_owner(connection, room_id, token)
765
+ begin_write(connection)
766
+ room = require_room(connection, room_id)
767
+ if room["mode"] == "free":
768
+ raise FileExistsError("房间没有进行中的讨论")
769
+ if room["runtime"]["status"] in ("running", "ready", "paused"):
770
+ configure_runtime(connection, room_id, token, {"action": "stop", "revision": room["runtime"]["revision"]})
771
+ connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND status IN ('pending','notified')", (room_id,))
772
+ connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
773
+ connection.execute("UPDATE topic_settings SET phase='concluded' WHERE room_id=?", (room_id,))
774
+ connection.execute("UPDATE room_modes SET mode='free' WHERE room_id=?", (room_id,))
775
+ return require_room(connection, room_id)
776
+
777
+
779
778
  def message_values(payload):
780
779
  """Validate a message and normalize its optional reply reference."""
781
780
  author = str(payload.get("author", "")).strip()
@@ -915,7 +914,7 @@ def message_details(room, payload):
915
914
  author = payload["author"]
916
915
  kind = payload.get("kind", "opinion")
917
916
  submitted_by = payload.get("submitted_by", author if payload.get("source") in ("cli", "api") else "用户")
918
- if kind not in ("opinion", "moderation", "receipt") or not is_participant(submitted_by):
917
+ if kind not in ("opinion", "moderation") or not is_participant(submitted_by):
919
918
  raise ValueError("发言职责或提交者无效")
920
919
  if submitted_by not in ("用户", room["host"], author):
921
920
  raise ValueError("只有用户或主持人可以代发其他专家的观点")
@@ -1023,34 +1022,44 @@ def record_reported_session(connection, room_id, token, payload):
1023
1022
  ON CONFLICT(member_id) DO UPDATE SET session_id=excluded.session_id,client=excluded.client""", (member["id"], room_id, session_id, client))
1024
1023
 
1025
1024
 
1025
+ def answers_direct_question(payload):
1026
+ """A discussion message that closes the user's direct question: it names an execution or the requests it answers."""
1027
+ return type(payload.get("execution_id")) is int or bool(payload.get("request_ids"))
1028
+
1029
+
1026
1030
  def append_message(connection, room_id, payload, token="", free_actor=None):
1027
1031
  """Atomically append an attributed message and complete its scheduled turn."""
1028
1032
  author, text, submission_id, reply_to = message_values(payload)
1029
1033
  begin_write(connection)
1030
1034
  room = require_room(connection, room_id)
1031
- if room["mode"] == "free":
1035
+ if room["mode"] == "free" or free_actor is not None:
1032
1036
  return append_free_message(connection, room, payload, free_actor)
1033
1037
  details = message_details(room, {**payload, "author": author})
1038
+ # An expert's opinion answers the arrangement open for that expert; naming it with turn_id is optional.
1039
+ implied_turn = author != "用户" and details["kind"] == "opinion" and not details["turn_id"]
1034
1040
  expected = {"author": author, "text": text, "reply_to": reply_to, **details}
1035
1041
  expected.update(message_summary(payload, expected))
1036
1042
  identifiers = attachment_ids(payload)
1037
1043
  previous = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND submission_id=?", (room_id, submission_id)).fetchone()
1038
1044
  if previous:
1039
1045
  previous = message_with_attachments(connection, previous)
1040
- if (any(previous[key] != expected[key] for key in ("author", "text", "reply_to", "kind", "submitted_by", "turn_id", "summary", "summary_by"))
1046
+ same_turn = implied_turn or previous["turn_id"] == expected["turn_id"]
1047
+ if (any(previous[key] != expected[key] for key in ("author", "text", "reply_to", "kind", "submitted_by", "summary", "summary_by")) or not same_turn
1041
1048
  or [item["id"] for item in previous["attachments"]] != identifiers):
1042
1049
  raise FileExistsError("这个提交编号已被另一条内容使用,请为新发言生成新编号")
1043
1050
  return {"message": previous, "duplicate": True}
1051
+ if implied_turn:
1052
+ open_turn = connection.execute("SELECT id FROM discussion_turns WHERE room_id=? AND expert=? AND status IN ('pending','notified')", (room_id, author)).fetchone()
1053
+ if not open_turn:
1054
+ raise ValueError("专家观点只能回答为你安排的发言;当前没有轮到你的有效安排")
1055
+ details["turn_id"] = expected["turn_id"] = open_turn["id"]
1044
1056
  if author != "用户" and details["kind"] == "opinion":
1045
1057
  require_within_limits(room)
1046
1058
  if room["phase"] in ("paused", "concluded") and details["kind"] == "opinion" and (author != "用户" or room["archived"]):
1047
- raise ValueError("话题已暂停或收束;恢复阶段后才能提交新观点,运行回执请用 receipt")
1059
+ raise ValueError("话题已暂停或收束;恢复阶段后才能提交新观点")
1048
1060
  check_message_references(connection, room_id, expected)
1049
1061
  check_message_attachments(connection, room_id, details["submitted_by"], identifiers)
1050
- if author != "用户":
1051
- if details["kind"] == "opinion" and not details["turn_id"]:
1052
- raise ValueError("专家观点必须关联当前有效的 turn_id")
1053
- require_floor(room, author)
1062
+ asked = checked_asks(connection, room, payload) if author == "用户" else []
1054
1063
  cursor = connection.execute("INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at) VALUES (?,?,?,?,?,?,?)",
1055
1064
  (room_id, author, text, reply_to, details["source"], submission_id, utc_now()))
1056
1065
  connection.execute("INSERT INTO message_details (message_id,kind,submitted_by,turn_id) VALUES (?,?,?,?)",
@@ -1062,6 +1071,7 @@ def append_message(connection, room_id, payload, token="", free_actor=None):
1062
1071
  (cursor.lastrowid, position, identifier))
1063
1072
  record_message_timing(connection, cursor.lastrowid, token)
1064
1073
  record_reported_session(connection, room_id, token, payload)
1074
+ ask_members(connection, room, cursor.lastrowid, asked)
1065
1075
  if details["turn_id"]:
1066
1076
  connection.execute("UPDATE discussion_turns SET status='answered',answer_id=? WHERE id=?", (cursor.lastrowid, details["turn_id"]))
1067
1077
  return {"message": get_message(connection, room_id, cursor.lastrowid), "duplicate": False}
@@ -1167,7 +1177,6 @@ def create_turn(connection, room_id, payload):
1167
1177
  raise ValueError("话题已暂停或收束,请先调整阶段")
1168
1178
  if room["current_turn"] and room["current_turn"]["status"] in ("pending", "notified"):
1169
1179
  raise FileExistsError("请先完成或取消当前发言安排")
1170
- require_floor(room, room["host"])
1171
1180
  ids = []
1172
1181
  for index, (expert, question, submission_id) in enumerate(plan):
1173
1182
  cursor = connection.execute(
@@ -1214,40 +1223,34 @@ def continue_turns(connection, room_id, payload):
1214
1223
  raise FileExistsError("没有排队中的安排;请创建新的安排")
1215
1224
  require_within_limits(room)
1216
1225
  require_registered(room, [room["queued_turns"][0]["expert"]])
1217
- require_floor(room, room["host"])
1218
1226
  turn_id = room["queued_turns"][0]["id"]
1219
1227
  connection.execute("UPDATE discussion_turns SET status='pending' WHERE id=? AND status='queued'", (turn_id,))
1220
1228
  return {"turn": get_turn(connection, room_id, turn_id), "queued": room["queued_turns"][1:]}
1221
1229
 
1222
1230
  def adjust_limits(connection, room_id, payload, action):
1223
- """User-only follow-ups after a limit: append rounds, ask for a summary, or reopen a concluded discussion."""
1231
+ """User-only follow-ups after a limit: append rounds, or reopen a concluded discussion."""
1224
1232
  begin_write(connection)
1225
1233
  room = require_room(connection, room_id)
1226
1234
  if payload.get("actor") != "用户":
1227
- raise ValueError("仅用户可以追加轮次、要求总结或重启已收束的讨论")
1235
+ raise ValueError("仅用户可以追加轮次或重启已收束的讨论")
1228
1236
  if room["archived"]:
1229
1237
  raise ValueError("请先恢复已存档房间")
1230
- budget = room["round_budget"]
1231
- if action != "summarize" and not budget:
1238
+ if not room["round_budget"]:
1232
1239
  raise ValueError("该房间沿用旧轮次计数,不支持追加轮次或重启")
1233
1240
  required_phase = "concluded" if action == "reopen" else "paused"
1234
1241
  if room["phase"] != required_phase:
1235
- raise ValueError("仅已收束的讨论可以重启" if action == "reopen" else "仅已暂停的讨论可以追加轮次或要求总结")
1236
- if action == "summarize":
1237
- connection.execute("UPDATE discussion_limits SET closing_at=?,stop_reason='user_summary',revision=revision+1 WHERE room_id=?",
1238
- (int(time.time()), room_id))
1239
- return require_room(connection, room_id)
1242
+ raise ValueError("仅已收束的讨论可以重启" if action == "reopen" else "仅已暂停的讨论可以追加轮次")
1240
1243
  rounds = payload.get("rounds") if action == "extend" else payload.get("max_rounds", room["limits"]["max_rounds"])
1241
1244
  if type(rounds) is not int or not (1 if action == "extend" else 0) <= rounds <= 100:
1242
1245
  raise ValueError("轮次须为整数(追加 1–100,重启 0–100,0 表示不限)")
1243
1246
  count = opinion_count(connection, room_id, room["experts"])
1244
1247
  if action == "extend":
1245
1248
  connection.execute("UPDATE round_budgets SET base=?,cap=?,extra=extra+? WHERE room_id=?", (count, rounds, rounds, room_id))
1246
- connection.execute("UPDATE discussion_limits SET closing_at=0,stop_reason='',revision=revision+1 WHERE room_id=?", (room_id,))
1249
+ connection.execute("UPDATE discussion_limits SET stop_reason='',revision=revision+1 WHERE room_id=?", (room_id,))
1247
1250
  return require_room(connection, room_id)
1248
1251
  limits = limit_values({"max_rounds": rounds, "max_minutes": payload.get("max_minutes", room["limits"]["max_minutes"])}, room["limits"])
1249
1252
  connection.execute("UPDATE round_budgets SET base=?,cap=0,extra=0 WHERE room_id=?", (count, room_id))
1250
- connection.execute("""UPDATE discussion_limits SET max_rounds=?,max_minutes=?,started_at=0,closing_at=0,stop_reason='',revision=revision+1
1253
+ connection.execute("""UPDATE discussion_limits SET max_rounds=?,max_minutes=?,started_at=0,stop_reason='',revision=revision+1
1251
1254
  WHERE room_id=?""", (limits["max_rounds"], limits["max_minutes"], room_id))
1252
1255
  connection.execute("UPDATE discussion_pauses SET paused_seconds=0,paused_at=0 WHERE room_id=?", (room_id,))
1253
1256
  connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
@@ -1255,42 +1258,29 @@ def adjust_limits(connection, room_id, payload, action):
1255
1258
 
1256
1259
 
1257
1260
  def check_limits(connection, room_id, payload):
1258
- """Apply a reached boundary once; the host must author the actual summary."""
1261
+ """Pause at a reached boundary (or once the host declares the goal done); the user then extends or ends the discussion."""
1259
1262
  action = payload.get("action", "check")
1260
- if action not in ("check", "done", "extend", "summarize", "reopen"):
1261
- raise ValueError("action 须为 check/done/extend/summarize/reopen")
1262
- if action in ("extend", "summarize", "reopen"):
1263
+ if action not in ("check", "done", "extend", "reopen"):
1264
+ raise ValueError("action 须为 check/done/extend/reopen")
1265
+ if action in ("extend", "reopen"):
1263
1266
  return adjust_limits(connection, room_id, payload, action)
1264
1267
  begin_write(connection)
1265
1268
  room = require_room(connection, room_id)
1266
1269
  require_host(room, payload)
1267
1270
  limits = room["limits"]
1268
1271
  done = action == "done"
1269
- force = payload.get("force") is True
1270
- if force and (room["phase"] in ("preparing", "paused", "concluded") or room["archived"]):
1271
- raise ValueError("仅进行中的讨论可以提前收尾")
1272
1272
  inactive = room["phase"] in ("paused", "concluded") or room["archived"] or (done and room["phase"] == "preparing")
1273
- if (not limits["reached"] and not force and not done) or inactive:
1273
+ if (not limits["reached"] and not done) or inactive:
1274
1274
  return room
1275
- if done or force:
1275
+ if done:
1276
1276
  require_conclusion_ready(connection, room, payload, allow_queued=True)
1277
1277
  reason = "goal_done" if done else limits["reason"] or "user_stop"
1278
- closing = (force or limits["on_limit"] == "summarize") and reason != "time_limit"
1279
- if closing and limits["closing"]:
1280
- return room
1281
- connection.execute("UPDATE discussion_limits SET closing_at=?,stop_reason=?,revision=revision+1 WHERE room_id=?",
1282
- (int(time.time()) if closing else limits["closing_at"], reason, room_id))
1278
+ connection.execute("UPDATE discussion_limits SET stop_reason=?,revision=revision+1 WHERE room_id=?", (reason, room_id))
1283
1279
  connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND status IN ('pending','notified')", (room_id,))
1284
1280
  connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
1285
- connection.execute("UPDATE room_floor SET holder=NULL,next_speaker=?,revision=revision+1 WHERE room_id=?", (room["host"], room_id))
1286
- if closing:
1287
- summary_deadline = int(time.time()) + limits["summary_minutes"] * 60
1288
- connection.execute("UPDATE discussion_runtime SET generation=generation+1,revision=revision+1,deadline=CASE WHEN deadline=0 THEN ? ELSE MIN(deadline,?) END WHERE room_id=? AND status='running'",
1289
- (summary_deadline, summary_deadline, room_id))
1290
- else:
1291
- connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
1292
- # Pausing at the limit also pauses the run, so the board offers restart and the old execution is superseded.
1293
- connection.execute("UPDATE discussion_runtime SET status='paused',generation=generation+1,revision=revision+1 WHERE room_id=? AND status='running'", (room_id,))
1281
+ connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
1282
+ # Pausing at the limit also pauses the run, so the board offers restart and the old execution is superseded.
1283
+ connection.execute("UPDATE discussion_runtime SET status='paused',generation=generation+1,revision=revision+1 WHERE room_id=? AND status='running'", (room_id,))
1294
1284
  return require_room(connection, room_id)
1295
1285
 
1296
1286
 
@@ -1309,8 +1299,6 @@ def update_turn(connection, room_id, turn_id, payload):
1309
1299
  raise ValueError("话题已暂停或收束,不能继续通知")
1310
1300
  if turn["status"] not in ("pending", "notified"):
1311
1301
  raise FileExistsError("该发言安排已结束")
1312
- if status == "cancelled" and payload.get("actor") != "用户":
1313
- require_floor(room, room["host"])
1314
1302
  notified_at = utc_now() if status == "notified" else turn["notified_at"]
1315
1303
  connection.execute("UPDATE discussion_turns SET status=?,notified_at=? WHERE id=?", (status, notified_at, turn["id"]))
1316
1304
  return get_turn(connection, room_id, turn_id)
@@ -1398,7 +1386,7 @@ def state_reading_text(room):
1398
1386
  """Read the host's brief explicitly without adding it to independent-entry overviews."""
1399
1387
  brief = room.get("host_brief")
1400
1388
  overview = f"主持简报(整理至 #{brief['until']},{brief['updated_at']})\n{brief['text']}" if brief and brief["text"] else "主持简报:尚未更新"
1401
- return f"{overview}\n\n主持人维护的状态(整理至 #{room['state_until']})\n\n{room['state_text'] or '尚未整理'}\n\n{receipt_references_text(room)}"
1389
+ return f"{overview}\n\n主持人维护的状态(整理至 #{room['state_until']})\n\n{room['state_text'] or '尚未整理'}"
1402
1390
 
1403
1391
 
1404
1392
  def message_text(message):
@@ -1419,7 +1407,7 @@ def room_text(connection, room_id, params):
1419
1407
  f"主持人:{room['host']};阶段:{PHASES[room['phase']]};当前焦点:{room['focus'] or '尚未填写'}",
1420
1408
  f"当前记录截止 #{room['latest_id']};共 {room['message_count']} 条;已完成 {room['completed_rounds']} 轮讨论(所有参与专家各发言一次)。"]
1421
1409
  limits = room["limits"]
1422
- content.append(f"讨论上限:{limits['max_rounds'] or '不限'} 轮;总时长 {limits['max_minutes'] or '不限'} 分钟;单次自动执行 {str(limits['execution_minutes']) + ' 分钟' if limits['execution_minutes'] else '不限时长'};到限 {limits['on_limit']};收束预留 {limits['summary_minutes']} 分钟(计入总时长)。到限:{limits['reached']};收束中:{limits['closing']}。")
1410
+ content.append(f"讨论上限:{limits['max_rounds'] or '不限'} 轮;总时长 {limits['max_minutes'] or '不限'} 分钟;单次自动执行 {str(limits['execution_minutes']) + ' 分钟' if limits['execution_minutes'] else '不限时长'};到限后暂停,由用户决定追加轮次或结束。到限:{limits['reached']}。")
1423
1411
  if room["mode"] == "free":
1424
1412
  members = ";".join(f"{item['name']}(已读至 #{item['read_until']},待投递 {item['pending']})" for item in room["free"]["members"])
1425
1413
  content.append(f"自由协作房间:无发言权、安排与轮次。成员:{members or '无'}。用户的消息点名成员后生成请求;成员回答须带本次执行编号,执行结束、被打断或过期后的回答会被拒绝,不能改作普通发言。")
@@ -1427,14 +1415,13 @@ def room_text(connection, room_id, params):
1427
1415
  content.extend(participation_text(params, free=True))
1428
1416
  return "\n\n".join(content)
1429
1417
  turn = room["current_turn"]
1430
- floor = room["floor"]
1431
- content.append(f"发言权:{floor['holder'] + ' 持有' if floor['holder'] else '待 ' + floor['next_speaker'] + ' 领取'};版本 {floor['revision']}。用户本人可随时留言,AI 发言前须领取。")
1418
+ content.append("没有发言权:被安排的专家直接回答自己的安排;用户本人和任何人的 @ 直接提问可随时发出。")
1432
1419
  if room["phase"] == "initial":
1433
1420
  content.append("本阶段先独立形成详细观点:仅阅读目标、背景、边界和自己的已有讨论;暂不阅读他人观点或主持总结。提交后再交叉阅读。这是参与约定,不是权限隔离。")
1434
1421
  if room["archived"]:
1435
1422
  content.append("房间已存档,记录仍可查阅;先恢复房间,再由用户明确恢复讨论阶段。")
1436
1423
  if room["phase"] in ("paused", "concluded"):
1437
- content.append("停止处理发言安排,取消本讨论自己的后台任务并核对实际状态;非必要不生成回执,正常停止无需回执。不要因旧通知恢复讨论。")
1424
+ content.append("停止处理发言安排,取消本讨论自己的后台任务并核对实际状态;不要因旧通知恢复讨论。")
1438
1425
  if turn:
1439
1426
  content.extend([f"当前安排 #{turn['id']}:{turn['expert']} · {turn_status_label(turn, room)}", f"待回应问题:{turn['question']}", f"安排详情:{base}/turns/{turn['id']}"])
1440
1427
  if room["queued_turns"]:
@@ -1503,23 +1490,21 @@ def protocol_text(room):
1503
1490
  "API 参与者由本机 runner 使用相同房间授权和执行约束提交,消息 source=api;CLI 仍使用 source=cli。兼容字段 experts 中的 cli 表示可自行提交,实际接入方式见成员 adapter。",
1504
1491
  "写入使用 POST,通常为 Content-Type: application/json;附件上传使用 application/octet-stream 原始字节。主持操作填写 actor 为当前主持人或用户;这些是声明身份,不是认证凭据。",
1505
1492
  f"附件:POST {base}/attachments?actor=身份&file_name=文件名&submission_id=新编号,原文件最多 20 MiB;GET 同路径默认列已发送附件,加 submission_id 精确核对上传。GET {base}/attachments/ID 读元数据,/ID/content 下载原文件。消息可带 attachment_ids(最多 10 个有序编号);只关联同房间、同提交者的未使用附件,可只有附件无正文。自动任务须在回答完成前上传;消息查询包含附件,按需下载后由当前软件读取,不自动解析。",
1506
- "讨论上限:room.limits 返回轮次、总时长、单次执行、到限方式、收束预留和实际进度。准备/暂停时通过 topic 的 limits 对象修改;0 表示不预设轮次/总时长/单次执行时限。POST /api/rooms/ROOM/limits(actor)或 room limits ROOM --check 检查到限处理;附 force:true 可在进行中提前进入收束,由主持人在收束预留时间内总结。到限停止新专家安排与观点;总结由真实主持人完成,预留计入总时长;暂停期间不计时也不消耗总时长。",
1493
+ "讨论上限:room.limits 返回轮次、总时长、单次执行和实际进度。准备/暂停时通过 topic 的 limits 对象修改;0 表示不预设轮次/总时长/单次执行时限。POST /api/rooms/ROOM/limits(actor)或 room limits ROOM --check 检查到限处理;到限后暂停并停止新专家安排与观点,由用户决定追加轮次或结束;暂停期间不计时也不消耗总时长。",
1507
1494
  "运行方式:room.runtime 显示 manual/automatic、状态及真实执行回执。所有房间写入须带 X-Discussion-Token,CLI 用 --access-file 读取私有文件。用户及运行控制使用房主凭据,手动本地成员使用自己的成员凭证,不能相互冒充;在线成员只读,由房主或讨论主持人代发。自动讨论先 execution enter,创建安排或保存关联回答即完成本步,不创建原生定时任务;本机唤醒成员的持久凭证不能替代自动执行,手动接入的专家用自己的成员凭证回答安排,暂停、停止、换代、租约到期后的旧执行失效。run view/start/pause/stop/configure 字段查 CLI schema。",
1508
- f"发言权:GET {base}/floor 返回 holder、next_speaker、revision;POST 同路径,字段 actor、speaker(默认 actor)、action=take/return、revision(刚读到的整数版本)。仅无人持有且 next_speaker 为本人时可领取;持有者可主动归还,下一位回到主持人。版本变化返回 409,先读回核对。主持人仅可代操作 online 专家,cli 专家自行操作;用户可协助。",
1509
- "新房间由主持人持有发言权。创建安排须主持人持有,保存成功即归还并点名该专家;AI 的 opinion、moderation、receipt 都须实际作者持有,代发不豁免,opinion 必须关联有效 turn_id。消息保存成功自动归还,下一位为主持人。失败不归还,同编号重试不再次交接。用户本人发言不受发言权限制,也不改变发言权;@ 不改变顺序。维护状态、阶段及通知回执不占用发言权。取消安排须主持人持有,用户可直接取消失效安排;取消后归还给主持人领取。",
1495
+ "没有发言权:同一话题同时只有一个未完成的发言安排,被安排的专家直接回答它;专家观点自动回答该专家当前有效的安排,turn_id 可省略(填写则须与之相符),没有轮到你时被拒绝。主持发言(moderation)只能由当前主持人署名,任何时候都可发出;创建、取消安排由主持人或用户操作。用户的 @ 直接提问不占安排也不计轮次。失败的写入不会改变安排状态,同编号重试沿用原内容。",
1510
1496
  '创建话题:POST /api/rooms,携带房主控制凭据;字段 title、goal、user_request、background、boundaries、host、experts、members、submission_id(新 UUID)。members 与 experts 名单一致,各项含 name、独立 token、access(手动本地 manual、自动 runner、在线 online)。AI 主持人必须为本地可写成员。CLI room create --access-file 私有房主文件 在请求前保存凭据,确认后返回各本地成员文件路径;同编号重试沿用原资料和全部凭据。名单、主持职责变更仅房主可做。',
1511
1497
  f"查询提交:GET /api/rooms?submission_id=编号 查建房结果;GET {base}/messages?submission_id=编号 或 {base}/turns?submission_id=编号 查消息或安排。查询成功且列表为空表示尚未查到,读取失败不表示未写入。",
1512
- f"增量读取:GET {base}/messages?after=已读消息编号,每次最多 20 条,按 next_after 续读,has_more 表示还有一批;可加 kind=opinion/moderation/receipt、author、mention、q。after 不与翻页混用,过滤后的游标只代表该查询范围。",
1513
- f"维护话题:POST {base}/topic;actor 加需要修改的上述字段,另可更新 phase(preparing/initial/discussing/reviewing/paused/concluded)、focus。收束前先完成或取消当前安排;主持人收束还须先 GET {base}/conclusion 取得 blockers 与 revision,并把 revision 作为 conclusion 一并提交(有阻碍项或状态已变会被拒绝)。暂停保留安排;恢复阶段后再继续。",
1498
+ f"增量读取:GET {base}/messages?after=已读消息编号,每次最多 20 条,按 next_after 续读,has_more 表示还有一批;可加 kind=opinion/moderation/chat、author、mention、q(旧房间里的历史回执 kind=receipt 仍可读取)。after 不与翻页混用,过滤后的游标只代表该查询范围。",
1499
+ f"维护话题:POST {base}/topic;actor 加需要修改的上述字段,另可更新 phase(preparing/initial/discussing/paused/concluded)、focus。收束前先完成或取消当前安排;主持人收束还须先 GET {base}/conclusion 取得 blockers 与 revision,并把 revision 作为 conclusion 一并提交(有阻碍项或状态已变会被拒绝)。暂停保留安排;恢复阶段后再继续。",
1514
1500
  f"安排发言:POST {base}/turns;字段 actor、expert、question、submission_id(新 UUID)。同一话题同时只有一个未完成安排;重试沿用同一编号和问题。整轮安排改传 turns=[{{expert,question}},…](最多 {MAX_PLAN_TURNS} 项),并须先 GET {base}/turns/roster 取得最新专家状态,把返回的 revision 作为 roster 一并提交(名单变化后需重取;须包含 waiting 中的每位专家):第一项立即生效,其余排队(queued);每个回答保存后自动交给下一位,最后一位答完再交回主持人。用户留言或修改目标、背景、边界、焦点后(room.held_since 非 0),当前回答完成即交回主持人;主持人处理后用 POST {base}/turns/continue(actor)按原计划继续,或直接创建新安排替换剩余排队(原排队改为 skipped)。",
1515
1501
  f"通知:仅把对象、问题、话题入口交给对应专家,不附背景、摘要或历史。使用已有且获准的会话通道;没有通道时交由用户协助。实际送达后 POST {base}/turns/安排编号,字段 actor、status=notified;取消用 status=cancelled。复制不算送达。",
1516
- f"专家观点:POST {base}/messages;author、text、submission_id、source=cli,kind=opinion;必须关联 turn_id(回答的安排编号),可选 reply_to(本话题原文编号)。每次回答同时提供 summary(最多 {MAX_SUMMARY_LENGTH} 字),通常 1–3 句结论先行,保留关键理由、条件和不确定性;summary_by 为原作者或提交者,默认提交者。text 保留完整正文,最多 {MAX_TEXT_LENGTH} 个 Unicode 字符,请求最多 {MAX_BODY_BYTES} 字节;不截断或用摘要完成安排。旧客户端缺少 summary 时不自动生成。",
1502
+ f"专家观点:POST {base}/messages;author、text、submission_id、source=cli,kind=opinion;turn_id(回答的安排编号)可省略,省略时回答你当前有效的安排;可选 reply_to(本话题原文编号)。每次回答同时提供 summary(最多 {MAX_SUMMARY_LENGTH} 字),通常 1–3 句结论先行,保留关键理由、条件和不确定性;summary_by 为原作者或提交者,默认提交者。text 保留完整正文,最多 {MAX_TEXT_LENGTH} 个 Unicode 字符,请求最多 {MAX_BODY_BYTES} 字节;不截断或用摘要完成安排。旧客户端缺少 summary 时不自动生成。",
1517
1503
  f"主持发言:同一消息接口,author={room['host']}、submitted_by={room['host']}、kind=moderation、source=cli;个人观点使用 kind=opinion,不能自动视为共识。",
1518
- f"代发只读专家观点:author 保留原专家,submitted_by={room['host']}、kind=opinion、text 为原文;须关联 turn_id,可附 reply_to。服务保存代发来源;不要冒充专家直接提交。",
1519
- f"整理状态:POST {base}/state;actor、text(详细状态,最多 {MAX_STATE_LENGTH} 字)、until(依据的消息截止编号);brief(最多 {MAX_BRIEF_LENGTH} 字)为面向人的主持简报,说明结论、共识、歧义和下一步意图。进度或安排变化后更新;省略 brief 保留旧简报,空字符串清除。所有参与专家各保存一次完整观点才完成一轮;同轮重复发言只计该专家一次,主持说明、用户留言、回执及取消安排不推进轮次。房间 rounds 按当前配置专家和完整历史生成,completed_rounds 为完成轮数,answered_turns 仅为已回答安排数。状态不覆盖原文,也不代表全体一致同意。",
1520
- "讨论方法:initial 先独立给出结论、依据、假设和风险,不回应他人;discussing 围绕具体分歧引用原文,说明维持或修正及原因;reviewing 检查拟定结论是否误述自己、遗漏条件或仍有阻碍。沉默不算同意。",
1521
- f"运行回执:非必要不生成。正常启停、首次检查、无变化轮询和已提交回答不另发回执;仅用户明确要求、异常影响协作且需要处理或交接确需补充确认时使用。POST {base}/messages;author 使用实际身份、kind=receipt、source=cli、text 简述实际结果、影响和下一步,有任务时附本次运行标识与真实任务编号;已有状态或消息足够说明时不重复。submission_id 同次重试不变,不填 turn_id。回执不算专家观点或已回答。",
1522
- "后台参与:先立即检查,再用本会话真实可用的原生任务按约定频率和自身运行上限检查。专家只答自己未完成的安排,无任务时等待;主持人仍需处理新回答、网页回复和下一安排。paused/concluded、用户叫停或达到自身上限时停止自己的任务并核验,实际结果保留在本地接续信息,是否发回执按上述规则判断;单个专家任务停止不等于整场讨论结束。失败时保留未提交原文,重新读取阶段后再决定是否重试。",
1504
+ f"代发只读专家观点:author 保留原专家,submitted_by={room['host']}、kind=opinion、text 为原文;须关联 turn_id,可附 reply_to。服务保存代发来源;不要冒充专家直接提交。该专家须有当前有效的安排。",
1505
+ f"整理状态:POST {base}/state;actor、text(详细状态,最多 {MAX_STATE_LENGTH} 字)、until(依据的消息截止编号);brief(最多 {MAX_BRIEF_LENGTH} 字)为面向人的主持简报,说明结论、共识、歧义和下一步意图。进度或安排变化后更新;省略 brief 保留旧简报,空字符串清除。所有参与专家各保存一次完整观点才完成一轮;同轮重复发言只计该专家一次,主持说明、用户留言及取消安排不推进轮次。房间 rounds 按当前配置专家和完整历史生成,completed_rounds 为完成轮数,answered_turns 仅为已回答安排数。状态不覆盖原文,也不代表全体一致同意。",
1506
+ "讨论方法:initial 先独立给出结论、依据、假设和风险,不回应他人;discussing 围绕具体分歧引用原文,说明维持或修正及原因;拟定结论时检查是否误述自己、遗漏条件或仍有阻碍。沉默不算同意。",
1507
+ "后台参与:先立即检查,再用本会话真实可用的原生任务按约定频率和自身运行上限检查。专家只答自己未完成的安排,无任务时等待;主持人仍需处理新回答、网页回复和下一安排。paused/concluded、用户叫停或达到自身上限时停止自己的任务并核验,实际结果保留在本地接续信息;单个专家任务停止不等于整场讨论结束。失败时保留未提交原文,重新读取阶段后再决定是否重试。",
1523
1508
  "收束:结论须有原文依据,区分明确共识、未决分歧与主持人判断;完成约定复核或达到已说明的时间/轮次上限后收束。讨论完成与后台任务退出分别核验;不为追求全员确认无限续轮。",
1524
1509
  "房间、消息与安排都支持 submission_id 去重,同次重试沿用编号和原始内容,新内容使用新编号。写入后重新读取服务器返回的结果,核对身份、内容与引用。",
1525
1510
  ])
@@ -1886,12 +1871,20 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1886
1871
  record_reported_session(connection, room_id, token, {"session_id": self.headers.get("X-Discussion-Session"),
1887
1872
  "session_client": self.headers.get("X-Discussion-Session-Client")})
1888
1873
  connection.commit()
1874
+ if parts[3:] == ["discussion"] and method == "POST":
1875
+ body = self.read_payload()
1876
+ if body.get("action") not in ("start", "end"):
1877
+ raise ValueError("action 须为 start 或 end")
1878
+ result = (start_discussion if body["action"] == "start" else end_discussion)(connection, room_id, token, body)
1879
+ connection.commit()
1880
+ return self.respond(result)
1889
1881
  if parts[3:] == ["register"] and method == "POST":
1890
1882
  result = register_member(connection, room_id, token, utc_now())
1891
1883
  connection.commit()
1892
1884
  return self.respond(result)
1893
1885
  free = room_mode(connection, room_id) == "free"
1894
- if free and parts[3:4] in (["inbox"], ["executions"], ["members"], ["suggestions"], ["recommendations"]):
1886
+ # A discussion keeps the local service's inbox and per-member executions for the user's direct questions.
1887
+ if parts[3:4] in (["inbox"], ["executions"]) or (free and parts[3:4] in (["members"], ["suggestions"], ["recommendations"])):
1895
1888
  return self.handle_free_api(connection, parts, method)
1896
1889
  if parts[3:4] == ["members"] and method == "POST" and len(parts) in (4, 5):
1897
1890
  if len(parts) == 4:
@@ -1914,6 +1907,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1914
1907
  pass
1915
1908
  elif method == "POST" and free:
1916
1909
  free_actor = authorize_free_write(connection, room_id, token, parts[3] if len(parts) > 3 else "", self.read_payload())
1910
+ elif method == "POST" and parts[3:] == ["messages"] and answers_direct_question(self.read_payload()):
1911
+ free_actor = authorize_free_write(connection, room_id, token, "messages", self.read_payload())
1917
1912
  elif method == "POST":
1918
1913
  authorize_write(connection, room_id, token, parts[3] if len(parts) > 3 else "", self.read_payload())
1919
1914
  if parts[3:] == ["backup"] and method == "GET":
@@ -1938,12 +1933,6 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1938
1933
  result = check_limits(connection, room_id, self.read_payload())
1939
1934
  connection.commit()
1940
1935
  return self.respond(result)
1941
- if parts[3:] == ["floor"]:
1942
- if method == "GET":
1943
- return self.respond(require_room(connection, room_id)["floor"])
1944
- result = update_floor(connection, room_id, self.read_payload())
1945
- connection.commit()
1946
- return self.respond(result)
1947
1936
  if parts[3:4] in (["topic"], ["turns"]):
1948
1937
  return self.handle_host_api(connection, parts, params, method)
1949
1938
  if parts[3:] == ["messages"]: