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