ai_co_discuss 0.2.69 → 0.2.71

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.
@@ -105,7 +105,11 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
105
105
 
106
106
  ## 上限与到限
107
107
 
108
- 轮次由程序按专家发言量统计并判定到限(`limits.segment_rounds` / `round_cap`),不要用自己的计划结构推算;暂停、准备与收束期间不计时。到限后讨论结束、房间回到自由协作,用 `room limits ROOM --check --as 本人`(凭据方式遵循参与技能)处理:
108
+ 轮次由程序按专家发言量统计并判定到限(`limits.segment_rounds` / `round_cap`),不要用自己的计划结构推算;暂停、准备与收束期间不计时。
109
+
110
+ 开场安排第一位专家前,先核对目标与上限:若按目标推算,本次轮次或时长上限显然不够完成(如要求报数到 10,3 人 3 轮只有 9 次发言),先发一条主持消息告诉用户冲突所在、上限内能做到哪一步,以及调整的办法(结束后在中间区点“继续讨论”设新上限);然后在上限内照常推进,不等待答复,也不自行放宽上限。只写进 state 或简报不算告知。
111
+
112
+ 到限后讨论结束、房间回到自由协作,用 `room limits ROOM --check --as 本人`(凭据方式遵循参与技能)处理:
109
113
 
110
114
  - 返回后房间已回到自由协作,本次执行随之换代,停止即可;之后用户的未点名留言照常交给你处理。用户要总结时,按“收束”一节写出最终 `moderation`。
111
115
  - 目标提前完成时,先保存有原文依据的结论、state 与简报,再 `room conclusion` 取得 `revision`,用 `room limits ROOM --done --conclusion <revision> --as 本人` 通知程序(未带、过期或有未处理留言、state 落后都会被拒绝):讨论结束,房间回到自由协作。
@@ -12,7 +12,7 @@
12
12
 
13
13
  ## 主持人
14
14
 
15
- 任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员都已结束,`all` 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 `--to`)。先 `room view` 看成员,需要他人时在回答中加 `--to 成员名` 派活并说明各自负责的文件或模块,不需要时不加,本次即结束;不点名自己,不扩大用户给的权限。
15
+ 任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员都已结束,`all` 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 `--to`)。先 `room view` 看成员,需要他人时在回答中加 `--to 成员名` 派活并说明各自负责的文件或模块,不需要时不加,本次即结束;不点名自己,不扩大用户给的权限。同一条用户消息引出的 AI 请求(含你派出的成员)最多 8 次,用完即停并等用户发新消息,所以派活前先想清楚需要几位。房间开了智能调度时,它对未点名消息的判断记在 `room view` 的 `scheduler_decisions` 里,可读来了解这条消息为何交给你;仅供参考,读原文后自行决定,不当作用户指令。
16
16
 
17
17
  多位成员共享同一工作目录时,只改分给自己的文件;只提交自己的改动,切分支、reset、全仓 stash 或 checkout 须用户确认。
18
18
 
@@ -9,6 +9,7 @@ answers by itself always hosts, routes unnamed messages, names members in its an
9
9
  woken once when everything it dispatched has finished. No software is launched here.
10
10
  """
11
11
 
12
+ import hashlib
12
13
  import hmac
13
14
  import json
14
15
  import re
@@ -38,6 +39,8 @@ ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM ro
38
39
  AND access<>'online' ORDER BY access='runner' DESC, id LIMIT 1)
39
40
  AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
40
41
  TERMINAL = ("completed", "cancelled", "failed")
42
+ # A queued member has been asked once its request for that message exists.
43
+ QUEUE_ASKED = "EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id={row}.message_id AND r.member_id={row}.member_id)"
41
44
  SESSION_MODES = ("new", "borrowed")
42
45
  FREE_OPERATIONS = ("messages", "attachments", "move", "folder", "archive", "restore", "delete")
43
46
  MEMBER_FIELDS = ("id", "name", "adapter", "role", "status", "access", "session_mode", "read_until")
@@ -247,7 +250,8 @@ def free_view(connection, room_id):
247
250
  members.append(member)
248
251
  names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
249
252
  requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
250
- """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at FROM member_requests
253
+ """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
254
+ (SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
251
255
  WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
252
256
  chains = [dict(row) for row in connection.execute(
253
257
  "SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
@@ -332,12 +336,18 @@ def recipients(connection, room_id, payload):
332
336
 
333
337
  A single active member is named by default. With several, an unnamed message goes to the
334
338
  AI host for routing; only a room of online members has none, and then it stays a plain note.
339
+ Naming everyone with order auto leaves the host a group request: the runner has everyone answer
340
+ at once or one after another; a host the runner does not wake has them all answer at once.
335
341
  """
336
342
  # Rooms created while the user could host get their AI host on the next message.
337
343
  sync_participants(connection, room_id)
338
344
  active = active_members(connection, room_id)
339
345
  if payload.get("to") is not None:
340
- return [(member_id, "ask") for member_id in named_members(active, payload["to"])]
346
+ named = named_members(active, payload["to"])
347
+ host = next((row for row in active.values() if row["role"] == "host"), None)
348
+ if payload.get("order") == "auto" and len(named) > 1 and host and host["access"] == "runner":
349
+ return [(host["id"], "group")]
350
+ return [(member_id, "ask") for member_id in named]
341
351
  if len(active) == 1:
