ai_co_discuss 0.2.70 → 0.2.72

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 落后都会被拒绝):讨论结束,房间回到自由协作。
@@ -34,7 +34,7 @@
34
34
 
35
35
  ## 在线专家:协助方式与操作恢复
36
36
 
37
- 每次请专家回答时,要求同时提供简述与完整正文;简述的写法、保存与署名遵循 discuss-participant。若原回答缺少简述,主持人可据完整原文另行整理并注明摘要归主持人,专家正文保持原样。
37
+ 请专家回答时,回答较长的同时提供简述与完整正文;简述的写法、保存与署名遵循 discuss-participant。若长回答缺少简述,主持人可据完整原文另行整理并注明摘要归主持人,专家正文保持原样。
38
38
 
39
39
  选择「用户手动复制协助」时,每轮在当前聊天给出完整、可复制的提问文本和对应安排编号,由用户发到专家原聊天并贴回完整回答;主持人不自行操作该专家网页或发送问题。用户确认送达后再记录通知,收到并核对对应回答后通过 CLI 原文代发。展示提问文本不算送达,等待用户期间不重复催促。
40
40
 
@@ -81,7 +81,7 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
81
81
 
82
82
  可以坚持有依据的少数意见,也可以因新证据修改判断并解释变化。主动检查关键反例与遗漏,但不为反对而反对,不以赞同人数代替证据;材料不足时说明什么证据会改变结论。需要对方回应时直接 `@专家名` 并说明具体问题;@ 只是讨论线索,不会通知。
83
83
 
84
- 篇幅和结构由问题决定。保留关键数字、条件与失败情形;回应追问只补新论证或观点变化,旧内容用原文编号引用。简述表达主要判断或变化,完整论证与出处放正文,不再复制一遍长清单。
84
+ 篇幅和结构由问题决定。保留关键数字、条件与失败情形;回应追问只补新论证或观点变化,旧内容用原文编号引用。回答摘要按需填写(讨论与自由协作相同):正文较长、读者需要先看结论时,用一两句写主要判断或变化及关键条件;简短回答不写,不把正文复述一遍。完整论证与出处放正文,不再复制一遍长清单。
85
85
 
86
86
  示例(只示意密度与结构,内容与篇幅按问题而定):
87
87
 
@@ -6,6 +6,8 @@
6
6
 
7
7
  没有安排和阶段,不执行 `execution enter`。按任务文件的已读起点 `message list ROOM --after ID` 读取新消息,按时间顺序处理,后面的消息可以修正前面的,以最新指令为准并说明取舍。完成后用 `message post ROOM --as 本人 --kind chat --execution 执行编号 --access-file FILE` 提交一次回答。
8
8
 
9
+ 任务需要多步操作时,在关键转折处(读完材料、改定方案、验证结果)用一两句中文写出进展,不另调命令:本机接入程序会把这些中途说明显示在网页的处理中卡片,让用户知道你在做什么。只写进展与判断,不复述工具输出,不代替最终回答。
10
+
9
11
  提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 `--execution` 改作普通发言,因为那会让已失效的执行继续产生可见结果。
10
12
 
11
13
  手动接入的自由成员没有执行编号:从 `room view` 的 `free.requests` 找到自己的待处理请求,回答时用 `--request 请求编号` 关闭。
@@ -24,6 +24,12 @@ RECENT_REQUESTS = 100
24
24
  RECENT_EXECUTIONS = 100
25
25
  RECENT_CHANGES = 50
26
26
  MAX_CHANGED_FILES = 200
27
+ # What a member's software last showed it doing, as reported by the runner with each renewal.
28
+ ACTIVITY_PHASES = ("", "thinking", "command", "tool", "editing", "writing")
29
+ # Progress notes per execution, their length, and how many one renewal may carry.
30
+ MAX_EXECUTION_NOTES = 50
31
+ MAX_NOTE_LENGTH = 500
32
+ MAX_NOTES_PER_RENEW = 10
27
33
  MAX_PATH_LENGTH = 500
28
34
  ACTIVE = ("dispatched", "started")
29
35
  ACCESS_MODES = ("runner", "manual", "online")
@@ -44,10 +50,12 @@ QUEUE_ASKED = "EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id={row}.m
44
50
  SESSION_MODES = ("new", "borrowed")
45
51
  FREE_OPERATIONS = ("messages", "attachments", "move", "folder", "archive", "restore", "delete")
46
52
  MEMBER_FIELDS = ("id", "name", "adapter", "role", "status", "access", "session_mode", "read_until")
47
- EXECUTION_FIELDS = ("id", "member_id", "status", "session_id", "lease_until", "created_at", "finished_at", "result")
53
+ EXECUTION_FIELDS = ("id", "member_id", "status", "session_id", "lease_until", "created_at", "finished_at", "result",
54
+ "activity", "activity_at")
48
55
  # Columns added after the first free-room tables; Python databases created by that build gain them here.
49
56
  LATER_COLUMNS = {"room_members": {"access": "TEXT NOT NULL DEFAULT 'runner'", "session_mode": "TEXT NOT NULL DEFAULT 'new'"},
50
- "member_executions": {"changes": "TEXT NOT NULL DEFAULT ''", "cwd": "TEXT NOT NULL DEFAULT ''"},
57
+ "member_executions": {"changes": "TEXT NOT NULL DEFAULT ''", "cwd": "TEXT NOT NULL DEFAULT ''",
58
+ "activity": "TEXT NOT NULL DEFAULT ''", "activity_at": "INTEGER NOT NULL DEFAULT 0"},
51
59
  "member_requests": {"chain_id": "INTEGER NOT NULL DEFAULT 0", "batch_id": "INTEGER NOT NULL DEFAULT 0",
52
60
  "purpose": "TEXT NOT NULL DEFAULT 'ask'", "created_by": "TEXT NOT NULL DEFAULT 'user'"}}
