ai_co_discuss 0.2.47 → 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 +79 -44
- package/.agents/skills/discuss-host/references/launch.md +19 -5
- 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 +46 -76
- 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 +15 -13
- 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 +23 -41
- package/package.json +1 -1
- package/runner/api_participant.mjs +6 -14
- package/runner/codiscuss.mjs +0 -1
- package/runner/discuss.py +19 -37
- package/runner/discussion_adapters.mjs +1 -1
- package/runner/discussion_launch.mjs +2 -2
- package/runner/discussion_runner.mjs +3 -3
- package/runner/discussion_service.mjs +46 -1
- package/runner/free_runner.mjs +10 -6
- package/web/app.js +73 -79
- package/web/backup.js +1 -1
- package/web/index.html +5 -7
- package/web/modern.css +1 -1
- package/web/modern.js +2152 -2284
package/backend/free_room.py
CHANGED
|
@@ -553,6 +553,39 @@ def after_free_message(connection, room_id, message_id, actor, payload):
|
|
|
553
553
|
settle_batches(connection, room_id)
|
|
554
554
|
|
|
555
555
|
|
|
556
|
+
def discussion_host_handles(connection, room):
|
|
557
|
+
"""A running discussion already wakes its host for every user message, so a question to the host needs no request of its own."""
|
|
558
|
+
return room["phase"] not in ("paused", "concluded") and bool(connection.execute(
|
|
559
|
+
"SELECT 1 FROM discussion_runtime WHERE room_id=? AND status='running'", (room["id"],)).fetchone())
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def checked_asks(connection, room, payload):
|
|
563
|
+
"""Validate the user's direct questions to members of a discussion before the message is stored."""
|
|
564
|
+
names = payload.get("to")
|
|
565
|
+
if names is None:
|
|
566
|
+
return []
|
|
567
|
+
return named_members(active_members(connection, room["id"]), names)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def ask_members(connection, room, message_id, member_ids):
|
|
571
|
+
"""Queue one 'ask' request per named member, outside the discussion's turns, rounds and limits."""
|
|
572
|
+
host_busy = discussion_host_handles(connection, room)
|
|
573
|
+
for member_id in member_ids:
|
|
574
|
+
row = connection.execute("SELECT name FROM room_members WHERE id=?", (member_id,)).fetchone()
|
|
575
|
+
if host_busy and row["name"] == room["host"]:
|
|
576
|
+
continue
|
|
577
|
+
insert_request(connection, room["id"], member_id, message_id, message_id, "ask", "user")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def asks_view(connection, room_id):
|
|
581
|
+
"""Direct questions of a discussion room: recent requests with their member, for status badges beside messages."""
|
|
582
|
+
names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
583
|
+
requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
|
|
584
|
+
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by FROM member_requests
|
|
585
|
+
WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
|
|
586
|
+
return {"requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id)}
|
|
587
|
+
|
|
588
|
+
|
|
556
589
|
def expire_executions(connection, room_id):
|
|
557
590
|
"""Fail executions whose lease lapsed; their requests stay visible as failed, never silently retried."""
|
|
558
591
|
now = int(time.time())
|
|
@@ -589,15 +622,16 @@ def inbox(connection, room_id, token):
|
|
|
589
622
|
expire_executions(connection, room_id)
|
|
590
623
|
release_recommendations(connection, room_id)
|
|
591
624
|
archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
|
|
592
|
-
members = []
|
|
593
|
-
|
|
625
|
+
members, observers = [], []
|
|
626
|
+
# Manual and online members are never delivered to; the runner only reads them so smart scheduling can route to them too.
|
|
627
|
+
for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status='active' ORDER BY id", (room_id,)):
|
|
594
628
|
pending = [dict(item) for item in connection.execute(
|
|
595
629
|
"SELECT id,message_id,purpose FROM member_requests WHERE member_id=? AND status='pending' ORDER BY id", (row["id"],))]
|
|
596
|
-
members.append({**{key: row[key] for key in MEMBER_FIELDS}, "pending": pending})
|
|
630
|
+
(members if row["access"] == "runner" else observers).append({**{key: row[key] for key in MEMBER_FIELDS}, "pending": pending})
|
|
597
631
|
executions = [{**public_execution(row), "member": row["name"]} for row in connection.execute(
|
|
598
632
|
"""SELECT member_executions.*,room_members.name FROM member_executions JOIN room_members ON room_members.id=member_id
|
|
599
633
|
WHERE member_executions.room_id=? AND member_executions.status IN ('dispatched','started') ORDER BY member_executions.id""", (room_id,))]
|
|
600
|
-
return {"room_id": room_id, "archived": bool(archived), "members": members, "executions": executions,
|
|
634
|
+
return {"room_id": room_id, "archived": bool(archived), "members": members, "observers": observers, "executions": executions,
|
|
601
635
|
"execution_minutes": execution_minutes(connection, room_id)}
|
|
602
636
|
|
|
603
637
|
|
|
@@ -618,9 +652,9 @@ def recommend_member(connection, room_id, token, payload):
|
|
|
618
652
|
if not route or route["room_id"] != room_id:
|
|
619
653
|
raise LookupError("只能为交给主持人的未点名消息给出推荐")
|
|
620
654
|
target = connection.execute("""SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'
|
|
621
|
-
AND
|
|
655
|
+
AND id<>?""", (room_id, payload.get("member_id"), route["member_id"])).fetchone()
|
|
622
656
|
if not target:
|
|
623
|
-
raise LookupError("
|
|
657
|
+
raise LookupError("推荐对象须为在场的其他成员")
|
|
624
658
|
now = int(time.time())
|
|
625
659
|
connection.execute("""INSERT OR IGNORE INTO member_recommendations (message_id,room_id,member_id,provider,confidence,created_at,expires_at)
|
|
626
660
|
VALUES (?,?,?,?,?,?,?)""", (message_id, room_id, target["id"], payload["provider"], float(confidence), now, now + RECOMMEND_SECONDS))
|
|
@@ -693,6 +727,9 @@ def reserve_execution(connection, room_id, token, payload):
|
|
|
693
727
|
if connection.execute("SELECT 1 FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
|
|
694
728
|
(member["id"],)).fetchone():
|
|
695
729
|
raise FileExistsError("该成员已有进行中的执行;同一会话不并发续接")
|
|
730
|
+
if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND participant=? AND status IN ('dispatched','started')",
|
|
731
|
+
(room_id, member["name"])).fetchone():
|
|
732
|
+
raise FileExistsError("该成员正在处理讨论安排;同一会话不并发续接")
|
|
696
733
|
if not connection.execute("SELECT 1 FROM member_requests WHERE member_id=? AND status='pending'", (member["id"],)).fetchone():
|
|
697
734
|
raise FileExistsError("该成员没有待投递的请求")
|
|
698
735
|
now = int(time.time())
|
|
@@ -812,45 +849,8 @@ def save_discussion_experts(connection, room_id, experts):
|
|
|
812
849
|
connection.execute("UPDATE topic_settings SET experts=? WHERE room_id=?", (json.dumps(experts, ensure_ascii=False), room_id))
|
|
813
850
|
|
|
814
851
|
|
|
815
|
-
def current_round_speakers(connection, room_id, experts):
|
|
816
|
-
"""Experts who have given an opinion in the latest unfinished round (empty when it just completed)."""
|
|
817
|
-
spoken = set()
|
|
818
|
-
rows = connection.execute("""SELECT messages.author FROM messages
|
|
819
|
-
JOIN message_details AS details ON details.message_id=messages.id AND details.kind='opinion'
|
|
820
|
-
WHERE messages.room_id=? ORDER BY messages.id""", (room_id,))
|
|
821
|
-
for row in rows:
|
|
822
|
-
if row["author"] not in experts:
|
|
823
|
-
continue
|
|
824
|
-
spoken.add(row["author"])
|
|
825
|
-
if len(spoken) == len(experts):
|
|
826
|
-
spoken = set()
|
|
827
|
-
return spoken
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
def place_invited_expert(connection, room_id, name):
|
|
831
|
-
"""Move the just-queued newcomer to where the current round completes: after the last queued expert who
|
|
832
|
-
has not spoken this round, before the first one who would speak again. Rows keep their ids; only who and what shift."""
|
|
833
|
-
rows = connection.execute("SELECT id,expert,question FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id", (room_id,)).fetchall()
|
|
834
|
-
if len(rows) < 2 or rows[-1]["expert"] != name:
|
|
835
|
-
return
|
|
836
|
-
_, experts, _ = discussion_roster(connection, room_id)
|
|
837
|
-
spoken = current_round_speakers(connection, room_id, experts)
|
|
838
|
-
open_turn = connection.execute("SELECT expert FROM discussion_turns WHERE room_id=? AND status IN ('pending','notified')", (room_id,)).fetchone()
|
|
839
|
-
if open_turn:
|
|
840
|
-
spoken.add(open_turn["expert"])
|
|
841
|
-
slot = len(rows) - 1
|
|
842
|
-
for index, row in enumerate(rows[:-1]):
|
|
843
|
-
if row["expert"] in spoken:
|
|
844
|
-
slot = index
|
|
845
|
-
break
|
|
846
|
-
spoken.add(row["expert"])
|
|
847
|
-
ordered = rows[:slot] + [rows[-1]] + rows[slot:-1]
|
|
848
|
-
for target, source in zip(rows, ordered):
|
|
849
|
-
connection.execute("UPDATE discussion_turns SET expert=?,question=? WHERE id=? AND status='queued'", (source["expert"], source["question"], target["id"]))
|
|
850
|
-
|
|
851
|
-
|
|
852
852
|
def queue_invited_expert(connection, room_id, name, now):
|
|
853
|
-
"""Queue a new expert
|
|
853
|
+
"""Queue a new expert at the tail of an existing live plan, without changing the current speaker or hold.
|
|
854
854
|
|
|
855
855
|
A manual expert is queued only once it has registered (the invite alone does not), and only once per invitation.
|
|
856
856
|
"""
|
|
@@ -865,10 +865,8 @@ def queue_invited_expert(connection, room_id, name, now):
|
|
|
865
865
|
FROM topic_settings AS settings JOIN room_members AS member ON member.room_id=settings.room_id
|
|
866
866
|
WHERE settings.room_id=? AND member.name=? AND member.status='active'
|
|
867
867
|
AND settings.phase NOT IN ('paused','concluded')
|
|
868
|
-
AND NOT EXISTS (SELECT 1 FROM discussion_limits WHERE room_id=settings.room_id AND closing_at>0)
|
|
869
868
|
AND EXISTS (SELECT 1 FROM discussion_turns WHERE room_id=settings.room_id AND status IN ('pending','notified','queued'))""",
|
|
870
869
|
(now, room_id, name))
|
|
871
|
-
place_invited_expert(connection, room_id, name)
|
|
872
870
|
|
|
873
871
|
|
|
874
872
|
def add_discussion_member(connection, room_id, token, payload, now):
|
|
@@ -899,15 +897,11 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
|
899
897
|
"""Owner sends an expert away (keeping its seat), brings it back with a new credential, or removes it.
|
|
900
898
|
|
|
901
899
|
Leaving cancels the expert's open assignments; the host keeps its role and cannot leave, and an
|
|
902
|
-
expert with an open assignment can only leave while the discussion is paused.
|
|
903
|
-
`redo` voids the expert's latest opinion by deleting it so the expert counts as not yet having spoken.
|
|
900
|
+
expert with an open assignment can only leave while the discussion is paused.
|
|
904
901
|
"""
|
|
905
902
|
member = owned_member(connection, room_id, member_id, token)
|
|
906
903
|
host, experts, phase = discussion_roster(connection, room_id)
|
|
907
904
|
action = payload.get("action")
|
|
908
|
-
if action == "redo":
|
|
909
|
-
void_latest_opinion(connection, room_id, member["name"], phase, payload.get("latest_id"))
|
|
910
|
-
return
|
|
911
905
|
if action in ("away", "remove"):
|
|
912
906
|
if member["name"] == host:
|
|
913
907
|
raise ValueError("主持人不能离开或移除;请先更换主持人")
|
|
@@ -934,7 +928,7 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
|
934
928
|
adopt_manual_member(connection, room_id, member, payload)
|
|
935
929
|
return
|
|
936
930
|
else:
|
|
937
|
-
raise ValueError("action 须为 away、active、remove
|
|
931
|
+
raise ValueError("action 须为 away、active、remove 或 adopt")
|
|
938
932
|
save_discussion_experts(connection, room_id, experts)
|
|
939
933
|
|
|
940
934
|
|
|
@@ -955,30 +949,6 @@ def adopt_manual_member(connection, room_id, member, payload):
|
|
|
955
949
|
connection.execute("DELETE FROM member_sessions WHERE member_id=?", (member["id"],))
|
|
956
950
|
|
|
957
951
|
|
|
958
|
-
def void_latest_opinion(connection, room_id, name, phase, seen_latest_id):
|
|
959
|
-
"""Delete an expert's latest opinion while paused; its assignment is cancelled and the round no longer counts it.
|
|
960
|
-
|
|
961
|
-
The caller states the latest message id it saw, so a stale page cannot void an opinion that is no longer the one on screen.
|
|
962
|
-
"""
|
|
963
|
-
if phase != "paused":
|
|
964
|
-
raise ValueError("请先暂停讨论,再作废专家的发言")
|
|
965
|
-
if seen_latest_id is None:
|
|
966
|
-
raise ValueError("作废发言须带上所见的最新消息编号 latest_id")
|
|
967
|
-
if seen_latest_id != connection.execute("SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=?", (room_id,)).fetchone()[0]:
|
|
968
|
-
raise FileExistsError("房间已有新内容,请刷新后再作废")
|
|
969
|
-
row = connection.execute("""SELECT messages.id,details.turn_id FROM messages JOIN message_details AS details ON details.message_id=messages.id
|
|
970
|
-
WHERE messages.room_id=? AND messages.author=? AND details.kind='opinion' ORDER BY messages.id DESC LIMIT 1""",
|
|
971
|
-
(room_id, name)).fetchone()
|
|
972
|
-
if not row:
|
|
973
|
-
raise LookupError(f"{name} 还没有可作废的发言")
|
|
974
|
-
message_id = row["id"]
|
|
975
|
-
connection.execute("UPDATE discussion_turns SET status='cancelled',answer_id=NULL WHERE answer_id=?", (message_id,))
|
|
976
|
-
connection.execute("UPDATE messages SET reply_to=NULL WHERE reply_to=?", (message_id,))
|
|
977
|
-
for table in ("message_timings", "message_summaries", "message_details"):
|
|
978
|
-
connection.execute(f"DELETE FROM {table} WHERE message_id=?", (message_id,))
|
|
979
|
-
connection.execute("DELETE FROM messages WHERE id=?", (message_id,))
|
|
980
|
-
|
|
981
|
-
|
|
982
952
|
def require_fresh_member_token(connection, room_id, token):
|
|
983
953
|
"""Prevent owner or another member's credential from being reused for a new identity."""
|
|
984
954
|
hashed = token_hash(token)
|
package/backend/room_import.py
CHANGED
|
@@ -115,7 +115,7 @@ def history_statements(tables, room_id, maps):
|
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
def settings_statements(tables, room, maps, now):
|
|
118
|
-
"""Topic,
|
|
118
|
+
"""Topic, limits and idle clock (a backup's retired speaking floor is ignored); an active brainstorm arrives paused because it has no run."""
|
|
119
119
|
room_id, messages, statements = room["id"], maps["messages"], []
|
|
120
120
|
free = any(row.get("mode") == "free" for row in tables.get("room_modes", []))
|
|
121
121
|
for row in tables.get("topic_settings", [])[:1]:
|
|
@@ -125,10 +125,6 @@ def settings_statements(tables, room, maps, now):
|
|
|
125
125
|
row["experts"], phase, remap_text(row.get("focus"), messages))),
|
|
126
126
|
("INSERT INTO discussion_pauses (room_id,paused_at) VALUES (?,?)",
|
|
127
127
|
(room_id, 0 if phase in ACTIVE_PHASES else now))]
|
|
128
|
-
for row in tables.get("room_floor", [])[:1]:
|
|
129
|
-
statements.append(("""INSERT INTO room_floor (room_id,holder,next_speaker,revision) VALUES (?,?,?,?) ON CONFLICT(room_id)
|
|
130
|
-
DO UPDATE SET holder=excluded.holder,next_speaker=excluded.next_speaker,revision=excluded.revision""",
|
|
131
|
-
(room_id, row.get("holder"), row["next_speaker"], row.get("revision", 0))))
|
|
132
128
|
for row in tables.get("discussion_limits", [])[:1]:
|
|
133
129
|
statements.append((f"UPDATE discussion_limits SET {','.join(f'{field}=?' for field in LIMIT_FIELDS)} WHERE room_id=?",
|
|
134
130
|
(*(row[field] for field in LIMIT_FIELDS), room_id)))
|
package/backend/runtime.py
CHANGED
|
@@ -7,8 +7,9 @@ import uuid
|
|
|
7
7
|
|
|
8
8
|
LEASE_SECONDS = 120
|
|
9
9
|
MAX_RUN_SECONDS = 86400
|
|
10
|
-
LIMIT_DEFAULTS = {"max_rounds": 0, "max_minutes": 0, "execution_minutes": 0
|
|
11
|
-
|
|
10
|
+
LIMIT_DEFAULTS = {"max_rounds": 0, "max_minutes": 0, "execution_minutes": 0}
|
|
11
|
+
# Older clients may still send these; reaching a limit always pauses, so only the pause value is accepted and the reserve is ignored.
|
|
12
|
+
LEGACY_LIMIT_FIELDS = ("on_limit", "summary_minutes")
|
|
12
13
|
PUBLIC_EVENT_FIELDS = ("id", "room_id", "run_id", "generation", "cause", "participant",
|
|
13
14
|
"role", "turn_id", "status", "session_id", "lease_until", "result", "created_at")
|
|
14
15
|
LIMITS_SQL = """SELECT limits.*,COALESCE(pauses.paused_seconds,0)+CASE WHEN COALESCE(pauses.paused_at,0)>0
|
|
@@ -39,18 +40,15 @@ def begin_write(connection):
|
|
|
39
40
|
|
|
40
41
|
def limit_values(payload, current=None):
|
|
41
42
|
"""Validate bounded discussion settings; zero means no round/time preset."""
|
|
42
|
-
if not isinstance(payload, dict) or set(payload) - set(LIMIT_DEFAULTS):
|
|
43
|
+
if not isinstance(payload, dict) or set(payload) - set(LIMIT_DEFAULTS) - set(LEGACY_LIMIT_FIELDS):
|
|
43
44
|
raise ValueError("limits 仅接受讨论上限配置字段")
|
|
45
|
+
if payload.get("on_limit", "pause") != "pause":
|
|
46
|
+
raise ValueError("到限后只会暂停;需要总结时请主持人在暂停后按你的要求写出")
|
|
44
47
|
result = {key: (current or {}).get(key, value) for key, value in LIMIT_DEFAULTS.items()}
|
|
45
|
-
result.update(payload)
|
|
46
|
-
for key, minimum, maximum in (("max_rounds", 0, 100), ("max_minutes", 0, 1440),
|
|
47
|
-
("execution_minutes", 0, 60), ("summary_minutes", 1, 60)):
|
|
48
|
+
result.update({key: value for key, value in payload.items() if key in LIMIT_DEFAULTS})
|
|
49
|
+
for key, minimum, maximum in (("max_rounds", 0, 100), ("max_minutes", 0, 1440), ("execution_minutes", 0, 60)):
|
|
48
50
|
if type(result[key]) is not int or not minimum <= result[key] <= maximum:
|
|
49
51
|
raise ValueError(f"{key} 须为 {minimum}–{maximum} 的整数")
|
|
50
|
-
if result["on_limit"] not in ("pause", "summarize"):
|
|
51
|
-
raise ValueError("on_limit 须为 pause 或 summarize")
|
|
52
|
-
if result["on_limit"] == "summarize" and result["max_minutes"] and result["summary_minutes"] >= result["max_minutes"]:
|
|
53
|
-
raise ValueError("收束预留时间必须小于讨论总时长")
|
|
54
52
|
return result
|
|
55
53
|
|
|
56
54
|
|
|
@@ -63,10 +61,6 @@ def limits_view(row, room):
|
|
|
63
61
|
if room.get("runtime", {}).get("deadline") and (room["runtime"]["status"] == "running" or result["stop_reason"]):
|
|
64
62
|
runtime_deadline = room["runtime"]["deadline"]
|
|
65
63
|
deadline = min(deadline or runtime_deadline, runtime_deadline)
|
|
66
|
-
if result["closing_at"]:
|
|
67
|
-
closing_deadline = result["closing_at"] + result["summary_minutes"] * 60
|
|
68
|
-
deadline = min(deadline or closing_deadline, closing_deadline)
|
|
69
|
-
cutoff = deadline - result["summary_minutes"] * 60 if deadline and result["on_limit"] == "summarize" and not result["closing_at"] else deadline
|
|
70
64
|
reason = ""
|
|
71
65
|
budget = room.get("round_budget")
|
|
72
66
|
experts = len(room.get("experts") or {})
|
|
@@ -78,14 +72,9 @@ def limits_view(row, room):
|
|
|
78
72
|
round_cap = (budget["cap"] or result["max_rounds"]) if budget else result["max_rounds"]
|
|
79
73
|
if deadline and now >= deadline:
|
|
80
74
|
reason = "time_limit"
|
|
81
|
-
elif result["closing_at"]:
|
|
82
|
-
reason = result["stop_reason"]
|
|
83
75
|
elif round_cap and segment_rounds >= round_cap:
|
|
84
76
|
reason = "round_limit"
|
|
85
|
-
|
|
86
|
-
reason = "summary_reserved" if result["on_limit"] == "summarize" else "time_limit"
|
|
87
|
-
result.update(deadline=deadline, cutoff=cutoff, reached=bool(reason), reason=reason,
|
|
88
|
-
closing=bool(result["closing_at"]) and room["phase"] not in ("paused", "concluded"), completed_rounds=room["completed_rounds"],
|
|
77
|
+
result.update(deadline=deadline, reached=bool(reason), reason=reason, completed_rounds=room["completed_rounds"],
|
|
89
78
|
segment_rounds=segment_rounds, round_cap=round_cap, extra_rounds=budget["extra"] if budget else 0,
|
|
90
79
|
remaining_seconds=max(0, deadline - now) if deadline else None,
|
|
91
80
|
elapsed_seconds=max(0, now - result["started_at"] - idle) if result["started_at"] else 0)
|
|
@@ -95,7 +84,7 @@ def limits_view(row, room):
|
|
|
95
84
|
def require_within_limits(room):
|
|
96
85
|
"""Stop new expert work at the configured boundary, preserving user notes."""
|
|
97
86
|
if room["limits"]["reached"]:
|
|
98
|
-
raise FileExistsError("
|
|
87
|
+
raise FileExistsError("已达到讨论上限;请检查上限并暂停,由用户决定追加轮次或结束")
|
|
99
88
|
|
|
100
89
|
|
|
101
90
|
def execution_seconds(connection, room_id):
|
|
@@ -191,12 +180,6 @@ def authorize_participant(connection, room_id, identity, operation, payload):
|
|
|
191
180
|
(room_id, author)).fetchone()
|
|
192
181
|
if author != actor and not (online and identity["role"] in ("owner", "host")):
|
|
193
182
|
raise PermissionError("只能提交本人观点,或由房主、主持人代发在线成员原文")
|
|
194
|
-
elif operation == "floor":
|
|
195
|
-
speaker = payload.get("speaker", actor)
|
|
196
|
-
online = connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND name=? AND access='online' AND status='active'",
|
|
197
|
-
(room_id, speaker)).fetchone()
|
|
198
|
-
if identity["role"] != "owner" and speaker != actor and not (identity["role"] == "host" and online):
|
|
199
|
-
raise PermissionError("只能操作本人发言权")
|
|
200
183
|
elif operation != "attachments" and identity["role"] != "owner":
|
|
201
184
|
if identity["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
|
|
202
185
|
raise PermissionError("本成员没有此管理权限")
|
|
@@ -235,11 +218,14 @@ def authorize_write(connection, room_id, token, operation, payload):
|
|
|
235
218
|
if operation == "messages":
|
|
236
219
|
if payload.get("author") != actor:
|
|
237
220
|
raise PermissionError("自动执行不能代发其他参与者")
|
|
238
|
-
if event["role"] == "expert" and payload.get("turn_id")
|
|
221
|
+
if event["role"] == "expert" and payload.get("turn_id") not in (None, event["turn_id"]):
|
|
239
222
|
raise PermissionError("回答必须关联本次安排")
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
223
|
+
if event["role"] == "expert" and payload.get("turn_id") is None:
|
|
224
|
+
# Without a named arrangement the answer belongs to the expert's open one, which must be this execution's.
|
|
225
|
+
open_turn = connection.execute("SELECT id FROM discussion_turns WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
|
|
226
|
+
(room_id, actor)).fetchone()
|
|
227
|
+
if not open_turn or open_turn["id"] != event["turn_id"]:
|
|
228
|
+
raise PermissionError("回答必须关联本次安排")
|
|
243
229
|
elif operation == "attachments":
|
|
244
230
|
pass
|
|
245
231
|
elif event["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
|
|
@@ -292,10 +278,6 @@ def validate_start(connection, room_id, payload):
|
|
|
292
278
|
limits = connection.execute(LIMITS_SQL, (room_id,)).fetchone()
|
|
293
279
|
configured = ((limits["started_at"] or int(time.time())) + limits["max_minutes"] * 60 + limits["idle_seconds"]
|
|
294
280
|
if limits["max_minutes"] else 0)
|
|
295
|
-
if limits["closing_at"]:
|
|
296
|
-
closing_deadline = limits["closing_at"] + limits["summary_minutes"] * 60
|
|
297
|
-
previous_deadline = runtime_view(connection, room_id).get("deadline", 0)
|
|
298
|
-
configured = min(value for value in (configured, closing_deadline, previous_deadline) if value)
|
|
299
281
|
deadline = payload.get("deadline", configured)
|
|
300
282
|
if type(deadline) is int and configured:
|
|
301
283
|
deadline = min(deadline or configured, configured)
|
|
@@ -379,6 +361,10 @@ def reserve_event(connection, event, payload):
|
|
|
379
361
|
if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')",
|
|
380
362
|
(event["room_id"],)).fetchone():
|
|
381
363
|
raise FileExistsError("已有在途执行")
|
|
364
|
+
if connection.execute("""SELECT 1 FROM member_executions JOIN room_members ON room_members.id=member_executions.member_id
|
|
365
|
+
WHERE member_executions.room_id=? AND room_members.name=? AND member_executions.status IN ('dispatched','started')""",
|
|
366
|
+
(event["room_id"], event["participant"])).fetchone():
|
|
367
|
+
raise FileExistsError("该参与者正在回答用户的直接提问;同一会话不并发续接")
|
|
382
368
|
connection.execute("UPDATE discussion_events SET status='dispatched',token_hash=? WHERE id=?", (hashed, event["id"]))
|
|
383
369
|
if event["role"] == "host":
|
|
384
370
|
connection.execute("UPDATE discussion_events SET status='completed',result=? WHERE room_id=? AND role='host' AND status='pending' AND id<?",
|
package/backend/schema.sql
CHANGED
|
@@ -144,27 +144,9 @@ CREATE TABLE IF NOT EXISTS room_floor (
|
|
|
144
144
|
next_speaker TEXT NOT NULL,
|
|
145
145
|
revision INTEGER NOT NULL DEFAULT 0
|
|
146
146
|
);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
FROM rooms LEFT JOIN topic_settings AS settings ON settings.room_id=rooms.id
|
|
151
|
-
LEFT JOIN discussion_turns AS turns ON turns.room_id=rooms.id AND turns.status IN ('pending','notified');
|
|
152
|
-
CREATE TRIGGER IF NOT EXISTS initialize_room_floor AFTER INSERT ON topic_settings
|
|
153
|
-
BEGIN
|
|
154
|
-
INSERT INTO room_floor (room_id,holder,next_speaker) VALUES (NEW.room_id,NEW.host,NEW.host)
|
|
155
|
-
ON CONFLICT(room_id) DO UPDATE SET
|
|
156
|
-
holder=CASE WHEN holder='用户' THEN NEW.host ELSE holder END,
|
|
157
|
-
next_speaker=CASE WHEN next_speaker='用户' THEN NEW.host ELSE next_speaker END,
|
|
158
|
-
revision=revision+1;
|
|
159
|
-
END;
|
|
160
|
-
CREATE TRIGGER IF NOT EXISTS transfer_host_floor AFTER UPDATE OF host ON topic_settings
|
|
161
|
-
WHEN OLD.host<>NEW.host
|
|
162
|
-
BEGIN
|
|
163
|
-
UPDATE room_floor SET holder=CASE WHEN holder=OLD.host THEN NEW.host ELSE holder END,
|
|
164
|
-
next_speaker=CASE WHEN next_speaker=OLD.host THEN NEW.host ELSE next_speaker END,
|
|
165
|
-
revision=revision+1 WHERE room_id=NEW.room_id
|
|
166
|
-
AND NOT EXISTS (SELECT 1 FROM discussion_turns WHERE room_id=NEW.room_id AND status IN ('pending','notified') AND expert=OLD.host);
|
|
167
|
-
END;
|
|
147
|
+
-- The speaking floor is retired: the table stays only so old databases and backups load, and nothing reads or writes it.
|
|
148
|
+
DROP TRIGGER IF EXISTS initialize_room_floor;
|
|
149
|
+
DROP TRIGGER IF EXISTS transfer_host_floor;
|
|
168
150
|
-- Site administrator account (single user) and its login sessions. Raw tokens and passwords are never stored.
|
|
169
151
|
CREATE TABLE IF NOT EXISTS site_admin (
|
|
170
152
|
id INTEGER PRIMARY KEY CHECK(id=1),
|
|
@@ -186,16 +168,12 @@ CREATE TABLE IF NOT EXISTS room_modes (
|
|
|
186
168
|
mode TEXT NOT NULL CHECK(mode IN ('free','brainstorm')),
|
|
187
169
|
control_hash TEXT NOT NULL DEFAULT ''
|
|
188
170
|
);
|
|
189
|
-
-- Replaced on every start so existing databases drop
|
|
171
|
+
-- Replaced on every start so existing databases drop every speaking-floor rule.
|
|
190
172
|
DROP TRIGGER IF EXISTS require_message_floor;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
AND NOT EXISTS (SELECT 1 FROM room_modes WHERE room_id=NEW.room_id AND mode='free')
|
|
196
|
-
BEGIN
|
|
197
|
-
SELECT RAISE(ABORT, 'floor_required');
|
|
198
|
-
END;
|
|
173
|
+
DROP TRIGGER IF EXISTS return_message_floor;
|
|
174
|
+
DROP TRIGGER IF EXISTS require_host_floor;
|
|
175
|
+
DROP TRIGGER IF EXISTS nominate_expert_floor;
|
|
176
|
+
DROP TRIGGER IF EXISTS cancel_turn_floor;
|
|
199
177
|
CREATE TRIGGER IF NOT EXISTS require_opinion_turn BEFORE INSERT ON message_details
|
|
200
178
|
WHEN NEW.kind='opinion' AND NEW.turn_id IS NULL
|
|
201
179
|
AND NOT EXISTS (SELECT 1 FROM message_details WHERE message_id=NEW.message_id)
|
|
@@ -203,35 +181,6 @@ WHEN NEW.kind='opinion' AND NEW.turn_id IS NULL
|
|
|
203
181
|
BEGIN
|
|
204
182
|
SELECT RAISE(ABORT, 'floor_turn_required');
|
|
205
183
|
END;
|
|
206
|
-
CREATE TRIGGER IF NOT EXISTS return_message_floor AFTER INSERT ON message_details
|
|
207
|
-
WHEN EXISTS (SELECT 1 FROM messages WHERE id=NEW.message_id AND author<>'用户')
|
|
208
|
-
BEGIN
|
|
209
|
-
UPDATE room_floor SET holder=NULL,
|
|
210
|
-
next_speaker=COALESCE((SELECT host FROM topic_settings WHERE room_id=room_floor.room_id),'用户'),
|
|
211
|
-
revision=revision+1 WHERE room_id=(SELECT room_id FROM messages WHERE id=NEW.message_id);
|
|
212
|
-
END;
|
|
213
|
-
DROP TRIGGER IF EXISTS require_host_floor;
|
|
214
|
-
-- Queued turns of a round plan are inserted after the first one has already nominated an expert.
|
|
215
|
-
CREATE TRIGGER require_host_floor BEFORE INSERT ON discussion_turns
|
|
216
|
-
WHEN NEW.status<>'queued' AND NOT EXISTS (SELECT 1 FROM discussion_turns WHERE room_id=NEW.room_id AND submission_id=NEW.submission_id)
|
|
217
|
-
AND NOT EXISTS (SELECT 1 FROM room_floor WHERE room_id=NEW.room_id
|
|
218
|
-
AND holder=COALESCE((SELECT host FROM topic_settings WHERE room_id=NEW.room_id),'用户'))
|
|
219
|
-
BEGIN
|
|
220
|
-
SELECT RAISE(ABORT, 'floor_required');
|
|
221
|
-
END;
|
|
222
|
-
DROP TRIGGER IF EXISTS nominate_expert_floor;
|
|
223
|
-
CREATE TRIGGER nominate_expert_floor AFTER INSERT ON discussion_turns
|
|
224
|
-
WHEN NEW.status<>'queued'
|
|
225
|
-
BEGIN
|
|
226
|
-
UPDATE room_floor SET holder=NULL,next_speaker=NEW.expert,revision=revision+1 WHERE room_id=NEW.room_id;
|
|
227
|
-
END;
|
|
228
|
-
CREATE TRIGGER IF NOT EXISTS cancel_turn_floor AFTER UPDATE OF status ON discussion_turns
|
|
229
|
-
WHEN NEW.status='cancelled' AND OLD.status<>NEW.status
|
|
230
|
-
BEGIN
|
|
231
|
-
UPDATE room_floor SET holder=NULL,
|
|
232
|
-
next_speaker=COALESCE((SELECT host FROM topic_settings WHERE room_id=NEW.room_id),'用户'),
|
|
233
|
-
revision=revision+1 WHERE room_id=NEW.room_id;
|
|
234
|
-
END;
|
|
235
184
|
|
|
236
185
|
-- Runtime control is opt-in. Legacy rooms have no runtime row and keep manual entry.
|
|
237
186
|
CREATE TABLE IF NOT EXISTS discussion_limits (
|
|
@@ -369,7 +318,6 @@ DROP TRIGGER IF EXISTS relay_turn_promoted;
|
|
|
369
318
|
CREATE TRIGGER relay_turn_promoted AFTER UPDATE OF status ON discussion_turns
|
|
370
319
|
WHEN OLD.status='queued' AND NEW.status='pending'
|
|
371
320
|
BEGIN
|
|
372
|
-
UPDATE room_floor SET holder=NULL,next_speaker=NEW.expert,revision=revision+1 WHERE room_id=NEW.room_id;
|
|
373
321
|
UPDATE turn_relay SET held_since=0 WHERE room_id=NEW.room_id
|
|
374
322
|
AND NOT EXISTS(SELECT 1 FROM messages m JOIN discussion_events e ON e.room_id=m.room_id AND e.role='host' AND e.status='started'
|
|
375
323
|
WHERE m.room_id=NEW.room_id AND m.author='用户' AND CAST(strftime('%s',m.created_at) AS INTEGER)>e.started_at);
|
|
@@ -679,3 +627,47 @@ CREATE TABLE IF NOT EXISTS room_links (
|
|
|
679
627
|
parent_id TEXT NOT NULL,
|
|
680
628
|
created_at TEXT NOT NULL
|
|
681
629
|
);
|
|
630
|
+
|
|
631
|
+
-- A discussion is a switch on a room, not a room type: one thin session per started discussion. Its status mirrors the legacy
|
|
632
|
+
-- topic phase until the phase machinery is retired. Free rooms have none.
|
|
633
|
+
CREATE TABLE IF NOT EXISTS discussion_sessions (
|
|
634
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
635
|
+
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
|
636
|
+
goal TEXT NOT NULL,
|
|
637
|
+
status TEXT NOT NULL DEFAULT 'paused' CHECK(status IN ('active','paused','concluded')),
|
|
638
|
+
started_message_id INTEGER NOT NULL DEFAULT 0,
|
|
639
|
+
ended_message_id INTEGER NOT NULL DEFAULT 0,
|
|
640
|
+
created_at TEXT NOT NULL
|
|
641
|
+
);
|
|
642
|
+
CREATE UNIQUE INDEX IF NOT EXISTS one_open_session ON discussion_sessions(room_id) WHERE status IN ('active','paused');
|
|
643
|
+
-- Rooms that predate sessions: every non-free room with topic settings gets one, mapped from its phase. Safe to repeat.
|
|
644
|
+
INSERT INTO discussion_sessions (room_id,goal,status,started_message_id,ended_message_id,created_at)
|
|
645
|
+
SELECT rooms.id,rooms.goal,
|
|
646
|
+
CASE WHEN topic_settings.phase='concluded' THEN 'concluded' WHEN topic_settings.phase IN ('preparing','paused') THEN 'paused' ELSE 'active' END,
|
|
647
|
+
0,CASE WHEN topic_settings.phase='concluded' THEN COALESCE((SELECT MAX(id) FROM messages WHERE room_id=rooms.id),0) ELSE 0 END,rooms.created_at
|
|
648
|
+
FROM rooms JOIN topic_settings ON topic_settings.room_id=rooms.id
|
|
649
|
+
WHERE NOT EXISTS (SELECT 1 FROM room_modes WHERE room_id=rooms.id AND mode='free')
|
|
650
|
+
AND NOT EXISTS (SELECT 1 FROM discussion_sessions WHERE room_id=rooms.id);
|
|
651
|
+
DROP TRIGGER IF EXISTS session_for_new_discussion;
|
|
652
|
+
CREATE TRIGGER session_for_new_discussion AFTER INSERT ON room_modes
|
|
653
|
+
WHEN NEW.mode='brainstorm' AND NOT EXISTS (SELECT 1 FROM discussion_sessions WHERE room_id=NEW.room_id)
|
|
654
|
+
BEGIN
|
|
655
|
+
INSERT INTO discussion_sessions (room_id,goal,status,created_at)
|
|
656
|
+
SELECT rooms.id,rooms.goal,
|
|
657
|
+
COALESCE((SELECT CASE WHEN phase='concluded' THEN 'concluded' WHEN phase IN ('preparing','paused') THEN 'paused' ELSE 'active' END
|
|
658
|
+
FROM topic_settings WHERE room_id=rooms.id),'paused'),rooms.created_at
|
|
659
|
+
FROM rooms WHERE rooms.id=NEW.room_id;
|
|
660
|
+
END;
|
|
661
|
+
DROP TRIGGER IF EXISTS session_follows_phase_insert;
|
|
662
|
+
CREATE TRIGGER session_follows_phase_insert AFTER INSERT ON topic_settings
|
|
663
|
+
BEGIN
|
|
664
|
+
UPDATE discussion_sessions SET status=CASE WHEN NEW.phase='concluded' THEN 'concluded' WHEN NEW.phase IN ('preparing','paused') THEN 'paused' ELSE 'active' END
|
|
665
|
+
WHERE id=(SELECT MAX(id) FROM discussion_sessions WHERE room_id=NEW.room_id);
|
|
666
|
+
END;
|
|
667
|
+
DROP TRIGGER IF EXISTS session_follows_phase;
|
|
668
|
+
CREATE TRIGGER session_follows_phase AFTER UPDATE OF phase ON topic_settings
|
|
669
|
+
BEGIN
|
|
670
|
+
UPDATE discussion_sessions SET status=CASE WHEN NEW.phase='concluded' THEN 'concluded' WHEN NEW.phase IN ('preparing','paused') THEN 'paused' ELSE 'active' END,
|
|
671
|
+
ended_message_id=CASE WHEN NEW.phase='concluded' THEN COALESCE((SELECT MAX(id) FROM messages WHERE room_id=NEW.room_id),0) ELSE 0 END
|
|
672
|
+
WHERE id=(SELECT MAX(id) FROM discussion_sessions WHERE room_id=NEW.room_id);
|
|
673
|
+
END;
|