342
352
  return [(next(iter(active.values()))["id"], "ask")]
343
353
  host = next((row for row in active.values() if row["role"] == "host"), None)
@@ -550,24 +560,137 @@ def settle_batches(connection, room_id):
550
560
 
551
561
 
552
562
  def queue_in_turn(connection, room_id, message_id, member_ids):
553
- """Line the named members up for one owner message: the host first, then by joining order; only the first is asked now."""
554
- marks = ",".join("?" * len(member_ids))
555
- for row in connection.execute(f"SELECT id FROM room_members WHERE id IN ({marks}) ORDER BY role='host' DESC, id", member_ids).fetchall():
556
- connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room_id, message_id, row["id"]))
563
+ """Line the named members up for one owner message in the room's speaking order; only the first is asked now."""
564
+ chosen = set(member_ids)
565
+ members = [dict(row) for row in connection.execute("SELECT id,name,role FROM room_members WHERE room_id=?", (room_id,))]
566
+ for member in default_order(connection, room_id, [member for member in members if member["id"] in chosen]):
567
+ connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room_id, message_id, member["id"]))
557
568
  advance_queues(connection, room_id)
558
569
 
559
570
 
560
571
  def advance_queues(connection, room_id):
561
- """Ask the next queued member of each message whose previous request ended; members no longer present are skipped."""
562
- connection.execute("""DELETE FROM free_queue WHERE room_id=? AND NOT EXISTS(SELECT 1 FROM room_members m
563
- WHERE m.id=free_queue.member_id AND m.status='active')""", (room_id,))
564
- connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
572
+ """Ask the next queued member of each message whose previous request ended; members no longer present are skipped.
573
+
574
+ Asked rows stay, so the speaking order can still show who already answered.
575
+ """
576
+ connection.execute(f"""DELETE FROM free_queue WHERE room_id=? AND NOT {QUEUE_ASKED.format(row="free_queue")}
577
+ AND NOT EXISTS(SELECT 1 FROM room_members m WHERE m.id=free_queue.member_id AND m.status='active')""", (room_id,))
578
+ connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
565
579
  SELECT q.room_id,q.member_id,q.message_id,q.message_id,0,'ask','user',? FROM free_queue q