53
61
 
@@ -187,16 +195,30 @@ def public_execution(row):
187
195
  return {key: row[key] for key in EXECUTION_FIELDS} if row else None
188
196
 
189
197
 
198
+ def note_count(connection, execution_id):
199
+ """How many progress notes an execution has; the texts are read separately so room views stay small."""
200
+ return connection.execute("SELECT COUNT(*) FROM execution_notes WHERE execution_id=?", (execution_id,)).fetchone()[0]
201
+
202
+
203
+ def execution_notes(connection, room_id, execution_id):
204
+ """Read one execution's progress notes in order, with the same access as the room view."""
205
+ if not connection.execute("SELECT 1 FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone():
206
+ raise LookupError("执行不存在")
207
+ return {"notes": [dict(row) for row in connection.execute(
208
+ "SELECT seq,text,created_at FROM execution_notes WHERE execution_id=? ORDER BY seq", (execution_id,))]}
209
+
210
+
190
211
  def recent_executions(connection, room_id):
191
212
  """Return bounded public reports, including requests no longer in the recent request list."""
192
213
  rows = connection.execute("""SELECT e.*,
193
214
  (SELECT json_group_array(id) FROM member_requests WHERE execution_id=e.id) AS request_ids,
194
215
  (SELECT MAX(answer_id) FROM member_requests WHERE execution_id=e.id) AS answer_id,
195
- (SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id
216
+ (SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id,
217
+ (SELECT COUNT(*) FROM execution_notes WHERE execution_id=e.id) AS note_count
196
218
  FROM member_executions e WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_EXECUTIONS))
197
219
  return [{**public_execution(row), "cwd": row["cwd"], "changes": json.loads(row["changes"] or "[]"),
198
220
  "changes_recorded": bool(row["changes"]), "request_ids": json.loads(row["request_ids"]),
199
- "answer_id": row["answer_id"], "request_message_id": row["request_message_id"]} for row in rows]
221
+ "answer_id": row["answer_id"], "request_message_id": row["request_message_id"], "notes": row["note_count"]} for row in rows]
200
222
 
201
223
 
202
224
  def possible_conflicts(connection, room_id, names):
@@ -245,6 +267,7 @@ def free_view(connection, room_id):
245
267
  member["execution"] = public_execution(latest)
246
268
  if latest and latest["status"] in ACTIVE:
247
269
  member["execution"]["stale"] = latest["lease_until"] <= now
270
+ member["execution"]["notes"] = note_count(connection, latest["id"])
248
271
  member["pending"] = connection.execute("SELECT COUNT(*) FROM member_requests WHERE member_id=? AND status='pending'",
249
272
  (row["id"],)).fetchone()[0]
250
273
  members.append(member)
@@ -336,12 +359,18 @@ def recipients(connection, room_id, payload):
336
359
 
337
360
  A single active member is named by default. With several, an unnamed message goes to the
338
361
  AI host for routing; only a room of online members has none, and then it stays a plain note.
362
+ Naming everyone with order auto leaves the host a group request: the runner has everyone answer
363
+ at once or one after another; a host the runner does not wake has them all answer at once.
339
364
  """
340
365
  # Rooms created while the user could host get their AI host on the next message.
341
366
  sync_participants(connection, room_id)
342
367
  active = active_members(connection, room_id)
343
368
  if payload.get("to") is not None:
344
- return [(member_id, "ask") for member_id in named_members(active, payload["to"])]
369
+ named = named_members(active, payload["to"])
370
+ host = next((row for row in active.values() if row["role"] == "host"), None)
371
+ if payload.get("order") == "auto" and len(named) > 1 and host and host["access"] == "runner":
372
+ return [(host["id"], "group")]
373
+ return [(member_id, "ask") for member_id in named]
345
374
  if len(active) == 1:
346
375
  return [(next(iter(active.values()))["id"], "ask")]
347
376
  host = next((row for row in active.values() if row["role"] == "host"), None)
@@ -587,12 +616,13 @@ def speaking_order(connection, room):
587
616
  """Who spoke, who speaks now and who is next, shared by free rooms and discussions; only the not-yet-spoken part may be reordered.
588
617
 
589
618
  A free room shows its latest unfinished one-after-another queue, else every member in the room's default order;
590
- a discussion shows the open round: speakers so far, the current turn, then queued turns. The revision changes with any of it.
619
+ a discussion shows the open round: speakers so far, the current turn, then this round's queued turns; a plan reaching into
620
+ later rounds lists them apart in `later` (one name list per round), read-only. The revision changes with any of it.
591
621
  """
592
622
  active = [member for member in room["members"] if member["status"] == "active"]
593
623
  by_id = {member["id"]: member for member in active}
594
624
  ordered = [member["name"] for member in default_order(connection, room["id"], active)]
595
- items, kind, target = [], "default", 0
625
+ items, kind, target, later = [], "default", 0, []
596
626
  if room["mode"] == "free":
597
627
  target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
598
628
  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,22 +636,37 @@ def speaking_order(connection, room):
606
636
  else:
607
637
  kind = "turns"
608
638
  last = room["rounds"][-1] if room["rounds"] else None
609
- items = [{"name": name, "state": "done"} for name in (last["speakers"] if last and not last["completed"] else [])]
610
639
  turn = room["current_turn"]
611
- if turn and turn["status"] in ("pending", "notified"):
612
- items.append({"name": turn["expert"], "state": "speaking"})
613
- items += [{"name": item["expert"], "state": "queued"} for item in room["queued_turns"]]
640
+ speaking = turn["expert"] if turn and turn["status"] in ("pending", "notified") else None
641
+ items = [{"name": name, "state": "done"} for name in (last["speakers"] if last and not last["completed"] else []) if name != speaking]
642
+ if speaking:
643
+ items.append({"name": speaking, "state": "speaking"})
644
+ rounds = split_rounds([item["expert"] for item in room["queued_turns"]], {item["name"] for item in items})
645
+ items += [{"name": name, "state": "queued"} for name in rounds[0]]
646
+ later = rounds[1:]
614
647
  # The user may still hold the host seat of an old discussion; only AI participants have a place.
615
648
  ordered = [name for name in [room["host"], *ordered, *room["experts"]] if (name in room["experts"] or name == room["host"]) and name != "用户"]
616
649
  listed = {item["name"] for item in items}
617
650
  items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
618
651
  movable = "queued" if kind != "default" else "idle"
619
- state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items]], ensure_ascii=False)
620
- return {"kind": kind, "message_id": target, "items": items, "movable": [item["name"] for item in items if item["state"] == movable],
652
+ state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later], ensure_ascii=False)
653
+ return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
621
654
  "default": [member["name"] for member in default_order(connection, room["id"], active)],
