ai_co_discuss 0.2.71 → 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.
@@ -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)
@@ -1037,6 +1060,26 @@ def changed_files(payload):
1037
1060
  return sorted(set(files))
1038
1061
 
1039
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
+
1040
1083
  def execution_action(connection, room_id, execution_id, token, payload):
1041
1084
  """Runner-side lifecycle: renew, fail, release undelivered work, or report observed file changes."""
1042
1085
  require_owner(connection, room_id, token)
@@ -1057,7 +1100,13 @@ def execution_action(connection, room_id, execution_id, token, payload):
1057
1100
  limit = row["created_at"] + execution_minutes(connection, room_id) * 60
1058
1101
  if row["lease_until"] <= now or limit <= now:
1059
1102
  raise FileExistsError("执行租约或单次时限已到,不能续租")
1060
- 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])
1061
1110
  elif action == "fail":
1062
1111
  finish_execution(connection, execution_id, "failed", str(payload.get("reason") or "执行失败"), "failed")
1063
1112
  settle_batches(connection, room_id)
@@ -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');
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():
@@ -223,12 +224,14 @@ def require_room(connection, room_id):
223
224
  room["round_budget"] = dict(budget) if budget else None
224
225
  if budget:
225
226
  room["opinion_count"] = opinion_count(connection, room_id, room["experts"])
226
- 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"]
227
230
  room["runtime"] = runtime_view(connection, room_id)
228
231
  room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
229
232
  room["mode"] = room_mode(connection, room_id)