566
- WHERE q.room_id=? AND q.id=(SELECT MIN(id) FROM free_queue x WHERE x.message_id=q.message_id)
580
+ WHERE q.room_id=? AND q.id=(SELECT MIN(x.id) FROM free_queue x WHERE x.message_id=q.message_id AND NOT {QUEUE_ASKED.format(row="x")})
567
581
  AND NOT EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id=q.message_id AND r.status IN ('pending','delivering'))""",
568
582
  (int(time.time()), room_id))
569
- connection.execute("""DELETE FROM free_queue WHERE room_id=? AND EXISTS(SELECT 1 FROM member_requests r
570
- WHERE r.message_id=free_queue.message_id AND r.member_id=free_queue.member_id)""", (room_id,))
583
+
584
+
585
+ def default_order(connection, room_id, members):
586
+ """Members in the room's saved speaking order; without one (or for members it lacks), the host first, then by joining order."""
587
+ row = connection.execute("SELECT member_ids FROM speaking_orders WHERE room_id=?", (room_id,)).fetchone()
588
+ rank = {member_id: index for index, member_id in enumerate(json.loads(row[0]) if row else [])}
589
+ return sorted(members, key=lambda member: (member["id"] not in rank, rank.get(member["id"], 0), member["role"] != "host", member["id"]))
590
+
591
+
592
+ def speaking_order(connection, room):
593
+ """Who spoke, who speaks now and who is next, shared by free rooms and discussions; only the not-yet-spoken part may be reordered.
594
+
595
+ A free room shows its latest unfinished one-after-another queue, else every member in the room's default order;
596
+ a discussion shows the open round: speakers so far, the current turn, then this round's queued turns; a plan reaching into
597
+ later rounds lists them apart in `later` (one name list per round), read-only. The revision changes with any of it.
598
+ """
599
+ active = [member for member in room["members"] if member["status"] == "active"]
600
+ by_id = {member["id"]: member for member in active}
601
+ ordered = [member["name"] for member in default_order(connection, room["id"], active)]
602
+ items, kind, target, later = [], "default", 0, []
603
+ if room["mode"] == "free":
604
+ target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
605
+ AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
606
+ if target:
607
+ kind = "queue"
608
+ for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
609
+ ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
610
+ if row["member_id"] in by_id:
611
+ state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
612
+ items.append({"name": by_id[row["member_id"]]["name"], "state": state})
613
+ else:
614
+ kind = "turns"
615
+ last = room["rounds"][-1] if room["rounds"] else None
616
+ turn = room["current_turn"]
617
+ speaking = turn["expert"] if turn and turn["status"] in ("pending", "notified") else None
618
+ items = [{"name": name, "state": "done"} for name in (last["speakers"] if last and not last["completed"] else []) if name != speaking]
619
+ if speaking:
620
+ items.append({"name": speaking, "state": "speaking"})
621
+ rounds = split_rounds([item["expert"] for item in room["queued_turns"]], {item["name"] for item in items})
622
+ items += [{"name": name, "state": "queued"} for name in rounds[0]]
623
+ later = rounds[1:]
624
+ # The user may still hold the host seat of an old discussion; only AI participants have a place.
625
+ ordered = [name for name in [room["host"], *ordered, *room["experts"]] if (name in room["experts"] or name == room["host"]) and name != "用户"]
626
+ listed = {item["name"] for item in items}
627
+ items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
628
+ movable = "queued" if kind != "default" else "idle"
629
+ state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later], ensure_ascii=False)
630
+ return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
631
+ "default": [member["name"] for member in default_order(connection, room["id"], active)],
632
+ "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
633
+
634
+
635
+ def split_rounds(names, spoken):
636
+ """Cut a turn queue into rounds: an expert who already spoke in the round being filled starts the next one."""
637
+ rounds, seen = [[]], set(spoken)
638
+ for name in names:
639
+ if name in seen:
640
+ rounds.append([])
641
+ seen = set()
642
+ rounds[-1].append(name)
643
+ seen.add(name)
644
+ return rounds
645
+
646
+
647
+ def reorder_speakers(connection, room, token, payload):
648
+ """Owner reorders the not-yet-spoken part of the speaking order; who already spoke or speaks now stays put.
649
+
650
+ Read the speaking order first and send its revision: a missing revision is refused, a changed one conflicts,
651
+ and the new order must list exactly the movable members.
652
+ """
653
+ require_owner(connection, room["id"], token)
654
+ current = room["speaking_order"]
655
+ if payload.get("revision") is None:
656
+ raise ValueError("调整发言顺序前须先读取最新顺序(speaking-order),并带上其 revision")
657
+ if payload["revision"] != current["revision"]:
658
+ raise FileExistsError("发言顺序已变化,请重新读取后再调整")
659
+ order = payload.get("order")
660
+ movable = current["movable"]
661
+ if not isinstance(order, list) or sorted(map(str, order)) != sorted(movable):
662
+ given = [str(name) for name in order] if isinstance(order, list) else []
663
+ missing = [name for name in dict.fromkeys(movable) if given.count(name) < movable.count(name)]
664
+ extra = [name for name in dict.fromkeys(given) if movable.count(name) < given.count(name)]
665
+ raise ValueError("新顺序须恰好包含尚未发言的成员" + (f";缺少:{'、'.join(missing)}" if missing else "") + (f";多出:{'、'.join(extra)}" if extra else ""))
666
+ names = {member["name"]: member["id"] for member in room["members"] if member["status"] == "active"}
667
+ if current["kind"] == "queue":
668
+ connection.execute(f"DELETE FROM free_queue WHERE message_id=? AND NOT {QUEUE_ASKED.format(row='free_queue')}", (current["message_id"],))
669
+ for name in order:
670
+ connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
671
+ elif current["kind"] == "turns":
672
+ reorder_turns(connection, room["id"], order)
673
+ else:
674
+ connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
675
+ (room["id"], json.dumps([names[name] for name in order])))
676
+
677
+
678
+ def reorder_turns(connection, room_id, order):
679
+ """Move this round's queued turns into the new order; ids keep the relay order, so each turn's content moves to the id at its new place.
680
+
681
+ This round's turns lead the queue, so they are its first rows; later rounds keep theirs.
682
+ """
683
+ rows = [dict(row) for row in connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id LIMIT ?",
684
+ (room_id, len(order)))]
685
+ remaining, moved = list(rows), []
686
+ for name in order:
687
+ moved.append(remaining.pop(next(index for index, row in enumerate(remaining) if row["expert"] == name)))
688
+ # Submission ids are unique, so free them before writing them to their new rows.
689
+ for slot in rows:
690
+ connection.execute("UPDATE discussion_turns SET submission_id=submission_id||':moving:'||id WHERE id=?", (slot["id"],))
691
+ for slot, content in zip(rows, moved):
692
+ connection.execute("UPDATE discussion_turns SET expert=?,question=?,submission_id=?,created_at=? WHERE id=?",
693
+ (content["expert"], content["question"], content["submission_id"], content["created_at"], slot["id"]))
571
694
 
572
695
 
573
696
  def answered_chain(connection, payload, actor):
@@ -641,7 +764,8 @@ def asks_view(connection, room_id):
641
764
  """Direct questions of a discussion room: recent requests with their member, for status badges beside messages."""
642
765
  names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
643
766
  requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