622
655
  "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
623
656
 
624
657
 
658
+ def split_rounds(names, spoken):
659
+ """Cut a turn queue into rounds: an expert who already spoke in the round being filled starts the next one."""
660
+ rounds, seen = [[]], set(spoken)
661
+ for name in names:
662
+ if name in seen:
663
+ rounds.append([])
664
+ seen = set()
665
+ rounds[-1].append(name)
666
+ seen.add(name)
667
+ return rounds
668
+
669
+
625
670
  def reorder_speakers(connection, room, token, payload):
626
671
  """Owner reorders the not-yet-spoken part of the speaking order; who already spoke or speaks now stays put.
627
672
 
@@ -654,13 +699,18 @@ def reorder_speakers(connection, room, token, payload):
654
699
 
655
700
 
656
701
  def reorder_turns(connection, room_id, order):
657
- """Move queued turns into the new order; ids keep the relay order, so each turn's content moves to the id at its new place."""
658
- rows = [dict(row) for row in connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id", (room_id,))]
702
+ """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.
703
+
704
+ This round's turns lead the queue, so they are its first rows; later rounds keep theirs.
705
+ """
706
+ rows = [dict(row) for row in connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id LIMIT ?",
707
+ (room_id, len(order)))]
659
708
  remaining, moved = list(rows), []
660
709
  for name in order:
661
710
  moved.append(remaining.pop(next(index for index, row in enumerate(remaining) if row["expert"] == name)))
662
711
  # Submission ids are unique, so free them before writing them to their new rows.
663
- connection.execute("UPDATE discussion_turns SET submission_id=submission_id||':moving:'||id WHERE room_id=? AND status='queued'", (room_id,))
712
+ for slot in rows:
713
+ connection.execute("UPDATE discussion_turns SET submission_id=submission_id||':moving:'||id WHERE id=?", (slot["id"],))
664
714
  for slot, content in zip(rows, moved):
665
715
  connection.execute("UPDATE discussion_turns SET expert=?,question=?,submission_id=?,created_at=? WHERE id=?",
666
716
  (content["expert"], content["question"], content["submission_id"], content["created_at"], slot["id"]))
@@ -772,19 +822,20 @@ def finish_execution(connection, execution_id, status, result, request_status, c
772
822
 
773
823
 
774
824
  def open_route_messages(connection, member_id, pending_only=False):
775
- """Messages whose host request is still open, in order; they need a new host if this one leaves."""
825
+ """(message, purpose) of host requests still open, in order; they need a new host if this one leaves."""
776
826
  states = "'pending'" if pending_only else "'pending','delivering'"
777
- return [row[0] for row in connection.execute(
778
- f"SELECT message_id FROM member_requests WHERE member_id=? AND purpose='route' AND status IN ({states}) ORDER BY message_id", (member_id,))]
827
+ return [(row[0], row[1]) for row in connection.execute(
828
+ f"""SELECT message_id,purpose FROM member_requests WHERE member_id=? AND purpose IN ('route','group')
829
+ AND status IN ({states}) ORDER BY message_id""", (member_id,))]
779
830
 
780
831
 
781
- def hand_over_routes(connection, room_id, message_ids, departed_id):
832
+ def hand_over_routes(connection, room_id, routes, departed_id):
782
833
  """Ask the current host to route messages left behind by the previous host."""
783
834
  host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active' AND id<>?",
784
835
  (room_id, departed_id or 0)).fetchone()
785
- for message_id in message_ids if host else []:
836
+ for message_id, purpose in routes if host else []:
786
837
  connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
787
- VALUES (?,?,?,?,'route','user',?)""", (room_id, host["id"], message_id, message_id, int(time.time())))
838
+ VALUES (?,?,?,?,?,'user',?)""", (room_id, host["id"], message_id, message_id, purpose, int(time.time())))
788
839
 
789
840
 
790
841
  def stop_member_work(connection, member_id, reason, cancel_reason):
@@ -865,9 +916,26 @@ def release_recommendations(connection, room_id):
865
916
  def route_wide(connection, room_id, message_id, action):
866
917
  """Smart scheduling answers an unnamed message with everyone (each member once, as if the user named them all) or no one.
867
918
 
919
+ A message naming everyone (a group request) is answered by all at once or, with action sequential, one after another.
868
920
  Only a host request that has not started changes; a started host already handles the message its own way.
869
921
  """