230
- 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()
231
- room["session"] = dict(session) if session else None
233
+ if room["mode"] == "free" and not session:
234
+ room["round_groups"] = []
232
235
  room["members"] = [dict(row) for row in connection.execute(
233
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,
234
237
  COALESCE(r.registered_at,'') AS registered_at
@@ -326,27 +329,33 @@ def discussion_rounds(connection, room_id, experts):
326
329
  return rounds
327
330
 
328
331
 
329
- def budget_round_groups(connection, room_id, experts, base):
332
+ def budget_round_groups(connection, room_id, experts, base, session):
330
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.
331
334
 
335
+ Only messages of the latest discussion are grouped, numbered from 1; free collaboration before or after it has no rounds.
332
336
  `rounds` stays speaker-based because scheduling needs who still owes a turn; this view only labels history.
333
337
  """
334
- if not experts:
338
+ if not experts or not session:
335
339
  return []
340
+ first, last = session["started_message_id"], session["ended_message_id"] or float("inf")
336
341
  rows = connection.execute("""SELECT messages.id,messages.author,
337
342
  COALESCE(details.kind,'opinion') AS kind FROM messages
338
343
  LEFT JOIN message_details AS details ON details.message_id=messages.id
339
344
  WHERE room_id=? ORDER BY messages.id""", (room_id,))
340
345
  groups, next_start, seen = [], None, 0
341
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
342
352
  if next_start is None:
343
353
  next_start = message["id"]
344
- if message["kind"] != "opinion" or message["author"] not in experts:
354
+ if not opinion:
345
355
  continue
346
356
  if not groups or groups[-1]["completed"]:
347
357
  groups.append({"number": len(groups) + 1, "start_id": next_start, "opinions": 0, "completed": False})
348
358
  current = groups[-1]
349
- seen += 1
350
359
  current["opinions"] += 1
351
360
  current["completed"] = current["opinions"] >= len(experts) or seen == base
352
361
  if current["completed"]:
@@ -645,7 +654,7 @@ def manage_room(connection, room_id, action, payload):
645
654
 
646
655
  def delete_room_records(connection, room_id):
647
656
  """Remove a room's dependent records in the caller's transaction."""
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",
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",
649
658
  "discussion_options", "round_budgets", "scheduler_decisions", "scheduler_routing", "discussion_sessions"):
650
659
  if table == "scheduler_decisions":
651
660
  for child in ("scheduler_feedback", "scheduler_decision_context", "scheduler_decision_inputs"):
@@ -766,7 +775,8 @@ def start_discussion(connection, room_id, token, payload):
766
775
  raise ValueError("named_followup 须为布尔值")
767
776
  # A fresh session first, so the phase mirror follows it and not a concluded earlier one.
768
777
  if not connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND status IN ('active','paused')", (room_id,)).fetchone():
769
- 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()))
770
780
  connection.execute("UPDATE rooms SET goal=? WHERE id=?", (settings["goal"], room_id))
771
781
  save_topic_settings(connection, room_id, settings)
772
782
  connection.execute("UPDATE room_modes SET mode='brainstorm' WHERE room_id=?", (room_id,))
@@ -1556,7 +1566,7 @@ def participation_text(params, free=False):
1556
1566
  "先回答真正的问题,给出理由、证据、适用条件与不确定性;区分事实、推断和建议,引用原文编号,不编造他人的回答。"
1557
1567
  "讨论初始阶段先独立判断,暂不读他人观点;深入讨论先理解对方主张及其最有力的依据,再说明同意、分歧或需要补的证据。"
1558
1568
  "可以坚持有依据的少数意见,也可因新证据修改判断并说明变化。",
1559
- "输出:先 1–3 句摘要,再给完整正文。",
1569
+ "输出:完整正文;回答较长时先给 1–3 句摘要。",
1560
1570
  "不要操作网页表单、调用写入接口或生成发布链接。读取失败时如实报告,由用户提供复制的材料。",
1561
1571
  *(["自由协作:只回应点名给你的消息(编号见阅读通知),按时间顺序处理,后面的消息可以修正前面的。"] if free else []), footer]
1562
1572
 
@@ -1604,7 +1614,7 @@ def protocol_text(room):
1604
1614
  f"维护话题:POST {base}/topic;actor 加需要修改的上述字段,另可更新 phase(preparing/initial/discussing/paused/concluded)、focus。收束前先完成或取消当前安排;主持人收束还须先 GET {base}/conclusion 取得 blockers 与 revision,并把 revision 作为 conclusion 一并提交(有阻碍项或状态已变会被拒绝)。暂停保留安排;恢复阶段后再继续。",
1605
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)。",
1606
1616
  f"通知:仅把对象、问题、话题入口交给对应专家,不附背景、摘要或历史。使用已有且获准的会话通道;没有通道时交由用户协助。实际送达后 POST {base}/turns/安排编号,字段 actor、status=notified;取消用 status=cancelled。复制不算送达。",
1607
- 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 时不自动生成。",
1608
1618
  f"主持发言:同一消息接口,author={room['host']}、submitted_by={room['host']}、kind=moderation、source=cli;个人观点使用 kind=opinion,不能自动视为共识。",
1609
1619
  f"代发只读专家观点:author 保留原专家,submitted_by={room['host']}、kind=opinion、text 为原文;须关联 turn_id,可附 reply_to。服务保存代发来源;不要冒充专家直接提交。该专家须有当前有效的安排。",
1610
1620
  f"整理状态:POST {base}/state;actor、text(详细状态,最多 {MAX_STATE_LENGTH} 字)、until(依据的消息截止编号);brief(最多 {MAX_BRIEF_LENGTH} 字)为面向人的主持简报,说明结论、共识、歧义和下一步意图。进度或安排变化后更新;省略 brief 保留旧简报,空字符串清除。所有参与专家各保存一次完整观点才完成一轮;同轮重复发言只计该专家一次,主持说明、用户留言及取消安排不推进轮次。房间 rounds 按当前配置专家和完整历史生成,completed_rounds 为完成轮数,answered_turns 仅为已回答安排数。状态不覆盖原文,也不代表全体一致同意。",
@@ -2170,6 +2180,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2170
2180
  result = recommend_member(connection, room_id, token, self.read_payload())
2171
2181
  elif len(parts) == 5 and parts[3] == "recommendations" and method == "POST":
2172
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]))
2173
2185
  elif len(parts) == 5 and parts[3] == "executions":
2174
2186
  execution_id = int(parts[4])
2175
2187
  result = (view_execution(connection, room_id, execution_id, token) if method == "GET"