644
- """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at FROM member_requests
767
+ """SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
768
+ (SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
645
769
  WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
646
770
  return {"requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id)}
647
771
 
@@ -659,22 +783,57 @@ def expire_executions(connection, room_id):
659
783
  settle_batches(connection, room_id)
660
784
 
661
785
 
662
- def finish_execution(connection, execution_id, status, result, request_status):
786
+ def record_cancel_reasons(connection, where, params, reason):
787
+ """Remember why requests are about to be cancelled; call before the status change selects them."""
788
+ connection.execute(f"INSERT OR REPLACE INTO request_cancel_reasons (request_id,reason) SELECT id,? FROM member_requests WHERE {where}", (reason, *params))
789
+
790
+
791
+ def finish_execution(connection, execution_id, status, result, request_status, cancel_reason=""):
663
792
  """End one execution and move its delivered requests in the same transaction."""
793
+ if request_status == "cancelled" and cancel_reason:
794
+ record_cancel_reasons(connection, "execution_id=? AND status='delivering'", (execution_id,), cancel_reason)
664
795
  connection.execute("UPDATE member_executions SET status=?,finished_at=?,result=? WHERE id=? AND status IN ('dispatched','started')",
665
796
  (status, int(time.time()), result[:500], execution_id))
666
797
  connection.execute("UPDATE member_requests SET status=?,execution_id=CASE WHEN ?='pending' THEN NULL ELSE execution_id END WHERE execution_id=? AND status='delivering'",
667
798
  (request_status, request_status, execution_id))
668
799
 
669
800
 
670
- def stop_member_work(connection, member_id, reason):
801
+ def open_route_messages(connection, member_id, pending_only=False):
802
+ """(message, purpose) of host requests still open, in order; they need a new host if this one leaves."""
803
+ states = "'pending'" if pending_only else "'pending','delivering'"
804
+ return [(row[0], row[1]) for row in connection.execute(
805
+ f"""SELECT message_id,purpose FROM member_requests WHERE member_id=? AND purpose IN ('route','group')
806
+ AND status IN ({states}) ORDER BY message_id""", (member_id,))]
807
+
808
+
809
+ def hand_over_routes(connection, room_id, routes, departed_id):
810
+ """Ask the current host to route messages left behind by the previous host."""
811
+ host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active' AND id<>?",
812
+ (room_id, departed_id or 0)).fetchone()
813
+ for message_id, purpose in routes if host else []:
814
+ connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
815
+ VALUES (?,?,?,?,?,'user',?)""", (room_id, host["id"], message_id, message_id, purpose, int(time.time())))
816
+
817
+
818
+ def stop_member_work(connection, member_id, reason, cancel_reason):
671
819
  """Void a member's live execution and every open request, so nothing restarts by itself."""
672
820
  for row in connection.execute("SELECT id FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
673
821
  (member_id,)).fetchall():
674
- finish_execution(connection, row["id"], "cancelled", reason, "cancelled")
822
+ finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
823
+ record_cancel_reasons(connection, "member_id=? AND status='pending'", (member_id,), cancel_reason)
675
824
  connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND status='pending'", (member_id,))
676
825
 
677
826
 
827
+ def stop_room_work(connection, room_id, reason, cancel_reason):
828
+ """Void every live execution and open request of a room, e.g. when it is archived."""
829
+ for row in connection.execute("SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started')",
830
+ (room_id,)).fetchall():
831
+ finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
832
+ record_cancel_reasons(connection, "room_id=? AND status='pending'", (room_id,), cancel_reason)
833
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE room_id=? AND status='pending'", (room_id,))
834
+ settle_batches(connection, room_id)
835
+
836
+
678
837
  def inbox(connection, room_id, token):
679
838
  """List what the runner should deliver or reconcile; owner credential only."""
680
839
  require_owner(connection, room_id, token)
@@ -734,14 +893,32 @@ def release_recommendations(connection, room_id):
734
893
  def route_wide(connection, room_id, message_id, action):
735
894
  """Smart scheduling answers an unnamed message with everyone (each member once, as if the user named them all) or no one.
736
895
 
896
+ A message naming everyone (a group request) is answered by all at once or, with action sequential, one after another.
737
897
  Only a host request that has not started changes; a started host already handles the message its own way.
738
898
  """
739
899
  route = host_route_request(connection, message_id)
900
+ group = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND purpose='group'", (message_id,)).fetchone()
901
+ if group and group["room_id"] == room_id:
902
+ if action not in ("all", "sequential"):
903
+ raise ValueError("请全体成员回应的消息只能选 action=all 或 sequential")
904
+ if group["status"] != "pending":
905
+ raise FileExistsError("这条消息已不再等待安排")
906
+ if action == "sequential":
907
+ # The row is removed rather than ended so the host takes its place in the queue like everyone else.
908
+ connection.execute("DELETE FROM member_requests WHERE id=?", (group["id"],))
909
+ members = connection.execute(f"SELECT id FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))
910
+ queue_in_turn(connection, room_id, message_id, [row["id"] for row in members])
911
+ requests = connection.execute("SELECT * FROM member_requests WHERE message_id=? ORDER BY id", (message_id,)).fetchall()
912
+ return {"action": action, "requests": [dict(row) for row in requests]}
913
+ route = group
914
+ elif action == "sequential":
915
+ raise ValueError("只有请全体成员回应的消息可以依次回答")
740
916
  if not route or route["room_id"] != room_id:
741
917
  raise LookupError("只能处理交给主持人的未点名消息")
742
918
  if route["status"] != "pending":
743
919
  raise FileExistsError("主持人已开始处理这条消息,不再改派")
744
920
  if action == "none":
921
+ record_cancel_reasons(connection, "id=?", (route["id"],), "no_reply")
745
922
  connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
746
923
  else:
747
924
  # The host keeps its row (one request per message and member) and now answers its own share, without dispatching.
@@ -760,10 +937,10 @@ def adopt_recommendation(connection, room_id, token, message_id, payload):
760
937
  """
761
938
  require_owner(connection, room_id, token)
762
939
  begin_write(connection)
763
- if payload.get("action") in ("all", "none"):
940
+ if payload.get("action") in ("all", "none", "sequential"):
764
941
  return route_wide(connection, room_id, message_id, payload["action"])
765
942
  if payload.get("action") != "adopt" or payload.get("mode") not in ("interrupt", "after"):
766
- raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体或无需回应为 action=all 或 none")
943
+ raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体、依次或无需回应为 action=all、sequential 或 none")
767
944
  row = connection.execute("SELECT * FROM member_recommendations WHERE room_id=? AND message_id=?", (room_id, message_id)).fetchone()
768
945
  if not row:
769
946
  raise LookupError("这条消息没有智能调度推荐")
@@ -777,8 +954,9 @@ def adopt_recommendation(connection, room_id, token, message_id, payload):
777
954
  mode = payload["mode"]
778
955
  if mode == "interrupt" and route["status"] == "delivering":
779
956
  # The host's execution may carry other messages too; stopping it voids that whole execution.
780
- finish_execution(connection, route["execution_id"], "cancelled", "智能调度改派,改由其他成员回答", "cancelled")
957
+ finish_execution(connection, route["execution_id"], "cancelled", "智能调度改派,改由其他成员回答", "cancelled", "rerouted")
781
958
  elif mode == "interrupt" and route["status"] == "pending":
959
+ record_cancel_reasons(connection, "id=?", (route["id"],), "rerouted")
782
960
  connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
783
961
  connection.execute("UPDATE member_recommendations SET status='waiting',mode=? WHERE message_id=?", (mode, message_id))
784
962
  release_recommendations(connection, room_id)
@@ -817,12 +995,13 @@ def reserve_execution(connection, room_id, token, payload):
817
995
  if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND participant=? AND status IN ('dispatched','started')",
818
996
  (room_id, member["name"])).fetchone():
819
997
  raise FileExistsError("该成员正在处理讨论安排;同一会话不并发续接")
820
- if not connection.execute("SELECT 1 FROM member_requests WHERE member_id=? AND status='pending'", (member["id"],)).fetchone():
998
+ # A group request waits for the runner to choose how everyone answers; it is never delivered itself.
999
+ if not connection.execute("SELECT 1 FROM member_requests WHERE member_id=? AND status='pending' AND purpose<>'group'", (member["id"],)).fetchone():
821
1000
  raise FileExistsError("该成员没有待投递的请求")
822
1001
  now = int(time.time())
823
1002
  cursor = connection.execute("""INSERT INTO member_executions (room_id,member_id,client_id,lease_until,created_at,cwd)
824
1003
  VALUES (?,?,?,?,?,?)""", (room_id, member["id"], client_id, now + LEASE_SECONDS, now, str(payload.get("cwd") or "")[:MAX_PATH_LENGTH]))
825
- connection.execute("UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending'",
1004
+ connection.execute("UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending' AND purpose<>'group'",
826
1005
  (cursor.lastrowid, member["id"]))
827
1006
  return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (cursor.lastrowid,)).fetchone())
828
1007
 
@@ -903,7 +1082,7 @@ def owned_member(connection, room_id, member_id, token):
903
1082
  def interrupt_member(connection, room_id, member_id, token):
904
1083
  """Owner stop: void the live execution and every open request."""
905
1084
  owned_member(connection, room_id, member_id, token)
906
- stop_member_work(connection, member_id, "用户打断")
1085
+ stop_member_work(connection, member_id, "用户打断", "user_stop")
907
1086
  settle_batches(connection, room_id)
908
1087
  return free_view(connection, room_id)
909
1088
 
@@ -1057,8 +1236,12 @@ def member_action(connection, room_id, member_id, token, payload):
1057
1236
  """
1058
1237
  member = owned_member(connection, room_id, member_id, token)
1059
1238
  action = payload.get("action")
1239
+ routed = []
1060
1240
  if action in ("away", "remove"):
1061
- stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除")
1241
+ # Unnamed messages still waiting on a departing host move to its successor instead of being dropped.
1242
+ routed = open_route_messages(connection, member_id)
1243
+ stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除",
1244
+ "member_away" if action == "away" else "member_removed")
1062
1245
  connection.execute("UPDATE room_members SET status=? WHERE id=?", ("away" if action == "away" else "removed", member_id))
1063
1246
  elif action == "active":
1064
1247
  hashed = token_hash(payload.get("token"))
@@ -1077,10 +1260,17 @@ def member_action(connection, room_id, member_id, token, payload):
1077
1260
  if role == "member" and member["role"] == "host":
1078
1261
  raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
1079
1262
  if role == "host":
1263
+ # Unnamed messages the old host has not started move to the new host, which carries the host rules.
1264
+ old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
1265
+ if old_host:
1266
+ routed = open_route_messages(connection, old_host["id"], pending_only=True)
1267
+ record_cancel_reasons(connection, "member_id=? AND purpose IN ('route','group') AND status='pending'", (old_host["id"],), "rerouted")
1268
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND purpose IN ('route','group') AND status='pending'", (old_host["id"],))
1080
1269
  connection.execute("UPDATE room_members SET role='member' WHERE room_id=? AND role='host'", (room_id,))
1081
1270
  connection.execute("UPDATE room_members SET role=? WHERE id=?", (role, member_id))
1082
1271
  else:
1083
1272
  raise ValueError("action 须为 away、active、remove、role 或 adopt")
1084
1273
  settle_batches(connection, room_id)
1085
1274
  sync_participants(connection, room_id)
1275
+ hand_over_routes(connection, room_id, routed, member_id if action in ("away", "remove") else None)
1086
1276
  return free_view(connection, room_id)
@@ -602,11 +602,31 @@ CREATE TABLE IF NOT EXISTS scheduler_routing (
602
602
  created_at INTEGER NOT NULL,
603
603
  PRIMARY KEY (room_id, message_id)
604
604
  );
605
+ -- Why a request was cancelled; a side table so older databases need no column change. Requests cancelled before it existed have no row.
606
+ CREATE TABLE IF NOT EXISTS request_cancel_reasons (
607
+ request_id INTEGER PRIMARY KEY REFERENCES member_requests(id) ON DELETE CASCADE,
608
+ reason TEXT NOT NULL CHECK(reason IN ('user_stop','member_away','member_removed','rerouted','no_reply','archived'))
609
+ );
605
610
  -- What produced a decision (model, threshold, prompt and package versions), so ratings stay comparable across versions.
606
611
  CREATE TABLE IF NOT EXISTS scheduler_decision_context (
607
612
  decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
608
613
  context TEXT NOT NULL
609
614
  );
615
+ -- The exact input the decision model saw (state and full questions with option descriptions); not part of backups.
616
+ CREATE TABLE IF NOT EXISTS scheduler_decision_inputs (
617
+ decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
618
+ input TEXT NOT NULL
619
+ );
620
+ -- A self-contained copy of each decision the owner currently rates bad, kept when its room is deleted so prompts can be reviewed.
621
+ CREATE TABLE IF NOT EXISTS scheduler_feedback_cases (
622
+ decision_id INTEGER PRIMARY KEY,
623
+ room_id TEXT NOT NULL,
624
+ room_title TEXT NOT NULL,
625
+ snapshot TEXT NOT NULL,
626
+ should_be TEXT NOT NULL DEFAULT '',
627
+ note TEXT NOT NULL DEFAULT '',
628
+ rated_at TEXT NOT NULL
629
+ );
610
630
  -- Smart scheduling's suggestion that another member answer an unnamed message. The host keeps the message;
611
631
  -- the user may adopt before expires_at (waiting until the host's request ends for mode 'after'), otherwise it lapses.
612
632
  CREATE TABLE IF NOT EXISTS member_recommendations (
@@ -635,13 +655,19 @@ CREATE TABLE IF NOT EXISTS free_batches (
635
655
  settled INTEGER NOT NULL DEFAULT 0,
636
656
  PRIMARY KEY (room_id,batch_id)
637
657
  );
638
- -- Members the user named to answer one after another: each gets its request once the one before it ends (host first, then by joining order).
658
+ -- Members the user named to answer one after another, in the room's speaking order: each gets its request once the one before
659
+ -- it ends. Asked rows stay so the speaking order can show who already answered; the owner may reorder rows not yet asked.
639
660
  CREATE TABLE IF NOT EXISTS free_queue (
640
661
  id INTEGER PRIMARY KEY AUTOINCREMENT,
641
662
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
642
663
  message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
643
664
  member_id INTEGER NOT NULL REFERENCES room_members(id) ON DELETE CASCADE
644
665
  );
666
+ -- The owner's default speaking order (JSON list of member ids); members it lacks follow, the host first, then by joining order.
667
+ CREATE TABLE IF NOT EXISTS speaking_orders (
668
+ room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
669
+ member_ids TEXT NOT NULL
670
+ );
645
671
  -- Chains whose automatic progress stopped (budget reached or every dispatched member failed).
646
672
  CREATE TABLE IF NOT EXISTS free_chains (
647
673
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
package/backend/server.py CHANGED
@@ -26,6 +26,7 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
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
28
  from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member, route_held_messages
29
+ from .free_room import reorder_speakers, speaking_order, stop_room_work
29
30
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
30
31
  from .room_import import import_room, imported_attachment
31
32
  from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
@@ -84,6 +85,8 @@ FROM scheduler_decisions d LEFT JOIN scheduler_feedback f ON f.decision_id=d.id
84
85
  MAX_DECISION_LABEL = 200
85
86
  ROUTING_VISIBLE_SECONDS = 120
86
87
  MAX_DECISION_DETAIL = 4000
88
+ MAX_DECISION_INPUT = 60000
89
+ FEEDBACK_CASES_PAGE = 50
87
90
  SCHEDULER_PROVIDERS = ("jev", "decisions")
88
91
  ROOM_BACKUP_QUERIES = {
89
92
  "rooms": "SELECT * FROM rooms WHERE id=?",
@@ -110,10 +113,14 @@ FREE_BACKUP_QUERIES = {
110
113
  MESSAGE_QUERY = """SELECT messages.*, COALESCE(details.kind,'opinion') AS kind,
111
114
  COALESCE(details.submitted_by, CASE WHEN messages.source IN ('cli','api','url','user')
112
115
  THEN messages.author ELSE '用户' END) AS submitted_by, details.turn_id,
113
- COALESCE(summaries.text,'') AS summary, COALESCE(summaries.author,'') AS summary_by, timing.elapsed_seconds
116
+ COALESCE(summaries.text,'') AS summary, COALESCE(summaries.author,'') AS summary_by, timing.elapsed_seconds,
117
+ answered.execution_id, answered.request_message_id
114
118
  FROM messages LEFT JOIN message_details AS details ON details.message_id=messages.id
115
119
  LEFT JOIN message_summaries AS summaries ON summaries.message_id=messages.id
116
- LEFT JOIN message_timings AS timing ON timing.message_id=messages.id"""
120
+ LEFT JOIN message_timings AS timing ON timing.message_id=messages.id
121
+ LEFT JOIN (SELECT answer_id, MIN(execution_id) AS execution_id, MIN(message_id) AS request_message_id
122
+ FROM member_requests WHERE answer_id IS NOT NULL AND execution_id IS NOT NULL GROUP BY answer_id) AS answered
123
+ ON answered.answer_id=messages.id"""
117
124
 
118
125
 
119
126
  def room_backup(connection, room_id):
@@ -252,6 +259,7 @@ def require_room(connection, room_id):
252
259
  # Free rooms have no host hand-off: user messages go straight to the named members.
253
260
  room["held_since"] = 0
254
261
  room["free"] = free_view(connection, room_id)
262
+ room["speaking_order"] = speaking_order(connection, room)
255
263
  return room
256
264
 
257
265
 
@@ -621,6 +629,9 @@ def manage_room(connection, room_id, action, payload):
621
629
  elif action in ("archive", "restore"):
622
630
  if action == "archive" and room["phase"] != "concluded":
623
631
  save_topic_settings(connection, room_id, {**room, "phase": "paused"})
632
+ if action == "archive" and room["mode"] == "free":
633
+ # An archived room is read-only: nothing it still owes may be delivered or answered later.
634
+ stop_room_work(connection, room_id, "房间已归档", "archived")
624
635
  connection.execute("UPDATE room_directory SET archived=? WHERE room_id=?", (int(action == "archive"), room_id))
625
636
  elif action == "delete":
626
637
  if payload.get("confirm_title") != room["title"]:
@@ -634,10 +645,10 @@ def manage_room(connection, room_id, action, payload):
634
645
 
635
646
  def delete_room_records(connection, room_id):
636
647
  """Remove a room's dependent records in the caller's transaction."""
637
- for table in ("member_recommendations", "free_queue", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
648
+ for table in ("member_recommendations", "free_queue", "speaking_orders", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
638
649
  "discussion_options", "round_budgets", "scheduler_decisions", "scheduler_routing", "discussion_sessions"):
639
650
  if table == "scheduler_decisions":
640
- for child in ("scheduler_feedback", "scheduler_decision_context"):
651
+ for child in ("scheduler_feedback", "scheduler_decision_context", "scheduler_decision_inputs"):
641
652
  connection.execute(f"DELETE FROM {child} WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
642
653
  connection.execute("DELETE FROM scheduler_decision_details WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
643
654
  connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
@@ -1144,8 +1155,10 @@ def roster_view(room):
1144
1155
  last = room["rounds"][-1] if room["rounds"] else None
1145
1156
  spoken = set(last["speakers"]) if last and not last["completed"] else set()
1146
1157
  pending_registration = unregistered_experts(room)
1147
- experts = [{"name": name, "mode": mode, "host": name == room["host"], "spoken": name in spoken,
1148
- "registered": name not in pending_registration} for name, mode in room["experts"].items()]
1158
+ # Experts follow the room's speaking order, so a round plan can take the user's preferred order from the roster.
1159
+ rank = {item["name"]: index for index, item in enumerate(room["speaking_order"]["items"])}
1160
+ experts = [{"name": name, "mode": room["experts"][name], "host": name == room["host"], "spoken": name in spoken,
1161
+ "registered": name not in pending_registration} for name in sorted(room["experts"], key=lambda name: rank.get(name, len(rank)))]
1149
1162
  state = json.dumps([sorted(room["experts"]), sorted(spoken), sorted(pending_registration)], ensure_ascii=False)
1150
1163
  return {"experts": experts, "unregistered": pending_registration,
1151
1164
  "waiting": [item["name"] for item in experts if not item["spoken"] and not item["host"] and item["registered"]],
@@ -1227,9 +1240,34 @@ def rate_scheduler_decision(connection, room_id, decision_id, token, payload):
1227
1240
  connection.execute("""INSERT INTO scheduler_feedback (decision_id,rating,should_be,note,created_at) VALUES (?,?,?,?,?)
1228
1241
  ON CONFLICT(decision_id) DO UPDATE SET rating=excluded.rating,should_be=excluded.should_be,note=excluded.note,created_at=excluded.created_at""",
1229
1242
  (decision_id, rating, should_be.strip(), note.strip(), utc_now()))
1243
+ if rating == "bad":
1244
+ save_feedback_case(connection, room_id, decision_id, should_be.strip(), note.strip())
1245
+ else:
1246
+ connection.execute("DELETE FROM scheduler_feedback_cases WHERE decision_id=?", (decision_id,))
1230
1247
  return {"decision_id": decision_id, "feedback": rating, "should_be": should_be.strip(), "note": note.strip()}
1231
1248
 
1232
1249
 
1250
+ def save_feedback_case(connection, room_id, decision_id, should_be, note):
1251
+ """Copy a bad-rated decision with everything the model saw, so it outlives its room for prompt review."""
1252
+ decision = dict(connection.execute(f"{DECISION_SELECT} WHERE d.id=?", (decision_id,)).fetchone())
1253
+ stored = {name: connection.execute(f"SELECT {column} FROM {table} WHERE decision_id=?", (decision_id,)).fetchone()
1254
+ for name, table, column in (("detail", "scheduler_decision_details", "detail"), ("input", "scheduler_decision_inputs", "input"))}
1255
+ snapshot = {key: decision[key] for key in ("kind", "after_message_id", "label", "confidence", "applied", "provider", "created_at")}
1256
+ snapshot["context"] = json.loads(decision["context"]) if decision["context"] else None
1257
+ snapshot.update({name: json.loads(row[0]) if row else None for name, row in stored.items()})
1258
+ title = connection.execute("SELECT title FROM rooms WHERE id=?", (room_id,)).fetchone()[0]
1259
+ connection.execute("""INSERT OR REPLACE INTO scheduler_feedback_cases (decision_id,room_id,room_title,snapshot,should_be,note,rated_at)
1260
+ VALUES (?,?,?,?,?,?,?)""", (decision_id, room_id, title, json.dumps(snapshot, ensure_ascii=False), should_be, note, utc_now()))
1261
+
1262
+
1263
+ def list_feedback_cases(connection, after):
1264
+ """Bad-rated decision snapshots after a decision id, oldest first, for the administrator's prompt review."""
1265
+ rows = connection.execute("SELECT * FROM scheduler_feedback_cases WHERE decision_id>? ORDER BY decision_id LIMIT ?",
1266
+ (after, FEEDBACK_CASES_PAGE)).fetchall()
1267
+ cases = [{**dict(row), "snapshot": json.loads(row["snapshot"])} for row in rows]
1268
+ return {"cases": cases, "next_after": cases[-1]["decision_id"] if len(cases) == FEEDBACK_CASES_PAGE else None}
1269
+
1270
+
1233
1271
  def mark_scheduler_routing(connection, room_id, token, payload):
1234
1272
  """The local service marks a message while the decision model is analysing it, and clears the mark when done."""
1235
1273
  begin_write(connection)
@@ -1273,6 +1311,12 @@ def record_scheduler_decision(connection, room_id, token, payload, owner=False):
1273
1311
  if not isinstance(detail, list) or len(detail) > 4 or len(text) > MAX_DECISION_DETAIL:
1274
1312
  raise ValueError(f"detail 须为最多 4 项、不超过 {MAX_DECISION_DETAIL} 字的列表")
1275
1313
  connection.execute("INSERT INTO scheduler_decision_details (decision_id,detail) VALUES (?,?)", (cursor.lastrowid, text))
1314
+ model_input = payload.get("input")
1315
+ if model_input is not None:
1316
+ text = json.dumps(model_input, ensure_ascii=False)
1317
+ if not isinstance(model_input, dict) or len(text) > MAX_DECISION_INPUT:
1318
+ raise ValueError(f"input 须为不超过 {MAX_DECISION_INPUT} 字的对象")
1319
+ connection.execute("INSERT INTO scheduler_decision_inputs (decision_id,input) VALUES (?,?)", (cursor.lastrowid, text))
1276
1320
  return dict(connection.execute("SELECT * FROM scheduler_decisions WHERE id=?", (cursor.lastrowid,)).fetchone())
1277
1321
 
1278
1322
 
@@ -1895,6 +1939,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1895
1939
  return self.respond({"ok": True})
1896
1940
  if parts[:2] == ["api", "admin"]:
1897
1941
  return self.handle_admin_api(connection, parts, method)
1942
+ if parts == ["api", "scheduler-feedback"] and method == "GET":
1943
+ if not self.admin_session(connection):
1944
+ raise AdminError("需要登录管理员账号", 401)
1945
+ return self.respond(list_feedback_cases(connection, int(params.get("after", 0))))
1898
1946
  if parts == ["api", "rooms"]:
1899
1947
  if method == "GET":
1900
1948
  return self.respond({**list_rooms(connection, params), "participants": PARTICIPANTS})
@@ -1943,6 +1991,15 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1943
1991
  result = register_member(connection, room_id, token, utc_now())
1944
1992
  connection.commit()
1945
1993
  return self.respond(result)
1994
+ if parts[3:] == ["speaking-order"]:
1995
+ if method == "GET":
1996
+ return self.respond(require_room(connection, room_id)["speaking_order"])
1997
+ payload = self.read_payload()
1998
+ begin_write(connection)
1999
+ reorder_speakers(connection, require_room(connection, room_id), token, payload)
2000
+ result = require_room(connection, room_id)
2001
+ connection.commit()
2002
+ return self.respond(result)
1946
2003
  free = room_mode(connection, room_id) == "free"
1947
2004
  # A discussion keeps the local service's inbox and per-member executions for the user's direct questions.
1948
2005
  if parts[3:4] in (["inbox"], ["executions"]) or (free and parts[3:4] in (["members"], ["suggestions"], ["recommendations"])):