870
922
  route = host_route_request(connection, message_id)
923
+ group = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND purpose='group'", (message_id,)).fetchone()
924
+ if group and group["room_id"] == room_id:
925
+ if action not in ("all", "sequential"):
926
+ raise ValueError("请全体成员回应的消息只能选 action=all 或 sequential")
927
+ if group["status"] != "pending":
928
+ raise FileExistsError("这条消息已不再等待安排")
929
+ if action == "sequential":
930
+ # The row is removed rather than ended so the host takes its place in the queue like everyone else.
931
+ connection.execute("DELETE FROM member_requests WHERE id=?", (group["id"],))
932
+ members = connection.execute(f"SELECT id FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))
933
+ queue_in_turn(connection, room_id, message_id, [row["id"] for row in members])
934
+ requests = connection.execute("SELECT * FROM member_requests WHERE message_id=? ORDER BY id", (message_id,)).fetchall()
935
+ return {"action": action, "requests": [dict(row) for row in requests]}
936
+ route = group
937
+ elif action == "sequential":
938
+ raise ValueError("只有请全体成员回应的消息可以依次回答")
871
939
  if not route or route["room_id"] != room_id:
872
940
  raise LookupError("只能处理交给主持人的未点名消息")
873
941
  if route["status"] != "pending":
@@ -892,10 +960,10 @@ def adopt_recommendation(connection, room_id, token, message_id, payload):
892
960
  """
893
961
  require_owner(connection, room_id, token)
894
962
  begin_write(connection)
895
- if payload.get("action") in ("all", "none"):
963
+ if payload.get("action") in ("all", "none", "sequential"):
896
964
  return route_wide(connection, room_id, message_id, payload["action"])
897
965
  if payload.get("action") != "adopt" or payload.get("mode") not in ("interrupt", "after"):
898
- raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体或无需回应为 action=all 或 none")
966
+ raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体、依次或无需回应为 action=all、sequential 或 none")
899
967
  row = connection.execute("SELECT * FROM member_recommendations WHERE room_id=? AND message_id=?", (room_id, message_id)).fetchone()
900
968
  if not row:
901
969
  raise LookupError("这条消息没有智能调度推荐")
@@ -950,12 +1018,13 @@ def reserve_execution(connection, room_id, token, payload):
950
1018
  if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND participant=? AND status IN ('dispatched','started')",
951
1019
  (room_id, member["name"])).fetchone():
952
1020
  raise FileExistsError("该成员正在处理讨论安排;同一会话不并发续接")
953
- if not connection.execute("SELECT 1 FROM member_requests WHERE member_id=? AND status='pending'", (member["id"],)).fetchone():
1021
+ # A group request waits for the runner to choose how everyone answers; it is never delivered itself.
1022
+ if not connection.execute("SELECT 1 FROM member_requests WHERE member_id=? AND status='pending' AND purpose<>'group'", (member["id"],)).fetchone():
954
1023
  raise FileExistsError("该成员没有待投递的请求")
955
1024
  now = int(time.time())
956
1025
  cursor = connection.execute("""INSERT INTO member_executions (room_id,member_id,client_id,lease_until,created_at,cwd)
957
1026
  VALUES (?,?,?,?,?,?)""", (room_id, member["id"], client_id, now + LEASE_SECONDS, now, str(payload.get("cwd") or "")[:MAX_PATH_LENGTH]))
958
- connection.execute("UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending'",
1027
+ connection.execute("UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending' AND purpose<>'group'",
959
1028
  (cursor.lastrowid, member["id"]))
960
1029
  return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (cursor.lastrowid,)).fetchone())
961
1030
 
@@ -991,6 +1060,26 @@ def changed_files(payload):
991
1060
  return sorted(set(files))
992
1061
 
993
1062
 
1063
+ def renewed_activity(payload, row, now):
1064
+ """Validate the runner's reported phase; its output time is kept within the execution, since the runner's clock may differ."""
1065
+ activity, activity_at = payload.get("activity"), payload.get("activity_at")
1066
+ if activity not in ACTIVITY_PHASES:
1067
+ raise ValueError(f"activity 须为 {'、'.join(item for item in ACTIVITY_PHASES if item)} 或空")
1068
+ if type(activity_at) is not int:
1069
+ raise ValueError("activity_at 须为秒级时间戳")
1070
+ return activity, min(max(activity_at, row["created_at"]), now)
1071
+
1072
+
1073
+ def renewed_notes(payload):
1074
+ """Validate progress notes carried by a renewal; a repeated sequence number is the same note resent."""
1075
+ notes = payload.get("notes", [])
1076
+ if (not isinstance(notes, list) or len(notes) > MAX_NOTES_PER_RENEW or any(
1077
+ not isinstance(note, dict) or type(note.get("seq")) is not int or not 1 <= note["seq"] <= MAX_EXECUTION_NOTES
1078
+ or not isinstance(note.get("text"), str) or not 0 < len(note["text"]) <= MAX_NOTE_LENGTH for note in notes)):
1079
+ raise ValueError(f"notes 须为最多 {MAX_NOTES_PER_RENEW} 条 {{seq: 1–{MAX_EXECUTION_NOTES}, text: 1–{MAX_NOTE_LENGTH} 字}}")
1080
+ return notes
1081
+
1082
+
994
1083
  def execution_action(connection, room_id, execution_id, token, payload):
995
1084
  """Runner-side lifecycle: renew, fail, release undelivered work, or report observed file changes."""
996
1085
  require_owner(connection, room_id, token)
@@ -1011,7 +1100,13 @@ def execution_action(connection, room_id, execution_id, token, payload):
1011
1100
  limit = row["created_at"] + execution_minutes(connection, room_id) * 60
1012
1101
  if row["lease_until"] <= now or limit <= now:
1013
1102
  raise FileExistsError("执行租约或单次时限已到,不能续租")
1014
- connection.execute("UPDATE member_executions SET lease_until=? WHERE id=?", (min(now + LEASE_SECONDS, limit), execution_id))
1103
+ # Older runners renew without activity; the last reported one is kept.
1104
+ activity, activity_at = renewed_activity(payload, row, now) if "activity" in payload else (row["activity"], row["activity_at"])
1105
+ notes = renewed_notes(payload)
1106
+ connection.execute("UPDATE member_executions SET lease_until=?,activity=?,activity_at=? WHERE id=?",
1107
+ (min(now + LEASE_SECONDS, limit), activity, activity_at, execution_id))
1108
+ connection.executemany("INSERT OR IGNORE INTO execution_notes (execution_id,seq,room_id,text,created_at) VALUES (?,?,?,?,?)",
1109
+ [(execution_id, note["seq"], room_id, note["text"], now) for note in notes])
1015
1110
  elif action == "fail":
1016
1111
  finish_execution(connection, execution_id, "failed", str(payload.get("reason") or "执行失败"), "failed")
1017
1112
  settle_batches(connection, room_id)
@@ -1218,8 +1313,8 @@ def member_action(connection, room_id, member_id, token, payload):
1218
1313
  old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
1219
1314
  if old_host:
1220
1315
  routed = open_route_messages(connection, old_host["id"], pending_only=True)
1221
- record_cancel_reasons(connection, "member_id=? AND purpose='route' AND status='pending'", (old_host["id"],), "rerouted")
1222
- connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND purpose='route' AND status='pending'", (old_host["id"],))
1316
+ record_cancel_reasons(connection, "member_id=? AND purpose IN ('route','group') AND status='pending'", (old_host["id"],), "rerouted")
1317
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND purpose IN ('route','group') AND status='pending'", (old_host["id"],))
1223
1318
  connection.execute("UPDATE room_members SET role='member' WHERE room_id=? AND role='host'", (room_id,))
1224
1319
  connection.execute("UPDATE room_members SET role=? WHERE id=?", (role, member_id))
1225
1320
  else:
@@ -532,8 +532,19 @@ CREATE TABLE IF NOT EXISTS member_executions (
532
532
  result TEXT NOT NULL DEFAULT '',
533
533
  changes TEXT NOT NULL DEFAULT '',
534
534
  cwd TEXT NOT NULL DEFAULT '',
535
+ activity TEXT NOT NULL DEFAULT '',
536
+ activity_at INTEGER NOT NULL DEFAULT 0,
535
537
  UNIQUE(room_id,client_id)
536
538
  );
539
+ -- Progress notes the runner saw a member's software write between tool calls; numbered per execution so a retried renewal adds nothing.
540
+ CREATE TABLE IF NOT EXISTS execution_notes (
541
+ execution_id INTEGER NOT NULL REFERENCES member_executions(id) ON DELETE CASCADE,
542
+ seq INTEGER NOT NULL,
543
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
544
+ text TEXT NOT NULL,
545
+ created_at INTEGER NOT NULL,
546
+ PRIMARY KEY(execution_id,seq)
547
+ );
537
548
  -- One live execution per member: different members may run in parallel, one session never twice.
538
549
  CREATE UNIQUE INDEX IF NOT EXISTS one_member_execution ON member_executions(member_id)
539
550
  WHERE status IN ('dispatched','started');
@@ -612,6 +623,21 @@ CREATE TABLE IF NOT EXISTS scheduler_decision_context (
612
623
  decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
613
624
  context TEXT NOT NULL
614
625
  );
626
+ -- The exact input the decision model saw (state and full questions with option descriptions); not part of backups.
627
+ CREATE TABLE IF NOT EXISTS scheduler_decision_inputs (
628
+ decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
629
+ input TEXT NOT NULL
630
+ );
631
+ -- A self-contained copy of each decision the owner currently rates bad, kept when its room is deleted so prompts can be reviewed.
632
+ CREATE TABLE IF NOT EXISTS scheduler_feedback_cases (
633
+ decision_id INTEGER PRIMARY KEY,
634
+ room_id TEXT NOT NULL,
635
+ room_title TEXT NOT NULL,
636
+ snapshot TEXT NOT NULL,
637
+ should_be TEXT NOT NULL DEFAULT '',
638
+ note TEXT NOT NULL DEFAULT '',
639
+ rated_at TEXT NOT NULL
640
+ );
615
641
  -- Smart scheduling's suggestion that another member answer an unnamed message. The host keeps the message;
616
642
  -- the user may adopt before expires_at (waiting until the host's request ends for mode 'after'), otherwise it lapses.
617
643
  CREATE TABLE IF NOT EXISTS member_recommendations (
package/backend/server.py CHANGED
@@ -26,7 +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
+ from .free_room import execution_notes, reorder_speakers, speaking_order, stop_room_work
30
30
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
31
31
  from .room_import import import_room, imported_attachment
32
32
  from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
@@ -61,7 +61,8 @@ ASSETS = {"/": ("index.html", "text/html"),
61
61
  "/markdown.js": ("markdown.js", "text/javascript"),
62
62
  "/backup.js": ("backup.js", "text/javascript"),
63
63
  "/markdown.css": ("markdown.css", "text/css"),
64
- "/icons/LICENSE.txt": ("icons/LICENSE.txt", "text/plain")}
64
+ "/icons/LICENSE.txt": ("icons/LICENSE.txt", "text/plain"),
65
+ "/icons/typesafe.png": ("icons/typesafe.png", "image/png")}
65
66
  ASSETS.update({f"/icons/{name}.svg": (f"icons/{name}.svg", "image/svg+xml")
66
67
  for name in ("openai", "claude", "gemini", "deepseek", "qwen", "user")})
67
68
  ASSETS.update({"/pdfjs/" + file.relative_to(WEB_ROOT / "pdfjs").as_posix():
@@ -85,6 +86,8 @@ FROM scheduler_decisions d LEFT JOIN scheduler_feedback f ON f.decision_id=d.id
85
86
  MAX_DECISION_LABEL = 200
86
87
  ROUTING_VISIBLE_SECONDS = 120
87
88
  MAX_DECISION_DETAIL = 4000
89
+ MAX_DECISION_INPUT = 60000
90
+ FEEDBACK_CASES_PAGE = 50
88
91
  SCHEDULER_PROVIDERS = ("jev", "decisions")
89
92
  ROOM_BACKUP_QUERIES = {
90
93
  "rooms": "SELECT * FROM rooms WHERE id=?",
@@ -111,10 +114,14 @@ FREE_BACKUP_QUERIES = {
111
114
  MESSAGE_QUERY = """SELECT messages.*, COALESCE(details.kind,'opinion') AS kind,
112
115
  COALESCE(details.submitted_by, CASE WHEN messages.source IN ('cli','api','url','user')
113
116
  THEN messages.author ELSE '用户' END) AS submitted_by, details.turn_id,
114
- COALESCE(summaries.text,'') AS summary, COALESCE(summaries.author,'') AS summary_by, timing.elapsed_seconds
117
+ COALESCE(summaries.text,'') AS summary, COALESCE(summaries.author,'') AS summary_by, timing.elapsed_seconds,
118
+ answered.execution_id, answered.request_message_id
115
119
  FROM messages LEFT JOIN message_details AS details ON details.message_id=messages.id
116
120
  LEFT JOIN message_summaries AS summaries ON summaries.message_id=messages.id
117
- LEFT JOIN message_timings AS timing ON timing.message_id=messages.id"""
121
+ LEFT JOIN message_timings AS timing ON timing.message_id=messages.id
122
+ LEFT JOIN (SELECT answer_id, MIN(execution_id) AS execution_id, MIN(message_id) AS request_message_id
123
+ FROM member_requests WHERE answer_id IS NOT NULL AND execution_id IS NOT NULL GROUP BY answer_id) AS answered
124
+ ON answered.answer_id=messages.id"""
118
125
 
119
126
 
120
127
  def room_backup(connection, room_id):
@@ -217,12 +224,14 @@ def require_room(connection, room_id):
217
224
  room["round_budget"] = dict(budget) if budget else None
218
225
  if budget:
219
226
  room["opinion_count"] = opinion_count(connection, room_id, room["experts"])
220
- room["round_groups"] = budget_round_groups(connection, room_id, room["experts"], budget["base"]) if budget else room["rounds"]
227
+ 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()
228
+ room["session"] = dict(session) if session else None
229
+ room["round_groups"] = budget_round_groups(connection, room_id, room["experts"], budget["base"], room["session"]) if budget else room["rounds"]
221
230
  room["runtime"] = runtime_view(connection, room_id)
222
231
  room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
223
232
  room["mode"] = room_mode(connection, room_id)
224
- 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()
225
- room["session"] = dict(session) if session else None
233
+ if room["mode"] == "free" and not session:
234
+ room["round_groups"] = []
226
235
  room["members"] = [dict(row) for row in connection.execute(
227
236
  """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,
228
237
  COALESCE(r.registered_at,'') AS registered_at
@@ -320,27 +329,33 @@ def discussion_rounds(connection, room_id, experts):
320
329
  return rounds
321
330
 
322
331
 
323
- def budget_round_groups(connection, room_id, experts, base):
332
+ def budget_round_groups(connection, room_id, experts, base, session):
324
333
  """Group history by the round budget's rule: every len(experts) opinions form a round, and a new segment (base) starts a new one.
325
334
 
335
+ Only messages of the latest discussion are grouped, numbered from 1; free collaboration before or after it has no rounds.
326
336
  `rounds` stays speaker-based because scheduling needs who still owes a turn; this view only labels history.
327
337
  """
328
- if not experts:
338
+ if not experts or not session:
329
339
  return []
340
+ first, last = session["started_message_id"], session["ended_message_id"] or float("inf")
330
341
  rows = connection.execute("""SELECT messages.id,messages.author,
331
342
  COALESCE(details.kind,'opinion') AS kind FROM messages
332
343
  LEFT JOIN message_details AS details ON details.message_id=messages.id
333
344
  WHERE room_id=? ORDER BY messages.id""", (room_id,))
334
345
  groups, next_start, seen = [], None, 0
335
346
  for message in rows:
347
+ opinion = message["kind"] == "opinion" and message["author"] in experts
348
+ # base counts every opinion in the room, so opinions outside the discussion still advance seen.
349
+ seen += opinion
350
+ if not first < message["id"] <= last:
351
+ continue
336
352
  if next_start is None:
337
353
  next_start = message["id"]
338
- if message["kind"] != "opinion" or message["author"] not in experts:
354
+ if not opinion:
339
355
  continue
340
356
  if not groups or groups[-1]["completed"]:
341
357
  groups.append({"number": len(groups) + 1, "start_id": next_start, "opinions": 0, "completed": False})
342
358
  current = groups[-1]
343
- seen += 1
344
359
  current["opinions"] += 1
345
360
  current["completed"] = current["opinions"] >= len(experts) or seen == base
346
361
  if current["completed"]:
@@ -639,10 +654,10 @@ def manage_room(connection, room_id, action, payload):
639
654
 
640
655
  def delete_room_records(connection, room_id):
641
656
  """Remove a room's dependent records in the caller's transaction."""
642
- 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",
657
+ for table in ("member_recommendations", "free_queue", "speaking_orders", "member_requests", "execution_notes", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
643
658
  "discussion_options", "round_budgets", "scheduler_decisions", "scheduler_routing", "discussion_sessions"):
644
659
  if table == "scheduler_decisions":
645
- for child in ("scheduler_feedback", "scheduler_decision_context"):
660
+ for child in ("scheduler_feedback", "scheduler_decision_context", "scheduler_decision_inputs"):
646
661
  connection.execute(f"DELETE FROM {child} WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
647
662
  connection.execute("DELETE FROM scheduler_decision_details WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
648
663
  connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
@@ -760,7 +775,8 @@ def start_discussion(connection, room_id, token, payload):
760
775
  raise ValueError("named_followup 须为布尔值")
761
776
  # A fresh session first, so the phase mirror follows it and not a concluded earlier one.
762
777
  if not connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND status IN ('active','paused')", (room_id,)).fetchone():
763
- connection.execute("INSERT INTO discussion_sessions (room_id,goal,status,created_at) VALUES (?,?,'paused',?)", (room_id, settings["goal"], utc_now()))
778
+ connection.execute("INSERT INTO discussion_sessions (room_id,goal,status,started_message_id,created_at) VALUES (?,?,'paused',?,?)",
779
+ (room_id, settings["goal"], room["latest_id"], utc_now()))
764
780
  connection.execute("UPDATE rooms SET goal=? WHERE id=?", (settings["goal"], room_id))
765
781
  save_topic_settings(connection, room_id, settings)
766
782
  connection.execute("UPDATE room_modes SET mode='brainstorm' WHERE room_id=?", (room_id,))
@@ -1234,9 +1250,34 @@ def rate_scheduler_decision(connection, room_id, decision_id, token, payload):
1234
1250
  connection.execute("""INSERT INTO scheduler_feedback (decision_id,rating,should_be,note,created_at) VALUES (?,?,?,?,?)
1235
1251
  ON CONFLICT(decision_id) DO UPDATE SET rating=excluded.rating,should_be=excluded.should_be,note=excluded.note,created_at=excluded.created_at""",
1236
1252
  (decision_id, rating, should_be.strip(), note.strip(), utc_now()))
1253
+ if rating == "bad":
1254
+ save_feedback_case(connection, room_id, decision_id, should_be.strip(), note.strip())
1255
+ else:
1256
+ connection.execute("DELETE FROM scheduler_feedback_cases WHERE decision_id=?", (decision_id,))
1237
1257
  return {"decision_id": decision_id, "feedback": rating, "should_be": should_be.strip(), "note": note.strip()}
1238
1258
 
1239
1259
 
1260
+ def save_feedback_case(connection, room_id, decision_id, should_be, note):
1261
+ """Copy a bad-rated decision with everything the model saw, so it outlives its room for prompt review."""
1262
+ decision = dict(connection.execute(f"{DECISION_SELECT} WHERE d.id=?", (decision_id,)).fetchone())
1263
+ stored = {name: connection.execute(f"SELECT {column} FROM {table} WHERE decision_id=?", (decision_id,)).fetchone()
1264
+ for name, table, column in (("detail", "scheduler_decision_details", "detail"), ("input", "scheduler_decision_inputs", "input"))}
1265
+ snapshot = {key: decision[key] for key in ("kind", "after_message_id", "label", "confidence", "applied", "provider", "created_at")}
1266
+ snapshot["context"] = json.loads(decision["context"]) if decision["context"] else None
1267
+ snapshot.update({name: json.loads(row[0]) if row else None for name, row in stored.items()})
1268
+ title = connection.execute("SELECT title FROM rooms WHERE id=?", (room_id,)).fetchone()[0]
1269
+ connection.execute("""INSERT OR REPLACE INTO scheduler_feedback_cases (decision_id,room_id,room_title,snapshot,should_be,note,rated_at)
1270
+ VALUES (?,?,?,?,?,?,?)""", (decision_id, room_id, title, json.dumps(snapshot, ensure_ascii=False), should_be, note, utc_now()))
1271
+
1272
+
1273
+ def list_feedback_cases(connection, after):
1274
+ """Bad-rated decision snapshots after a decision id, oldest first, for the administrator's prompt review."""
1275
+ rows = connection.execute("SELECT * FROM scheduler_feedback_cases WHERE decision_id>? ORDER BY decision_id LIMIT ?",
1276
+ (after, FEEDBACK_CASES_PAGE)).fetchall()
1277
+ cases = [{**dict(row), "snapshot": json.loads(row["snapshot"])} for row in rows]
1278
+ return {"cases": cases, "next_after": cases[-1]["decision_id"] if len(cases) == FEEDBACK_CASES_PAGE else None}
1279
+
1280
+
1240
1281
  def mark_scheduler_routing(connection, room_id, token, payload):
1241
1282
  """The local service marks a message while the decision model is analysing it, and clears the mark when done."""
1242
1283
  begin_write(connection)
@@ -1280,6 +1321,12 @@ def record_scheduler_decision(connection, room_id, token, payload, owner=False):
1280
1321
  if not isinstance(detail, list) or len(detail) > 4 or len(text) > MAX_DECISION_DETAIL:
1281
1322
  raise ValueError(f"detail 须为最多 4 项、不超过 {MAX_DECISION_DETAIL} 字的列表")
1282
1323
  connection.execute("INSERT INTO scheduler_decision_details (decision_id,detail) VALUES (?,?)", (cursor.lastrowid, text))
1324
+ model_input = payload.get("input")
1325
+ if model_input is not None:
1326
+ text = json.dumps(model_input, ensure_ascii=False)
1327
+ if not isinstance(model_input, dict) or len(text) > MAX_DECISION_INPUT:
1328
+ raise ValueError(f"input 须为不超过 {MAX_DECISION_INPUT} 字的对象")
1329
+ connection.execute("INSERT INTO scheduler_decision_inputs (decision_id,input) VALUES (?,?)", (cursor.lastrowid, text))
1283
1330
  return dict(connection.execute("SELECT * FROM scheduler_decisions WHERE id=?", (cursor.lastrowid,)).fetchone())
1284
1331
 
1285
1332
 
@@ -1519,7 +1566,7 @@ def participation_text(params, free=False):
1519
1566
  "先回答真正的问题,给出理由、证据、适用条件与不确定性;区分事实、推断和建议,引用原文编号,不编造他人的回答。"
1520
1567
  "讨论初始阶段先独立判断,暂不读他人观点;深入讨论先理解对方主张及其最有力的依据,再说明同意、分歧或需要补的证据。"
1521
1568
  "可以坚持有依据的少数意见,也可因新证据修改判断并说明变化。",
1522
- "输出:先 1–3 句摘要,再给完整正文。",
1569
+ "输出:完整正文;回答较长时先给 1–3 句摘要。",
1523
1570
  "不要操作网页表单、调用写入接口或生成发布链接。读取失败时如实报告,由用户提供复制的材料。",
1524
1571
  *(["自由协作:只回应点名给你的消息(编号见阅读通知),按时间顺序处理,后面的消息可以修正前面的。"] if free else []), footer]
1525
1572
 
@@ -1567,7 +1614,7 @@ def protocol_text(room):
1567
1614
  f"维护话题:POST {base}/topic;actor 加需要修改的上述字段,另可更新 phase(preparing/initial/discussing/paused/concluded)、focus。收束前先完成或取消当前安排;主持人收束还须先 GET {base}/conclusion 取得 blockers 与 revision,并把 revision 作为 conclusion 一并提交(有阻碍项或状态已变会被拒绝)。暂停保留安排;恢复阶段后再继续。",
1568
1615
  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)。",
1569
1616
  f"通知:仅把对象、问题、话题入口交给对应专家,不附背景、摘要或历史。使用已有且获准的会话通道;没有通道时交由用户协助。实际送达后 POST {base}/turns/安排编号,字段 actor、status=notified;取消用 status=cancelled。复制不算送达。",
1570
- 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 时不自动生成。",
1617
+ 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 时不自动生成。",
1571
1618
  f"主持发言:同一消息接口,author={room['host']}、submitted_by={room['host']}、kind=moderation、source=cli;个人观点使用 kind=opinion,不能自动视为共识。",
1572
1619
  f"代发只读专家观点:author 保留原专家,submitted_by={room['host']}、kind=opinion、text 为原文;须关联 turn_id,可附 reply_to。服务保存代发来源;不要冒充专家直接提交。该专家须有当前有效的安排。",
1573
1620
  f"整理状态:POST {base}/state;actor、text(详细状态,最多 {MAX_STATE_LENGTH} 字)、until(依据的消息截止编号);brief(最多 {MAX_BRIEF_LENGTH} 字)为面向人的主持简报,说明结论、共识、歧义和下一步意图。进度或安排变化后更新;省略 brief 保留旧简报,空字符串清除。所有参与专家各保存一次完整观点才完成一轮;同轮重复发言只计该专家一次,主持说明、用户留言及取消安排不推进轮次。房间 rounds 按当前配置专家和完整历史生成,completed_rounds 为完成轮数,answered_turns 仅为已回答安排数。状态不覆盖原文,也不代表全体一致同意。",
@@ -1902,6 +1949,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1902
1949
  return self.respond({"ok": True})
1903
1950
  if parts[:2] == ["api", "admin"]:
1904
1951
  return self.handle_admin_api(connection, parts, method)
1952
+ if parts == ["api", "scheduler-feedback"] and method == "GET":
1953
+ if not self.admin_session(connection):
1954
+ raise AdminError("需要登录管理员账号", 401)
1955
+ return self.respond(list_feedback_cases(connection, int(params.get("after", 0))))
1905
1956
  if parts == ["api", "rooms"]:
1906
1957
  if method == "GET":
1907
1958
  return self.respond({**list_rooms(connection, params), "participants": PARTICIPANTS})
@@ -2129,6 +2180,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2129
2180
  result = recommend_member(connection, room_id, token, self.read_payload())
2130
2181
  elif len(parts) == 5 and parts[3] == "recommendations" and method == "POST":
2131
2182
  result = adopt_recommendation(connection, room_id, token, int(parts[4]), self.read_payload())
2183
+ elif len(parts) == 6 and parts[3] == "executions" and parts[5] == "notes" and method == "GET":
2184
+ result = execution_notes(connection, room_id, int(parts[4]))
2132
2185
  elif len(parts) == 5 and parts[3] == "executions":
2133
2186
  execution_id = int(parts[4])
2134
2187
  result = (view_execution(connection, room_id, execution_id, token) if method == "GET"