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.
- package/.agents/skills/discuss-host/SKILL.md +5 -1
- package/.agents/skills/discuss-host/references/manual.md +1 -1
- package/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/.agents/skills/discuss-participant/references/free.md +2 -0
- package/backend/free_room.py +124 -29
- package/backend/schema.sql +26 -0
- package/backend/server.py +69 -16
- package/docs/architecture.md +4 -3
- package/docs/cli.md +1 -1
- package/docs/deployment.md +225 -225
- package/docs/runner.md +455 -454
- package/docs/skills.md +1 -1
- package/docs/workflow.md +8 -6
- package/package.json +1 -1
- package/runner/api_participant.mjs +235 -230
- package/runner/discussion_adapters.mjs +70 -2
- package/runner/discussion_runner.mjs +1 -1
- package/runner/free_runner.mjs +78 -6
- package/runner/scheduler.mjs +48 -24
- package/web/app.js +5 -5
- package/web/icons/typesafe.png +0 -0
- package/web/modern.css +1 -1
- package/web/modern.js +4148 -3876
|
@@ -105,7 +105,11 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
|
|
|
105
105
|
|
|
106
106
|
## 上限与到限
|
|
107
107
|
|
|
108
|
-
轮次由程序按专家发言量统计并判定到限(`limits.segment_rounds` / `round_cap
|
|
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
|
-
|
|
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 请求编号` 关闭。
|
package/backend/free_room.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
|
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,
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1222
|
-
connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND purpose
|
|
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:
|
package/backend/schema.sql
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
|
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'
|
|
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
|
-
"
|
|
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
|
|
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"
|