ai_co_discuss 0.3.46 → 0.3.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 +1 -0
- package/.agents/skills/discuss-host/references/discussion.md +113 -113
- package/.agents/skills/discuss-host/references/free.md +36 -34
- package/.agents/skills/discuss-host/references/goal.md +35 -0
- package/.agents/skills/discuss-participant/SKILL.md +1 -0
- package/.agents/skills/workflow-creator/SKILL.md +2 -2
- package/README.md +3 -1
- package/backend/admin.py +104 -35
- package/backend/free_room.py +2170 -2136
- package/backend/goal_room.py +358 -0
- package/backend/mode_room.py +105 -0
- package/backend/schema.sql +1593 -1510
- package/backend/server.py +2636 -2555
- package/backend/session_room.py +1180 -1148
- package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
- package/docs/deployment.md +5 -1
- package/docs/en/workflow.md +15 -1
- package/docs/runner.md +28 -7
- package/docs/workflow.md +15 -1
- package/package.json +3 -2
- package/runner/adapters/api.mjs +333 -313
- package/runner/adapters/claude.mjs +12 -16
- package/runner/admin_account.mjs +12 -19
- package/runner/device_auth.mjs +369 -0
- package/runner/discuss.py +1208 -1125
- package/runner/discussion_launch.mjs +107 -12
- package/runner/discussion_service.mjs +25 -1
- package/runner/expert_prompts.mjs +132 -132
- package/runner/free_runner.mjs +982 -910
- package/runner/pinyin_table.mjs +406 -0
- package/runner/project_files.mjs +52 -3
- package/runner/relay_channel.mjs +51 -7
- package/runner/remote_launch.mjs +221 -235
- package/runner/remote_policy.mjs +156 -0
- package/runner/scheduler.mjs +459 -429
- package/runner/stt.mjs +2 -2
- package/runner/tts.mjs +74 -113
- package/runner/tts_providers.mjs +205 -0
- package/runner/webauthn.mjs +155 -0
- package/runner/workflows.mjs +371 -348
- package/web/app.js +355 -51
- package/web/modern.css +1 -1
- package/web/modern.js +5764 -3816
package/backend/session_room.py
CHANGED
|
@@ -1,1148 +1,1180 @@
|
|
|
1
|
-
"""Request-backed discussion session state and plan operations."""
|
|
2
|
-
|
|
3
|
-
import hashlib
|
|
4
|
-
import json
|
|
5
|
-
import re
|
|
6
|
-
import time
|
|
7
|
-
|
|
8
|
-
from .free_room import active_members, finish_execution, require_member, require_owner
|
|
9
|
-
from .runtime import begin_write, enforce_session_limits, limit_values, room_limits_view, token_hash
|
|
10
|
-
|
|
11
|
-
MAX_PLAN_TURNS = 10
|
|
12
|
-
MAX_QUESTION_LENGTH = 1000
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def current_session(connection, room_id, active_only=False):
|
|
16
|
-
"""Return the latest request-model session, optionally only while it is open."""
|
|
17
|
-
status_filter = "AND status IN ('active','paused')" if active_only else ""
|
|
18
|
-
row = connection.execute(f"SELECT * FROM discussion_sessions WHERE room_id=? {status_filter} ORDER BY id DESC LIMIT 1", (room_id,)).fetchone()
|
|
19
|
-
if not row or not connection.execute("SELECT 1 FROM session_state WHERE session_id=?", (row['id'],)).fetchone():
|
|
20
|
-
return None
|
|
21
|
-
return dict(row)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def session_room(connection, room_id):
|
|
25
|
-
"""Read discussion control state and its revision without exposing credentials."""
|
|
26
|
-
if enforce_session_limits(connection, room_id):
|
|
27
|
-
connection.commit()
|
|
28
|
-
session = current_session(connection, room_id, active_only=True)
|
|
29
|
-
if not session:
|
|
30
|
-
return {"session": None, "revision": _idle_session_revision(connection, room_id)}
|
|
31
|
-
state = dict(connection.execute("SELECT * FROM session_state WHERE session_id=?", (session['id'],)).fetchone())
|
|
32
|
-
limits = dict(connection.execute("SELECT * FROM session_limits WHERE session_id=?", (session['id'],)).fetchone())
|
|
33
|
-
pause = dict(connection.execute("SELECT * FROM session_pauses WHERE session_id=?", (session['id'],)).fetchone())
|
|
34
|
-
option = connection.execute("SELECT named_followup FROM session_options WHERE session_id=?", (session['id'],)).fetchone()
|
|
35
|
-
counts = connection.execute("SELECT COUNT(*) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status='answered'", (session['id'],)).fetchone()[0]
|
|
36
|
-
session.update(state)
|
|
37
|
-
submission = connection.execute("SELECT submission_id FROM session_start_submissions WHERE session_id=?", (session['id'],)).fetchone()
|
|
38
|
-
session["start_submission_id"] = submission[0] if submission else None
|
|
39
|
-
session["named_followup"] = bool(option[0]) if option else True
|
|
40
|
-
session["limits"] = limits
|
|
41
|
-
session["pause"] = pause
|
|
42
|
-
session["answered"] = counts
|
|
43
|
-
revision = _session_revision(connection, session)
|
|
44
|
-
session["revision"] = revision
|
|
45
|
-
return {"session": session, "revision": revision}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _idle_session_revision(connection, room_id):
|
|
49
|
-
"""Bind a start token to the prior session, roster, defaults and visible history."""
|
|
50
|
-
latest_session = connection.execute("SELECT id,status FROM discussion_sessions WHERE room_id=? ORDER BY id DESC LIMIT 1",
|
|
51
|
-
(room_id,)).fetchone()
|
|
52
|
-
members = [list(row) for row in connection.execute("""SELECT m.id,m.name,m.role,m.access,m.status,
|
|
53
|
-
CASE WHEN r.token_hash=m.token_hash THEN 1 ELSE 0 END AS registered
|
|
54
|
-
FROM room_members m LEFT JOIN member_registrations r ON r.member_id=m.id
|
|
55
|
-
WHERE m.room_id=? ORDER BY m.id""", (room_id,))]
|
|
56
|
-
latest_message = connection.execute("""SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=?
|
|
57
|
-
AND NOT EXISTS(SELECT 1 FROM internal_task_messages WHERE message_id=messages.id)""", (room_id,)).fetchone()[0]
|
|
58
|
-
mode = connection.execute("SELECT mode FROM room_modes WHERE room_id=?", (room_id,)).fetchone()
|
|
59
|
-
options = connection.execute("SELECT named_followup FROM session_options WHERE session_id=?",
|
|
60
|
-
(latest_session["id"],)).fetchone() if latest_session else None
|
|
61
|
-
limits = room_limits_view(connection, room_id)
|
|
62
|
-
return _hash_revision([room_id, list(latest_session) if latest_session else None, members,
|
|
63
|
-
mode[0] if mode else "", list(options) if options else None, latest_message,
|
|
64
|
-
[limits[key] for key in ("max_rounds", "max_minutes", "execution_minutes")]])
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _hash_revision(value):
|
|
68
|
-
"""Create a compact version token from the complete state a write depends on."""
|
|
69
|
-
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
70
|
-
return hashlib.sha256(encoded.encode()).hexdigest()[:16]
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _session_revision(connection, session):
|
|
74
|
-
"""Bind a control revision to this session, its roster, queue, phase and limits."""
|
|
75
|
-
session_id = session["id"]
|
|
76
|
-
state = connection.execute("SELECT phase,read_until,held_since FROM session_state WHERE session_id=?", (session_id,)).fetchone()
|
|
77
|
-
limits = connection.execute("SELECT max_rounds,max_minutes,execution_minutes,started_at,stop_reason FROM session_limits WHERE session_id=?", (session_id,)).fetchone()
|
|
78
|
-
members = [list(row) for row in connection.execute("""SELECT m.id,m.name,m.role,m.access,m.status,
|
|
79
|
-
CASE WHEN r.token_hash=m.token_hash THEN 1 ELSE 0 END AS registered
|
|
80
|
-
FROM room_members m LEFT JOIN member_registrations r ON r.member_id=m.id
|
|
81
|
-
WHERE m.room_id=? ORDER BY m.id""", (session["room_id"],))]
|
|
82
|
-
queue = [list(row) for row in connection.execute("""SELECT id,round,position,member_id,purpose,status,submission_id,question
|
|
83
|
-
FROM session_requests WHERE session_id=? ORDER BY round,position,id""", (session_id,))]
|
|
84
|
-
options = list(connection.execute("SELECT named_followup FROM session_options WHERE session_id=?", (session_id,)).fetchone())
|
|
85
|
-
latest = connection.execute("""SELECT COALESCE(MAX(m.id),0) FROM messages m WHERE m.room_id=?
|
|
86
|
-
AND NOT EXISTS (SELECT 1 FROM internal_task_messages t WHERE t.message_id=m.id)""", (session["room_id"],)).fetchone()[0]
|
|
87
|
-
return _hash_revision([session_id, session["status"], list(state), list(limits), options, members, queue, latest])
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def require_revision(view, payload, message):
|
|
91
|
-
"""Require the exact revision read before a state-dependent session write."""
|
|
92
|
-
if payload.get("revision") is None:
|
|
93
|
-
raise ValueError(f"{message}前须先读取最新讨论状态并带上 revision")
|
|
94
|
-
if str(payload["revision"]) != str(view["revision"]):
|
|
95
|
-
raise FileExistsError("讨论状态已变化,请重新读取后再提交")
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _task_message(connection, session_id, request_id, cause):
|
|
99
|
-
"""Create a hidden, uniquely sourced message used only as a member request anchor."""
|
|
100
|
-
task = connection.execute("SELECT * FROM session_requests WHERE id=? AND session_id=?", (request_id, session_id)).fetchone()
|
|
101
|
-
existing = connection.execute("SELECT message_id FROM task_message_sources WHERE session_request_id=?", (request_id,)).fetchone()
|
|
102
|
-
if existing:
|
|
103
|
-
return existing["message_id"]
|
|
104
|
-
room_id = connection.execute("SELECT room_id FROM discussion_sessions WHERE id=?", (session_id,)).fetchone()[0]
|
|
105
|
-
submission_id = f"session-task-{request_id}"
|
|
106
|
-
created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
107
|
-
cursor = connection.execute("INSERT INTO messages(room_id,author,text,source,submission_id,created_at) VALUES(?,?,?,?,?,?)",
|
|
108
|
-
(room_id, "系统任务", task["question"], "system", submission_id, created_at))
|
|
109
|
-
message_id = cursor.lastrowid
|
|
110
|
-
connection.execute("INSERT INTO task_message_sources(message_id,session_id,session_request_id,cause_kind,cause_message_id,cause_request_id) VALUES(?,?,?,?,?,?)",
|
|
111
|
-
(message_id, session_id, request_id, cause["kind"], cause.get("message_id"), cause.get("request_id")))
|
|
112
|
-
return message_id
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _open_request(connection, session_request, cause):
|
|
116
|
-
"""Anchor a session plan to one internal task message and create its normal member request."""
|
|
117
|
-
request_id = session_request["id"]
|
|
118
|
-
message_id = _task_message(connection, session_request["session_id"], request_id, cause)
|
|
119
|
-
existing = connection.execute("SELECT id FROM member_requests WHERE message_id=? AND member_id=?", (message_id, session_request["member_id"])).fetchone()
|
|
120
|
-
if existing:
|
|
121
|
-
member_request_id = existing["id"]
|
|
122
|
-
else:
|
|
123
|
-
session = connection.execute("SELECT room_id FROM discussion_sessions WHERE id=?", (session_request["session_id"],)).fetchone()
|
|
124
|
-
cursor = connection.execute("""INSERT INTO member_requests
|
|
125
|
-
(room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
|
|
126
|
-
VALUES(?,?,?,?,?,?,?)""", (session["room_id"], session_request["member_id"],
|
|
127
|
-
message_id, message_id, session_request["purpose"], "host", int(time.time())))
|
|
128
|
-
member_request_id = cursor.lastrowid
|
|
129
|
-
connection.execute("UPDATE session_requests SET status='open',member_request_id=? WHERE id=?", (member_request_id, request_id))
|
|
130
|
-
return member_request_id
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def create_host_request(connection, session_id, question, cause):
|
|
134
|
-
"""Create one idempotent host request for a session event."""
|
|
135
|
-
state = connection.execute("SELECT phase FROM session_state WHERE session_id=?", (session_id,)).fetchone()
|
|
136
|
-
if not state:
|
|
137
|
-
raise LookupError("讨论会话不存在")
|
|
138
|
-
session = connection.execute("SELECT room_id FROM discussion_sessions WHERE id=?", (session_id,)).fetchone()
|
|
139
|
-
host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active'", (session["room_id"],)).fetchone()
|
|
140
|
-
if not host:
|
|
141
|
-
raise ValueError("当前讨论没有在场的 AI 主持人")
|
|
142
|
-
cause_kind = cause["kind"]
|
|
143
|
-
key = str(cause.get("key") or cause.get("message_id") or cause.get("request_id") or cause_kind)
|
|
144
|
-
submission_id = f"host-{session_id}-{cause_kind}-{key}"[:100]
|
|
145
|
-
row = connection.execute("SELECT * FROM session_requests WHERE session_id=? AND submission_id=?", (session_id, submission_id)).fetchone()
|
|
146
|
-
if row:
|
|
147
|
-
return dict(row)
|
|
148
|
-
position = connection.execute("SELECT COALESCE(MAX(position),0)+1 FROM session_requests WHERE session_id=? AND purpose='discuss_host'", (session_id,)).fetchone()[0]
|
|
149
|
-
cursor = connection.execute("INSERT INTO session_requests(session_id,round,position,member_id,purpose,question,status,submission_id,read_until,created_at) VALUES(?,0,?,?,'discuss_host',?,'open',?,NULL,?)",
|
|
150
|
-
(session_id, position, host["id"], question, submission_id, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
|
|
151
|
-
row = connection.execute("SELECT * FROM session_requests WHERE id=?", (cursor.lastrowid,)).fetchone()
|
|
152
|
-
_open_request(connection, row, cause)
|
|
153
|
-
return dict(connection.execute("SELECT * FROM session_requests WHERE id=?", (cursor.lastrowid,)).fetchone())
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def advance_session(connection, session_id):
|
|
157
|
-
"""Promote the next queued discussion plan; execution reservation enforces session exclusivity."""
|
|
158
|
-
session = connection.execute("SELECT status FROM discussion_sessions WHERE id=?", (session_id,)).fetchone()
|
|
159
|
-
if not session or session["status"] != "active":
|
|
160
|
-
return None
|
|
161
|
-
active = connection.execute("SELECT id FROM session_requests WHERE session_id=? AND purpose='discuss' AND status='open'", (session_id,)).fetchone()
|
|
162
|
-
if active:
|
|
163
|
-
return dict(connection.execute("SELECT * FROM session_requests WHERE id=?", (active["id"],)).fetchone())
|
|
164
|
-
queued = connection.execute("SELECT * FROM session_requests WHERE session_id=? AND purpose='discuss' AND status='queued' ORDER BY round,position,id LIMIT 1", (session_id,)).fetchone()
|
|
165
|
-
if not queued:
|
|
166
|
-
return None
|
|
167
|
-
request = dict(queued)
|
|
168
|
-
_open_request(connection, request, {"kind": "scheduled_speaker"})
|
|
169
|
-
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session_id,))
|
|
170
|
-
return dict(connection.execute("SELECT * FROM session_requests WHERE id=?", (queued["id"],)).fetchone())
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _start_submission(connection, room_id, payload):
|
|
174
|
-
"""Bind an optional launch replay id to the exact original start request."""
|
|
175
|
-
submission_id = payload.get("submission_id")
|
|
176
|
-
if submission_id is None:
|
|
177
|
-
return None
|
|
178
|
-
if not isinstance(submission_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{8,100}", submission_id):
|
|
179
|
-
raise ValueError("submission_id 须为 8–100 位字母、数字、下划线或短横线")
|
|
180
|
-
request_json = json.dumps({key: payload[key] for key in ("revision", "goal", "limits", "named_followup") if key in payload},
|
|
181
|
-
ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
182
|
-
row = connection.execute("SELECT * FROM session_start_submissions WHERE room_id=? AND submission_id=?",
|
|
183
|
-
(room_id, submission_id)).fetchone()
|
|
184
|
-
if row and row["request_json"] != request_json:
|
|
185
|
-
raise FileExistsError("这个 submission_id 已用于不同的开启请求;请核对原提交")
|
|
186
|
-
session = dict(connection.execute("SELECT * FROM discussion_sessions WHERE id=?", (row["session_id"],)).fetchone()) if row else None
|
|
187
|
-
return {"submission_id": submission_id, "request_json": request_json, "session": session}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def
|
|
191
|
-
"""
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
(
|
|
227
|
-
connection.execute("
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
"
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if session
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
session
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
connection.execute("UPDATE discussion_sessions SET status='
|
|
315
|
-
connection.execute("UPDATE
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
WHERE session_id=?"
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if
|
|
406
|
-
|
|
407
|
-
if
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def
|
|
421
|
-
"""
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
"""
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
connection.execute("
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
if
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
return
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
def
|
|
910
|
-
"""
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
connection.execute(
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
return
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
def
|
|
942
|
-
"""
|
|
943
|
-
begin_write(connection)
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
if
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
"
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
if
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
if
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
if
|
|
1048
|
-
|
|
1049
|
-
request_id =
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
def
|
|
1135
|
-
"""
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1
|
+
"""Request-backed discussion session state and plan operations."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .free_room import active_members, finish_execution, require_member, require_owner
|
|
9
|
+
from .runtime import begin_write, enforce_session_limits, limit_values, room_limits_view, token_hash
|
|
10
|
+
|
|
11
|
+
MAX_PLAN_TURNS = 10
|
|
12
|
+
MAX_QUESTION_LENGTH = 1000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def current_session(connection, room_id, active_only=False):
|
|
16
|
+
"""Return the latest request-model session, optionally only while it is open."""
|
|
17
|
+
status_filter = "AND status IN ('active','paused')" if active_only else ""
|
|
18
|
+
row = connection.execute(f"SELECT * FROM discussion_sessions WHERE room_id=? {status_filter} ORDER BY id DESC LIMIT 1", (room_id,)).fetchone()
|
|
19
|
+
if not row or not connection.execute("SELECT 1 FROM session_state WHERE session_id=?", (row['id'],)).fetchone():
|
|
20
|
+
return None
|
|
21
|
+
return dict(row)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def session_room(connection, room_id):
|
|
25
|
+
"""Read discussion control state and its revision without exposing credentials."""
|
|
26
|
+
if enforce_session_limits(connection, room_id):
|
|
27
|
+
connection.commit()
|
|
28
|
+
session = current_session(connection, room_id, active_only=True)
|
|
29
|
+
if not session:
|
|
30
|
+
return {"session": None, "revision": _idle_session_revision(connection, room_id)}
|
|
31
|
+
state = dict(connection.execute("SELECT * FROM session_state WHERE session_id=?", (session['id'],)).fetchone())
|
|
32
|
+
limits = dict(connection.execute("SELECT * FROM session_limits WHERE session_id=?", (session['id'],)).fetchone())
|
|
33
|
+
pause = dict(connection.execute("SELECT * FROM session_pauses WHERE session_id=?", (session['id'],)).fetchone())
|
|
34
|
+
option = connection.execute("SELECT named_followup FROM session_options WHERE session_id=?", (session['id'],)).fetchone()
|
|
35
|
+
counts = connection.execute("SELECT COUNT(*) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status='answered'", (session['id'],)).fetchone()[0]
|
|
36
|
+
session.update(state)
|
|
37
|
+
submission = connection.execute("SELECT submission_id FROM session_start_submissions WHERE session_id=?", (session['id'],)).fetchone()
|
|
38
|
+
session["start_submission_id"] = submission[0] if submission else None
|
|
39
|
+
session["named_followup"] = bool(option[0]) if option else True
|
|
40
|
+
session["limits"] = limits
|
|
41
|
+
session["pause"] = pause
|
|
42
|
+
session["answered"] = counts
|
|
43
|
+
revision = _session_revision(connection, session)
|
|
44
|
+
session["revision"] = revision
|
|
45
|
+
return {"session": session, "revision": revision}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _idle_session_revision(connection, room_id):
|
|
49
|
+
"""Bind a start token to the prior session, roster, defaults and visible history."""
|
|
50
|
+
latest_session = connection.execute("SELECT id,status FROM discussion_sessions WHERE room_id=? ORDER BY id DESC LIMIT 1",
|
|
51
|
+
(room_id,)).fetchone()
|
|
52
|
+
members = [list(row) for row in connection.execute("""SELECT m.id,m.name,m.role,m.access,m.status,
|
|
53
|
+
CASE WHEN r.token_hash=m.token_hash THEN 1 ELSE 0 END AS registered
|
|
54
|
+
FROM room_members m LEFT JOIN member_registrations r ON r.member_id=m.id
|
|
55
|
+
WHERE m.room_id=? ORDER BY m.id""", (room_id,))]
|
|
56
|
+
latest_message = connection.execute("""SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=?
|
|
57
|
+
AND NOT EXISTS(SELECT 1 FROM internal_task_messages WHERE message_id=messages.id)""", (room_id,)).fetchone()[0]
|
|
58
|
+
mode = connection.execute("SELECT mode FROM room_modes WHERE room_id=?", (room_id,)).fetchone()
|
|
59
|
+
options = connection.execute("SELECT named_followup FROM session_options WHERE session_id=?",
|
|
60
|
+
(latest_session["id"],)).fetchone() if latest_session else None
|
|
61
|
+
limits = room_limits_view(connection, room_id)
|
|
62
|
+
return _hash_revision([room_id, list(latest_session) if latest_session else None, members,
|
|
63
|
+
mode[0] if mode else "", list(options) if options else None, latest_message,
|
|
64
|
+
[limits[key] for key in ("max_rounds", "max_minutes", "execution_minutes")]])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _hash_revision(value):
|
|
68
|
+
"""Create a compact version token from the complete state a write depends on."""
|
|
69
|
+
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
70
|
+
return hashlib.sha256(encoded.encode()).hexdigest()[:16]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _session_revision(connection, session):
|
|
74
|
+
"""Bind a control revision to this session, its roster, queue, phase and limits."""
|
|
75
|
+
session_id = session["id"]
|
|
76
|
+
state = connection.execute("SELECT phase,read_until,held_since FROM session_state WHERE session_id=?", (session_id,)).fetchone()
|
|
77
|
+
limits = connection.execute("SELECT max_rounds,max_minutes,execution_minutes,started_at,stop_reason FROM session_limits WHERE session_id=?", (session_id,)).fetchone()
|
|
78
|
+
members = [list(row) for row in connection.execute("""SELECT m.id,m.name,m.role,m.access,m.status,
|
|
79
|
+
CASE WHEN r.token_hash=m.token_hash THEN 1 ELSE 0 END AS registered
|
|
80
|
+
FROM room_members m LEFT JOIN member_registrations r ON r.member_id=m.id
|
|
81
|
+
WHERE m.room_id=? ORDER BY m.id""", (session["room_id"],))]
|
|
82
|
+
queue = [list(row) for row in connection.execute("""SELECT id,round,position,member_id,purpose,status,submission_id,question
|
|
83
|
+
FROM session_requests WHERE session_id=? ORDER BY round,position,id""", (session_id,))]
|
|
84
|
+
options = list(connection.execute("SELECT named_followup FROM session_options WHERE session_id=?", (session_id,)).fetchone())
|
|
85
|
+
latest = connection.execute("""SELECT COALESCE(MAX(m.id),0) FROM messages m WHERE m.room_id=?
|
|
86
|
+
AND NOT EXISTS (SELECT 1 FROM internal_task_messages t WHERE t.message_id=m.id)""", (session["room_id"],)).fetchone()[0]
|
|
87
|
+
return _hash_revision([session_id, session["status"], list(state), list(limits), options, members, queue, latest])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def require_revision(view, payload, message):
|
|
91
|
+
"""Require the exact revision read before a state-dependent session write."""
|
|
92
|
+
if payload.get("revision") is None:
|
|
93
|
+
raise ValueError(f"{message}前须先读取最新讨论状态并带上 revision")
|
|
94
|
+
if str(payload["revision"]) != str(view["revision"]):
|
|
95
|
+
raise FileExistsError("讨论状态已变化,请重新读取后再提交")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _task_message(connection, session_id, request_id, cause):
|
|
99
|
+
"""Create a hidden, uniquely sourced message used only as a member request anchor."""
|
|
100
|
+
task = connection.execute("SELECT * FROM session_requests WHERE id=? AND session_id=?", (request_id, session_id)).fetchone()
|
|
101
|
+
existing = connection.execute("SELECT message_id FROM task_message_sources WHERE session_request_id=?", (request_id,)).fetchone()
|
|
102
|
+
if existing:
|
|
103
|
+
return existing["message_id"]
|
|
104
|
+
room_id = connection.execute("SELECT room_id FROM discussion_sessions WHERE id=?", (session_id,)).fetchone()[0]
|
|
105
|
+
submission_id = f"session-task-{request_id}"
|
|
106
|
+
created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
107
|
+
cursor = connection.execute("INSERT INTO messages(room_id,author,text,source,submission_id,created_at) VALUES(?,?,?,?,?,?)",
|
|
108
|
+
(room_id, "系统任务", task["question"], "system", submission_id, created_at))
|
|
109
|
+
message_id = cursor.lastrowid
|
|
110
|
+
connection.execute("INSERT INTO task_message_sources(message_id,session_id,session_request_id,cause_kind,cause_message_id,cause_request_id) VALUES(?,?,?,?,?,?)",
|
|
111
|
+
(message_id, session_id, request_id, cause["kind"], cause.get("message_id"), cause.get("request_id")))
|
|
112
|
+
return message_id
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _open_request(connection, session_request, cause):
|
|
116
|
+
"""Anchor a session plan to one internal task message and create its normal member request."""
|
|
117
|
+
request_id = session_request["id"]
|
|
118
|
+
message_id = _task_message(connection, session_request["session_id"], request_id, cause)
|
|
119
|
+
existing = connection.execute("SELECT id FROM member_requests WHERE message_id=? AND member_id=?", (message_id, session_request["member_id"])).fetchone()
|
|
120
|
+
if existing:
|
|
121
|
+
member_request_id = existing["id"]
|
|
122
|
+
else:
|
|
123
|
+
session = connection.execute("SELECT room_id FROM discussion_sessions WHERE id=?", (session_request["session_id"],)).fetchone()
|
|
124
|
+
cursor = connection.execute("""INSERT INTO member_requests
|
|
125
|
+
(room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
|
|
126
|
+
VALUES(?,?,?,?,?,?,?)""", (session["room_id"], session_request["member_id"],
|
|
127
|
+
message_id, message_id, session_request["purpose"], "host", int(time.time())))
|
|
128
|
+
member_request_id = cursor.lastrowid
|
|
129
|
+
connection.execute("UPDATE session_requests SET status='open',member_request_id=? WHERE id=?", (member_request_id, request_id))
|
|
130
|
+
return member_request_id
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def create_host_request(connection, session_id, question, cause):
|
|
134
|
+
"""Create one idempotent host request for a session event."""
|
|
135
|
+
state = connection.execute("SELECT phase FROM session_state WHERE session_id=?", (session_id,)).fetchone()
|
|
136
|
+
if not state:
|
|
137
|
+
raise LookupError("讨论会话不存在")
|
|
138
|
+
session = connection.execute("SELECT room_id FROM discussion_sessions WHERE id=?", (session_id,)).fetchone()
|
|
139
|
+
host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active'", (session["room_id"],)).fetchone()
|
|
140
|
+
if not host:
|
|
141
|
+
raise ValueError("当前讨论没有在场的 AI 主持人")
|
|
142
|
+
cause_kind = cause["kind"]
|
|
143
|
+
key = str(cause.get("key") or cause.get("message_id") or cause.get("request_id") or cause_kind)
|
|
144
|
+
submission_id = f"host-{session_id}-{cause_kind}-{key}"[:100]
|
|
145
|
+
row = connection.execute("SELECT * FROM session_requests WHERE session_id=? AND submission_id=?", (session_id, submission_id)).fetchone()
|
|
146
|
+
if row:
|
|
147
|
+
return dict(row)
|
|
148
|
+
position = connection.execute("SELECT COALESCE(MAX(position),0)+1 FROM session_requests WHERE session_id=? AND purpose='discuss_host'", (session_id,)).fetchone()[0]
|
|
149
|
+
cursor = connection.execute("INSERT INTO session_requests(session_id,round,position,member_id,purpose,question,status,submission_id,read_until,created_at) VALUES(?,0,?,?,'discuss_host',?,'open',?,NULL,?)",
|
|
150
|
+
(session_id, position, host["id"], question, submission_id, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
|
|
151
|
+
row = connection.execute("SELECT * FROM session_requests WHERE id=?", (cursor.lastrowid,)).fetchone()
|
|
152
|
+
_open_request(connection, row, cause)
|
|
153
|
+
return dict(connection.execute("SELECT * FROM session_requests WHERE id=?", (cursor.lastrowid,)).fetchone())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def advance_session(connection, session_id):
|
|
157
|
+
"""Promote the next queued discussion plan; execution reservation enforces session exclusivity."""
|
|
158
|
+
session = connection.execute("SELECT status FROM discussion_sessions WHERE id=?", (session_id,)).fetchone()
|
|
159
|
+
if not session or session["status"] != "active":
|
|
160
|
+
return None
|
|
161
|
+
active = connection.execute("SELECT id FROM session_requests WHERE session_id=? AND purpose='discuss' AND status='open'", (session_id,)).fetchone()
|
|
162
|
+
if active:
|
|
163
|
+
return dict(connection.execute("SELECT * FROM session_requests WHERE id=?", (active["id"],)).fetchone())
|
|
164
|
+
queued = connection.execute("SELECT * FROM session_requests WHERE session_id=? AND purpose='discuss' AND status='queued' ORDER BY round,position,id LIMIT 1", (session_id,)).fetchone()
|
|
165
|
+
if not queued:
|
|
166
|
+
return None
|
|
167
|
+
request = dict(queued)
|
|
168
|
+
_open_request(connection, request, {"kind": "scheduled_speaker"})
|
|
169
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session_id,))
|
|
170
|
+
return dict(connection.execute("SELECT * FROM session_requests WHERE id=?", (queued["id"],)).fetchone())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _start_submission(connection, room_id, payload):
|
|
174
|
+
"""Bind an optional launch replay id to the exact original start request."""
|
|
175
|
+
submission_id = payload.get("submission_id")
|
|
176
|
+
if submission_id is None:
|
|
177
|
+
return None
|
|
178
|
+
if not isinstance(submission_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{8,100}", submission_id):
|
|
179
|
+
raise ValueError("submission_id 须为 8–100 位字母、数字、下划线或短横线")
|
|
180
|
+
request_json = json.dumps({key: payload[key] for key in ("revision", "goal", "limits", "named_followup") if key in payload},
|
|
181
|
+
ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
182
|
+
row = connection.execute("SELECT * FROM session_start_submissions WHERE room_id=? AND submission_id=?",
|
|
183
|
+
(room_id, submission_id)).fetchone()
|
|
184
|
+
if row and row["request_json"] != request_json:
|
|
185
|
+
raise FileExistsError("这个 submission_id 已用于不同的开启请求;请核对原提交")
|
|
186
|
+
session = dict(connection.execute("SELECT * FROM discussion_sessions WHERE id=?", (row["session_id"],)).fetchone()) if row else None
|
|
187
|
+
return {"submission_id": submission_id, "request_json": request_json, "session": session}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def start_request(connection, room_id, token, payload):
|
|
191
|
+
"""Check the host's `discuss_start` request from an owner's `/discuss`; it stands in for the owner's revision."""
|
|
192
|
+
member = require_member(connection, room_id, token)
|
|
193
|
+
if member["role"] != "host":
|
|
194
|
+
raise PermissionError("只有主持人能补全并开启讨论")
|
|
195
|
+
request = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, payload["request_id"])).fetchone()
|
|
196
|
+
if (not request or request["purpose"] != "discuss_start" or request["member_id"] != member["id"]
|
|
197
|
+
or request["status"] not in ("pending", "delivering")):
|
|
198
|
+
raise FileExistsError("讨论启动请求不存在、不属于你或已处理;只有用户的 /discuss 命令能由主持人开启讨论")
|
|
199
|
+
# The request is the one launch: a retry returns the same session, other content is refused.
|
|
200
|
+
payload["submission_id"] = f"discuss-start-{request['id']}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def start_session(connection, room_id, token, payload, room):
|
|
204
|
+
"""Start an in-room discussion session without changing the room's free-collaboration mode.
|
|
205
|
+
|
|
206
|
+
The owner starts with the revision just read; the host may instead start one bound to its `discuss_start` request.
|
|
207
|
+
"""
|
|
208
|
+
begin_write(connection)
|
|
209
|
+
by_host = payload.get("request_id") is not None
|
|
210
|
+
if by_host:
|
|
211
|
+
start_request(connection, room_id, token, payload)
|
|
212
|
+
else:
|
|
213
|
+
require_owner(connection, room_id, token)
|
|
214
|
+
current = session_room(connection, room_id)
|
|
215
|
+
submission = _start_submission(connection, room_id, payload)
|
|
216
|
+
if submission and submission["session"]:
|
|
217
|
+
original = submission["session"]
|
|
218
|
+
session = current["session"] if current["session"] and current["session"]["id"] == original["id"] else original
|
|
219
|
+
return {"session": {**session, "start_submission_id": submission["submission_id"]},
|
|
220
|
+
"revision": current["revision"], "duplicate": True}
|
|
221
|
+
if room["archived"]:
|
|
222
|
+
raise ValueError("房间已存档;恢复后才能开启讨论")
|
|
223
|
+
if not by_host:
|
|
224
|
+
require_revision(current, payload, "开启讨论")
|
|
225
|
+
if current["session"]:
|
|
226
|
+
raise FileExistsError("房间已有进行中的讨论会话")
|
|
227
|
+
if connection.execute("SELECT 1 FROM room_goals WHERE room_id=? AND status IN ('active','paused')", (room_id,)).fetchone():
|
|
228
|
+
raise FileExistsError("Goal 进行中;结束 Goal 后才能开启讨论")
|
|
229
|
+
members = active_members(connection, room_id, registered_only=False)
|
|
230
|
+
host = next((member for member in members.values() if member["role"] == "host" and member["access"] == "runner"), None)
|
|
231
|
+
if not host:
|
|
232
|
+
raise ValueError("开启讨论需要一位由本机托管的 AI 主持人")
|
|
233
|
+
goal = str(payload.get("goal", room["goal"])).strip()
|
|
234
|
+
if not goal or len(goal) > 1000:
|
|
235
|
+
raise ValueError("讨论目标须为 1–1000 字")
|
|
236
|
+
# The host fills in only the goal: limits are the owner's, so a host start takes the room's defaults.
|
|
237
|
+
limits = limit_values({} if by_host else payload.get("limits", {}), room_limits_view(connection, room_id))
|
|
238
|
+
named_followup = payload.get("named_followup", False)
|
|
239
|
+
if type(named_followup) is not bool:
|
|
240
|
+
raise ValueError("named_followup 须为布尔值")
|
|
241
|
+
latest = connection.execute("SELECT COALESCE(MAX(m.id),0) FROM messages m WHERE m.room_id=? AND NOT EXISTS (SELECT 1 FROM internal_task_messages t WHERE t.message_id=m.id)", (room_id,)).fetchone()[0]
|
|
242
|
+
created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
243
|
+
cursor = connection.execute("INSERT INTO discussion_sessions(room_id,goal,status,started_message_id,created_at) VALUES(?,?,'active',?,?)", (room_id, goal, latest, created_at))
|
|
244
|
+
session_id = cursor.lastrowid
|
|
245
|
+
if submission:
|
|
246
|
+
connection.execute("INSERT INTO session_start_submissions VALUES(?,?,?,?)",
|
|
247
|
+
(room_id, submission["submission_id"], session_id, submission["request_json"]))
|
|
248
|
+
connection.execute("INSERT INTO session_state(session_id,phase,revision,read_until) VALUES(?,'initial',0,?)", (session_id, latest))
|
|
249
|
+
connection.execute("INSERT INTO session_limits(session_id,max_rounds,max_minutes,execution_minutes,started_at) VALUES(?,?,?,?,?)",
|
|
250
|
+
(session_id, limits["max_rounds"], limits["max_minutes"], limits["execution_minutes"], int(time.time())))
|
|
251
|
+
connection.execute("INSERT INTO session_round_budgets(session_id) VALUES(?)", (session_id,))
|
|
252
|
+
connection.execute("INSERT INTO session_pauses(session_id) VALUES(?)", (session_id,))
|
|
253
|
+
connection.execute("INSERT INTO session_options(session_id,named_followup) VALUES(?,?)", (session_id, int(named_followup)))
|
|
254
|
+
connection.execute("UPDATE rooms SET goal=? WHERE id=?", (goal, room_id))
|
|
255
|
+
create_host_request(connection, session_id, f"请主持讨论:{goal}。先确认目标与限制,再安排第一位专家发言。",
|
|
256
|
+
{"kind": "session_started", "key": "start"})
|
|
257
|
+
return {**session_room(connection, room_id), "duplicate": False}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cancel_session_work(connection, session_id, reason):
|
|
261
|
+
"""Cancel queued and open session plans together with their linked requests and executions."""
|
|
262
|
+
rows = connection.execute("SELECT sr.id,sr.member_request_id FROM session_requests sr WHERE sr.session_id=? AND sr.status IN ('queued','open')", (session_id,)).fetchall()
|
|
263
|
+
for row in rows:
|
|
264
|
+
if row["member_request_id"]:
|
|
265
|
+
request = connection.execute("SELECT execution_id,status FROM member_requests WHERE id=?", (row["member_request_id"],)).fetchone()
|
|
266
|
+
if request and request["status"] in ('pending','delivering'):
|
|
267
|
+
if request["execution_id"]:
|
|
268
|
+
finish_execution(connection, request["execution_id"], "cancelled", reason, "cancelled", "user_stop")
|
|
269
|
+
else:
|
|
270
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (row["member_request_id"],))
|
|
271
|
+
connection.execute("UPDATE session_requests SET status='cancelled' WHERE id=?", (row["id"],))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def session_member_change(connection, room_id, member, action):
|
|
275
|
+
"""Invalidate queued discussion work after a roster change and ask the host to re-plan."""
|
|
276
|
+
session = current_session(connection, room_id, active_only=True)
|
|
277
|
+
if not session:
|
|
278
|
+
return
|
|
279
|
+
member_rows = connection.execute("SELECT * FROM session_requests WHERE session_id=? AND member_id=? AND purpose='discuss' AND status IN ('open','queued','failed')",
|
|
280
|
+
(session["id"], member["id"])).fetchall() if action in ("away", "remove") else []
|
|
281
|
+
rows = connection.execute("SELECT * FROM session_requests WHERE session_id=? AND purpose='discuss' AND status='queued'",
|
|
282
|
+
(session["id"],)).fetchall()
|
|
283
|
+
for row in rows:
|
|
284
|
+
if row["member_request_id"]:
|
|
285
|
+
request = connection.execute("SELECT execution_id,status FROM member_requests WHERE id=?", (row["member_request_id"],)).fetchone()
|
|
286
|
+
if request and request["status"] == "pending":
|
|
287
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (row["member_request_id"],))
|
|
288
|
+
connection.execute("UPDATE session_requests SET status='skipped' WHERE id=?", (row["id"],))
|
|
289
|
+
for row in member_rows:
|
|
290
|
+
if row["member_request_id"]:
|
|
291
|
+
request = connection.execute("SELECT execution_id,status FROM member_requests WHERE id=?", (row["member_request_id"],)).fetchone()
|
|
292
|
+
if request and request["status"] in ("pending", "delivering"):
|
|
293
|
+
if request["execution_id"]:
|
|
294
|
+
finish_execution(connection, request["execution_id"], "cancelled", "成员已离开", "cancelled", "member_away")
|
|
295
|
+
else:
|
|
296
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (row["member_request_id"],))
|
|
297
|
+
connection.execute("UPDATE session_requests SET status='cancelled' WHERE id=? AND status='open'", (row["id"],))
|
|
298
|
+
connection.execute("UPDATE session_requests SET status='skipped' WHERE id=? AND status='queued'", (row["id"],))
|
|
299
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
|
|
300
|
+
state_revision = connection.execute("SELECT revision FROM session_state WHERE session_id=?", (session["id"],)).fetchone()[0]
|
|
301
|
+
if session["status"] == "active":
|
|
302
|
+
detail = ("暂离" if action == "away" else "移除" if action == "remove" else "请回")
|
|
303
|
+
create_host_request(connection, session["id"],
|
|
304
|
+
f"专家 {member['name']} 已{detail};剩余排队安排已作废,请检查当前发言并重新安排。",
|
|
305
|
+
{"kind": "member_change", "key": f"member-{member['id']}-{action}-{state_revision}"})
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def pause_session_for_host_change(connection, room_id):
|
|
309
|
+
"""Pause an active discussion and cancel its assignments before its host changes."""
|
|
310
|
+
session = current_session(connection, room_id, active_only=True)
|
|
311
|
+
if not session or session["status"] != "active":
|
|
312
|
+
return
|
|
313
|
+
cancel_session_work(connection, session["id"], "主持人已更换")
|
|
314
|
+
connection.execute("UPDATE discussion_sessions SET status='paused' WHERE id=?", (session["id"],))
|
|
315
|
+
connection.execute("UPDATE session_pauses SET paused_at=? WHERE session_id=?", (int(time.time()), session["id"]))
|
|
316
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def end_session(connection, room_id, token, payload, reason="user_stop"):
|
|
320
|
+
"""Conclude the active session, snapshot its state and leave the room available for free collaboration."""
|
|
321
|
+
require_owner(connection, room_id, token)
|
|
322
|
+
begin_write(connection)
|
|
323
|
+
current = session_room(connection, room_id)
|
|
324
|
+
require_revision(current, payload, "结束讨论")
|
|
325
|
+
session = current["session"]
|
|
326
|
+
if not session:
|
|
327
|
+
raise FileExistsError("房间没有进行中的讨论")
|
|
328
|
+
return _conclude_session(connection, session, reason)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _conclude_session(connection, session, reason):
|
|
332
|
+
"""Cancel session work, persist the conclusion snapshot and stop the session."""
|
|
333
|
+
room_id = session["room_id"]
|
|
334
|
+
session_id = session["id"]
|
|
335
|
+
cancel_session_work(connection, session_id, "讨论已结束")
|
|
336
|
+
now = int(time.time())
|
|
337
|
+
latest = connection.execute("SELECT COALESCE(MAX(m.id),0) FROM messages m WHERE m.room_id=? AND NOT EXISTS (SELECT 1 FROM internal_task_messages t WHERE t.message_id=m.id)", (room_id,)).fetchone()[0]
|
|
338
|
+
connection.execute("UPDATE discussion_sessions SET status='concluded',ended_message_id=? WHERE id=?", (latest, session_id))
|
|
339
|
+
connection.execute("UPDATE session_limits SET stop_reason=?,revision=revision+1 WHERE session_id=?", (reason, session_id))
|
|
340
|
+
state = connection.execute("SELECT state_text,state_until FROM rooms WHERE id=?", (room_id,)).fetchone()
|
|
341
|
+
brief = connection.execute("SELECT text,until FROM room_briefs WHERE room_id=?", (room_id,)).fetchone()
|
|
342
|
+
started = session["started_message_id"]
|
|
343
|
+
limits = connection.execute("SELECT max_rounds,max_minutes FROM session_limits WHERE session_id=?", (session_id,)).fetchone()
|
|
344
|
+
connection.execute("INSERT OR REPLACE INTO discussion_conclusions(session_id,room_id,stop_reason,max_rounds,max_minutes,brief,state,state_until,concluded_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
|
345
|
+
(session_id, room_id, reason, limits["max_rounds"], limits["max_minutes"],
|
|
346
|
+
brief["text"] if brief and brief["until"] > started else "", state["state_text"] if state["state_until"] > started else "",
|
|
347
|
+
state["state_until"] if state["state_until"] > started else 0,
|
|
348
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))))
|
|
349
|
+
return session_room(connection, room_id)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def discussion_action(connection, room_id, token, payload, room):
|
|
353
|
+
"""Start, pause, resume or end a session using the revision returned by GET /discussion."""
|
|
354
|
+
action = payload.get("action")
|
|
355
|
+
if action == "start":
|
|
356
|
+
return start_session(connection, room_id, token, payload, room)
|
|
357
|
+
require_owner(connection, room_id, token)
|
|
358
|
+
begin_write(connection)
|
|
359
|
+
current = session_room(connection, room_id)
|
|
360
|
+
require_revision(current, payload, action or "讨论控制")
|
|
361
|
+
session = current["session"]
|
|
362
|
+
if not session:
|
|
363
|
+
raise FileExistsError("房间没有进行中的讨论")
|
|
364
|
+
if action == "end":
|
|
365
|
+
return end_session(connection, room_id, token, payload)
|
|
366
|
+
if action not in ("pause", "resume"):
|
|
367
|
+
raise ValueError("action 须为 start、pause、resume 或 end")
|
|
368
|
+
control_session(connection, session, action)
|
|
369
|
+
return session_room(connection, room_id)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def control_session(connection, session, action):
|
|
373
|
+
"""Pause, resume or stop an open session; the owner's revision or `/discuss` command was checked by the caller."""
|
|
374
|
+
if action == "stop":
|
|
375
|
+
return _conclude_session(connection, session, "user_stop")
|
|
376
|
+
if action == "pause":
|
|
377
|
+
if session["status"] != "active":
|
|
378
|
+
raise FileExistsError("讨论已经暂停")
|
|
379
|
+
cancel_session_work(connection, session["id"], "用户暂停讨论")
|
|
380
|
+
connection.execute("UPDATE discussion_sessions SET status='paused' WHERE id=?", (session["id"],))
|
|
381
|
+
connection.execute("UPDATE session_pauses SET paused_at=? WHERE session_id=?", (int(time.time()), session["id"]))
|
|
382
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
|
|
383
|
+
elif action == "resume":
|
|
384
|
+
if session["status"] != "paused":
|
|
385
|
+
raise FileExistsError("讨论当前没有暂停")
|
|
386
|
+
pause = connection.execute("SELECT paused_at FROM session_pauses WHERE session_id=?", (session["id"],)).fetchone()[0]
|
|
387
|
+
connection.execute("UPDATE discussion_sessions SET status='active' WHERE id=?", (session["id"],))
|
|
388
|
+
connection.execute("UPDATE session_pauses SET paused_seconds=paused_seconds+MAX(0,?-paused_at),paused_at=0 WHERE session_id=?", (int(time.time()), session["id"]))
|
|
389
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
|
|
390
|
+
if pause:
|
|
391
|
+
create_host_request(connection, session["id"], "讨论已恢复,请检查队列并继续。",
|
|
392
|
+
{"kind": "member_change", "key": f"resume-{session['id']}-{session['revision']}"})
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def update_session_limits(connection, room_id, token, payload):
|
|
397
|
+
"""Update active-session limits using the state revision returned by GET /discussion."""
|
|
398
|
+
require_owner(connection, room_id, token)
|
|
399
|
+
begin_write(connection)
|
|
400
|
+
current = session_room(connection, room_id)
|
|
401
|
+
require_revision(current, payload, "修改讨论限制")
|
|
402
|
+
session = current["session"]
|
|
403
|
+
if not session:
|
|
404
|
+
raise FileExistsError("房间没有进行中的讨论会话")
|
|
405
|
+
if session["status"] != "paused":
|
|
406
|
+
raise ValueError("请先暂停讨论,再修改上限参数")
|
|
407
|
+
values = {key: value for key, value in payload.items() if key not in ("revision", "action", "actor")}
|
|
408
|
+
if not values:
|
|
409
|
+
raise ValueError("至少提供一项讨论上限字段")
|
|
410
|
+
current_limits = {key: session["limits"][key] for key in ("max_rounds", "max_minutes", "execution_minutes")}
|
|
411
|
+
limits = limit_values(values, current_limits)
|
|
412
|
+
connection.execute("""UPDATE session_limits SET max_rounds=?,max_minutes=?,execution_minutes=?,revision=revision+1
|
|
413
|
+
WHERE session_id=?""", (limits["max_rounds"], limits["max_minutes"],
|
|
414
|
+
limits["execution_minutes"], session["id"]))
|
|
415
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
|
|
416
|
+
enforce_session_limits(connection, room_id)
|
|
417
|
+
return session_room(connection, room_id)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def session_conclusion_view(connection, room_id):
|
|
421
|
+
"""Return conclusion blockers and a revision bound to the latest request-backed session state."""
|
|
422
|
+
session = current_session(connection, room_id)
|
|
423
|
+
if not session:
|
|
424
|
+
return None
|
|
425
|
+
held_since = connection.execute("SELECT held_since FROM session_state WHERE session_id=?", (session["id"],)).fetchone()[0]
|
|
426
|
+
state_until = connection.execute("SELECT state_until FROM rooms WHERE id=?", (room_id,)).fetchone()[0]
|
|
427
|
+
last_answer = connection.execute("""SELECT COALESCE(MAX(r.answer_id),0) FROM session_requests sr
|
|
428
|
+
JOIN member_requests r ON r.id=sr.member_request_id
|
|
429
|
+
WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.status='answered'""", (session["id"],)).fetchone()[0]
|
|
430
|
+
active = [list(row) for row in connection.execute("""SELECT id,round,position,member_id,status FROM session_requests
|
|
431
|
+
WHERE session_id=? AND purpose='discuss' AND status='open' ORDER BY round,position,id""", (session["id"],))]
|
|
432
|
+
queued = [list(row) for row in connection.execute("""SELECT id,round,position,member_id,status FROM session_requests
|
|
433
|
+
WHERE session_id=? AND purpose='discuss' AND status='queued' ORDER BY round,position,id""", (session["id"],))]
|
|
434
|
+
latest = connection.execute("""SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=?
|
|
435
|
+
AND NOT EXISTS(SELECT 1 FROM internal_task_messages WHERE message_id=messages.id)""", (room_id,)).fetchone()[0]
|
|
436
|
+
blockers = []
|
|
437
|
+
if held_since:
|
|
438
|
+
blockers.append("有未处理的用户留言或设置变更")
|
|
439
|
+
if state_until < last_answer:
|
|
440
|
+
blockers.append(f"状态整理至 #{state_until},未覆盖最新发言 #{last_answer};先更新 state 与简报")
|
|
441
|
+
if active:
|
|
442
|
+
blockers.append("仍有专家正在发言")
|
|
443
|
+
if queued:
|
|
444
|
+
blockers.append("仍有排队的发言安排")
|
|
445
|
+
revision = _hash_revision([_session_revision(connection, session), latest, state_until, last_answer,
|
|
446
|
+
held_since, active, queued])
|
|
447
|
+
return {"latest_id": latest, "last_opinion_id": last_answer, "state_until": state_until,
|
|
448
|
+
"held_since": held_since, "queued": len(queued), "active": len(active),
|
|
449
|
+
"blockers": blockers, "ready": not blockers, "revision": revision}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def conclude_session_by_host(connection, room_id, payload):
|
|
453
|
+
"""Let the host finish a ready discussion after reading its conclusion revision."""
|
|
454
|
+
begin_write(connection)
|
|
455
|
+
session = current_session(connection, room_id, active_only=True)
|
|
456
|
+
if not session or session["status"] != "active":
|
|
457
|
+
raise FileExistsError("只有进行中的讨论可以由主持人收束")
|
|
458
|
+
view = session_conclusion_view(connection, room_id)
|
|
459
|
+
if payload.get("conclusion") is None:
|
|
460
|
+
raise ValueError("收束前须先读取收束状态(room conclusion),并带上其 revision")
|
|
461
|
+
if str(payload["conclusion"]) != view["revision"]:
|
|
462
|
+
raise FileExistsError("讨论状态已变化,请重新读取 room conclusion 后再收束")
|
|
463
|
+
blockers = [item for item in view["blockers"] if item != "仍有排队的发言安排"]
|
|
464
|
+
if blockers:
|
|
465
|
+
raise ValueError(f"暂不能收束:{';'.join(blockers)}")
|
|
466
|
+
return _conclude_session(connection, session, "goal_done")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _host_interjections(connection, session_id):
|
|
470
|
+
"""Read pending user interjections with their original source and execution."""
|
|
471
|
+
return [dict(row) for row in connection.execute("""SELECT r.id,r.status,r.execution_id,
|
|
472
|
+
q.id AS session_request_id,t.cause_message_id AS message_id
|
|
473
|
+
FROM session_requests q JOIN member_requests r ON r.id=q.member_request_id
|
|
474
|
+
JOIN task_message_sources t ON t.session_request_id=q.id
|
|
475
|
+
JOIN messages m ON m.id=t.cause_message_id
|
|
476
|
+
WHERE q.session_id=? AND q.purpose='discuss_host' AND q.status='open'
|
|
477
|
+
AND r.status IN ('pending','delivering') AND t.cause_kind='user_message' AND m.author='用户'
|
|
478
|
+
AND NOT EXISTS(SELECT 1 FROM message_withdrawals w WHERE w.message_id=m.id)
|
|
479
|
+
ORDER BY r.id""", (session_id,))]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _session_roster(connection, session_id):
|
|
483
|
+
"""Build active experts and their answered state for this session's current round."""
|
|
484
|
+
session = connection.execute("SELECT room_id,status FROM discussion_sessions WHERE id=?", (session_id,)).fetchone()
|
|
485
|
+
session_state = connection.execute("SELECT phase,revision,read_until,held_since FROM session_state WHERE session_id=?", (session_id,)).fetchone()
|
|
486
|
+
max_round = connection.execute("""SELECT MIN(round) FROM session_requests
|
|
487
|
+
WHERE session_id=? AND purpose='discuss' AND status IN ('queued','open')""", (session_id,)).fetchone()[0]
|
|
488
|
+
if max_round is None:
|
|
489
|
+
max_round = connection.execute("SELECT COALESCE(MAX(round),1) FROM session_requests WHERE session_id=? AND purpose='discuss'", (session_id,)).fetchone()[0]
|
|
490
|
+
answered = {row[0] for row in connection.execute("SELECT member_id FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=? AND status='answered'", (session_id, max_round))}
|
|
491
|
+
members = [dict(row) for row in connection.execute("""SELECT m.id,m.name,m.role,m.access,m.status,m.token_hash,
|
|
492
|
+
COALESCE(r.token_hash,'') AS registration_hash
|
|
493
|
+
FROM room_members m LEFT JOIN member_registrations r ON r.member_id=m.id
|
|
494
|
+
WHERE m.room_id=? AND m.status='active' ORDER BY m.id""", (session["room_id"],))]
|
|
495
|
+
experts = []
|
|
496
|
+
for member in members:
|
|
497
|
+
registered = member["access"] != "manual" or member["registration_hash"] == member["token_hash"]
|
|
498
|
+
experts.append({"id": member["id"],"name":member["name"],"host":member["role"] == "host", "mode":member["access"],
|
|
499
|
+
"spoken":member["id"] in answered, "registered":registered})
|
|
500
|
+
experts.sort(key=lambda item: (item["host"], next(member["id"] for member in members if member["name"]==item["name"])))
|
|
501
|
+
waiting = [item["name"] for item in experts if not item["host"] and not item["spoken"] and item["registered"]]
|
|
502
|
+
unregistered = [item["name"] for item in experts if not item["registered"]]
|
|
503
|
+
queue = [list(row) for row in connection.execute("""SELECT id,round,position,member_id,status,submission_id
|
|
504
|
+
FROM session_requests WHERE session_id=? AND purpose='discuss' ORDER BY id""", (session_id,))]
|
|
505
|
+
limits = list(connection.execute("SELECT max_rounds,max_minutes,execution_minutes FROM session_limits WHERE session_id=?", (session_id,)).fetchone())
|
|
506
|
+
latest_visible = connection.execute("""SELECT COALESCE(MAX(m.id),0) FROM messages m WHERE m.room_id=?
|
|
507
|
+
AND NOT EXISTS (SELECT 1 FROM internal_task_messages t WHERE t.message_id=m.id)""", (session["room_id"],)).fetchone()[0]
|
|
508
|
+
host_requests = _host_interjections(connection, session_id)
|
|
509
|
+
state = [session_id, session["status"], list(session_state), latest_visible, max_round, host_requests,
|
|
510
|
+
[[item['id'], item['name'], item['host'], item['spoken'], item['registered']] for item in experts], queue, limits]
|
|
511
|
+
return {"experts":experts,"waiting":waiting,"unregistered":unregistered,"round":max_round,
|
|
512
|
+
"revision":_hash_revision(state), "host_requests":host_requests,
|
|
513
|
+
"status":session["status"], "held_since":session_state["held_since"], "queue":queue}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def roster_view(connection, room_id):
|
|
517
|
+
"""Expose the current session roster and revision required by a multi-speaker plan."""
|
|
518
|
+
session = current_session(connection, room_id)
|
|
519
|
+
if not session:
|
|
520
|
+
raise FileExistsError("房间没有进行中的讨论会话")
|
|
521
|
+
return {**_session_roster(connection, session["id"]),"session_id":session["id"]}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def query_session_requests(connection, room_id, params=None):
|
|
525
|
+
"""Read request plans for the current session with fields compatible with the legacy turns view."""
|
|
526
|
+
session = current_session(connection, room_id)
|
|
527
|
+
if not session:
|
|
528
|
+
return {"turns":[],"page":1,"pages":1,"total":0}
|
|
529
|
+
params = params or {}
|
|
530
|
+
page = max(1,int(params.get("page",1)))
|
|
531
|
+
where = "session_id=? AND purpose='discuss'"
|
|
532
|
+
values = [session["id"]]
|
|
533
|
+
if "submission_id" in params:
|
|
534
|
+
where += " AND submission_id=?"
|
|
535
|
+
values.append(str(params["submission_id"]))
|
|
536
|
+
total = connection.execute(f"SELECT COUNT(*) FROM session_requests WHERE {where}",values).fetchone()[0]
|
|
537
|
+
rows = connection.execute(f"SELECT * FROM session_requests WHERE {where} ORDER BY round,position,id LIMIT 20 OFFSET ?",[*values,(page-1)*20]).fetchall()
|
|
538
|
+
members = {row["id"]:row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?",(room_id,))}
|
|
539
|
+
turns = []
|
|
540
|
+
for row in rows:
|
|
541
|
+
item = dict(row)
|
|
542
|
+
item.update(expert=members.get(item["member_id"],""),status_label=item["status"])
|
|
543
|
+
turns.append(item)
|
|
544
|
+
return {"turns":turns,"page":page,"pages":max(1,(total+19)//20),"total":total}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def get_session_request(connection, room_id, plan_id):
|
|
548
|
+
"""Read one session plan by its durable identifier, including its member name."""
|
|
549
|
+
row = connection.execute("""SELECT sr.*,m.name AS expert,s.room_id FROM session_requests sr
|
|
550
|
+
JOIN discussion_sessions s ON s.id=sr.session_id JOIN room_members m ON m.id=sr.member_id
|
|
551
|
+
WHERE s.room_id=? AND sr.id=? AND sr.purpose='discuss'""", (room_id, int(plan_id))).fetchone()
|
|
552
|
+
if not row:
|
|
553
|
+
raise LookupError("讨论安排不存在")
|
|
554
|
+
return dict(row)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def create_plan(connection, room_id, payload, room):
|
|
558
|
+
"""Store one speaker or an ordered round, opening only its first deliverable request."""
|
|
559
|
+
begin_write(connection)
|
|
560
|
+
session = current_session(connection, room_id, active_only=True)
|
|
561
|
+
if not session:
|
|
562
|
+
raise FileExistsError("房间没有进行中的讨论会话")
|
|
563
|
+
if room["archived"] or session["status"] != "active":
|
|
564
|
+
raise ValueError("讨论已暂停或房间已存档")
|
|
565
|
+
actor = payload.get("actor")
|
|
566
|
+
host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND name=? AND role='host' AND status='active'",(room_id,actor)).fetchone()
|
|
567
|
+
if not host:
|
|
568
|
+
raise ValueError("请使用当前 AI 主持人身份安排发言")
|
|
569
|
+
if payload.get("roster") is None:
|
|
570
|
+
raise ValueError("安排发言前须先获取 turn roster 并带上 revision")
|
|
571
|
+
base_id, plan = _validated_plan(payload)
|
|
572
|
+
existing = _existing_plan(connection, session["id"], base_id)
|
|
573
|
+
if existing:
|
|
574
|
+
return _check_duplicate_plan(connection, existing, plan)
|
|
575
|
+
roster = _session_roster(connection, session["id"])
|
|
576
|
+
if payload["roster"] != roster["revision"]:
|
|
577
|
+
raise FileExistsError("专家状态已变化,请重新获取 turn roster 后再安排")
|
|
578
|
+
_validate_plan_members(connection, room_id, plan)
|
|
579
|
+
entries = _number_plan_entries(connection, session["id"], plan, roster)
|
|
580
|
+
if len(plan) > 1:
|
|
581
|
+
first_round_names = {item[1] for item in entries if item[3] == roster["round"]}
|
|
582
|
+
missing = [name for name in roster["waiting"] if name not in first_round_names]
|
|
583
|
+
if missing:
|
|
584
|
+
raise ValueError(f"整轮安排缺少本轮尚未发言的专家:{'、'.join(missing)}")
|
|
585
|
+
read_until = _discussion_read_cutoff(connection, session["id"])
|
|
586
|
+
inserted = _insert_plan_rows(connection, session["id"], entries, read_until)
|
|
587
|
+
# Planning is the host's completed response; close its current host request in this transaction.
|
|
588
|
+
_complete_host_work(connection,session['id'],host['id'],"主持人已提交发言安排")
|
|
589
|
+
advance_session(connection,session['id'])
|
|
590
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session['id'],))
|
|
591
|
+
return {"turn":dict(connection.execute("SELECT * FROM session_requests WHERE id=?",(inserted[0],)).fetchone()),
|
|
592
|
+
"queued":[dict(connection.execute("SELECT * FROM session_requests WHERE id=?",(item,)).fetchone()) for item in inserted[1:]],"duplicate":False}
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _validated_plan(payload):
|
|
596
|
+
"""Validate the host's one-speaker or ordered multi-speaker plan payload."""
|
|
597
|
+
items = payload.get("turns")
|
|
598
|
+
if items is None:
|
|
599
|
+
items = [{"expert": payload.get("expert", ""), "question": payload.get("question", "")}]
|
|
600
|
+
if not isinstance(items, list) or not 1 <= len(items) <= MAX_PLAN_TURNS:
|
|
601
|
+
raise ValueError(f"整轮安排需要 1–{MAX_PLAN_TURNS} 项")
|
|
602
|
+
base_id = str(payload.get("submission_id", "")).strip()
|
|
603
|
+
if not 8 <= len(base_id) <= 100:
|
|
604
|
+
raise ValueError("submission_id 须为 8–100 字符")
|
|
605
|
+
plan = []
|
|
606
|
+
for index, item in enumerate(items):
|
|
607
|
+
if not isinstance(item, dict):
|
|
608
|
+
raise ValueError("每项安排需要 expert 和 question")
|
|
609
|
+
name = str(item.get("expert", "")).strip()
|
|
610
|
+
question = str(item.get("question", "")).strip()
|
|
611
|
+
if not name or not question or len(question) > MAX_QUESTION_LENGTH:
|
|
612
|
+
raise ValueError("请选择专家并提供不超过 1000 字的问题")
|
|
613
|
+
submission_id = base_id if index == 0 else f"{base_id}-q{index + 1}"
|
|
614
|
+
plan.append((name, question, submission_id))
|
|
615
|
+
return base_id, plan
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _validate_plan_members(connection, room_id, plan):
|
|
619
|
+
"""Require each scheduled participant to be active and any manual member registered."""
|
|
620
|
+
for name, _, _ in plan:
|
|
621
|
+
member = connection.execute("""SELECT * FROM room_members m
|
|
622
|
+
WHERE m.room_id=? AND m.name=? AND m.status='active'""",
|
|
623
|
+
(room_id, name)).fetchone()
|
|
624
|
+
if not member:
|
|
625
|
+
raise ValueError(f"安排对象 {name} 不是在场专家")
|
|
626
|
+
if member["access"] == "manual" and not connection.execute(
|
|
627
|
+
"SELECT 1 FROM member_registrations WHERE member_id=? AND token_hash=?",
|
|
628
|
+
(member["id"], member["token_hash"])).fetchone():
|
|
629
|
+
raise ValueError(f"手动专家 {name} 尚未注册")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _discussion_read_cutoff(connection, session_id):
|
|
633
|
+
"""Freeze the current boundary for independent opinions; NULL allows later discussion reads."""
|
|
634
|
+
state = connection.execute("SELECT phase,read_until FROM session_state WHERE session_id=?", (session_id,)).fetchone()
|
|
635
|
+
return state["read_until"] if state["phase"] == "initial" else None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _number_plan_entries(connection, session_id, plan, roster):
|
|
639
|
+
"""Split a plan into rounds when a member repeats in the current queue."""
|
|
640
|
+
round_number = roster["round"]
|
|
641
|
+
answered = {item["id"] for item in roster["experts"] if item["spoken"]}
|
|
642
|
+
if not connection.execute("SELECT 1 FROM session_requests WHERE session_id=? AND purpose='discuss'", (session_id,)).fetchone():
|
|
643
|
+
round_number = 1
|
|
644
|
+
answered = set()
|
|
645
|
+
positions = {row[0]: row[1] for row in connection.execute("""SELECT round,MAX(position)
|
|
646
|
+
FROM session_requests WHERE session_id=? AND purpose='discuss' GROUP BY round""", (session_id,))}
|
|
647
|
+
members = {row["name"]: row["id"] for row in connection.execute("""SELECT id,name FROM room_members
|
|
648
|
+
WHERE room_id=(SELECT room_id FROM discussion_sessions WHERE id=?)""", (session_id,))}
|
|
649
|
+
entries = []
|
|
650
|
+
for name, question, submission_id in plan:
|
|
651
|
+
member_id = members[name]
|
|
652
|
+
if member_id in answered:
|
|
653
|
+
round_number += 1
|
|
654
|
+
answered = set()
|
|
655
|
+
positions[round_number] = positions.get(round_number, 0) + 1
|
|
656
|
+
entries.append((member_id, name, question, round_number, positions[round_number], submission_id))
|
|
657
|
+
answered.add(member_id)
|
|
658
|
+
return entries
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _insert_plan_rows(connection, session_id, entries, read_until):
|
|
662
|
+
"""Persist all plan entries atomically in queue order."""
|
|
663
|
+
inserted = []
|
|
664
|
+
for member_id, _, question, round_number, position, submission_id in entries:
|
|
665
|
+
entry_cutoff = read_until if round_number == 1 else None
|
|
666
|
+
cursor = connection.execute("""INSERT INTO session_requests
|
|
667
|
+
(session_id,round,position,member_id,purpose,question,status,submission_id,read_until,created_at)
|
|
668
|
+
VALUES(?,?,?,?,'discuss',?,'queued',?,?,?)""",
|
|
669
|
+
(session_id, round_number, position, member_id, question, submission_id, entry_cutoff,
|
|
670
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
|
|
671
|
+
inserted.append(cursor.lastrowid)
|
|
672
|
+
return inserted
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _existing_plan(connection, session_id, base_id):
|
|
676
|
+
"""Find all stored entries for one base submission, including queued suffixes."""
|
|
677
|
+
pattern = f"{base_id}-q[0-9]*"
|
|
678
|
+
return [dict(row) for row in connection.execute("""SELECT * FROM session_requests
|
|
679
|
+
WHERE session_id=? AND (submission_id=? OR submission_id GLOB ?)
|
|
680
|
+
ORDER BY id""", (session_id, base_id, pattern))]
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _check_duplicate_plan(connection, existing, plan):
|
|
684
|
+
"""Return an exact replay and reject any reuse of the base id with changed entries."""
|
|
685
|
+
expected_ids = [item[2] for item in plan]
|
|
686
|
+
actual_ids = [item["submission_id"] for item in existing]
|
|
687
|
+
room_id = connection.execute("SELECT room_id FROM discussion_sessions WHERE id=?", (existing[0]["session_id"],)).fetchone()[0]
|
|
688
|
+
members = {row["name"]: row["id"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
689
|
+
exact = actual_ids == expected_ids
|
|
690
|
+
for row, (name, question, _) in zip(existing, plan):
|
|
691
|
+
exact = exact and row["member_id"] == members.get(name)
|
|
692
|
+
exact = exact and row["question"] == question
|
|
693
|
+
if not exact:
|
|
694
|
+
raise FileExistsError("这个 submission_id 已用于不同的安排内容;请核对原提交后再使用新编号")
|
|
695
|
+
return {"turn": existing[0], "queued": existing[1:], "duplicate": True}
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _complete_host_work(connection, session_id, host_id, result):
|
|
699
|
+
"""Treat host planning as completion of the host's assigned request and execution."""
|
|
700
|
+
rows = connection.execute("""SELECT DISTINCT r.id AS request_id,r.execution_id,r.status AS request_status,e.status AS execution_status
|
|
701
|
+
FROM session_requests sr JOIN member_requests r ON r.id=sr.member_request_id
|
|
702
|
+
LEFT JOIN member_executions e ON e.id=r.execution_id WHERE sr.session_id=? AND sr.purpose='discuss_host' AND sr.member_id=?
|
|
703
|
+
AND sr.status='open' AND r.status IN ('pending','delivering')""", (session_id, host_id)).fetchall()
|
|
704
|
+
executions = {row["execution_id"] for row in rows
|
|
705
|
+
if row["request_status"] == "delivering" and row["execution_status"] in ("dispatched", "started")}
|
|
706
|
+
selected = ([row for row in rows if row["execution_id"] in executions]
|
|
707
|
+
if executions else [row for row in rows if row["request_status"] == "pending"])
|
|
708
|
+
request_ids = [row["request_id"] for row in selected]
|
|
709
|
+
for execution_id in executions:
|
|
710
|
+
finish_execution(connection, execution_id, "completed", result, "completed")
|
|
711
|
+
if request_ids:
|
|
712
|
+
placeholders = ",".join("?" for _ in request_ids)
|
|
713
|
+
connection.execute(f"UPDATE member_requests SET status='completed' WHERE id IN ({placeholders}) AND status='pending'",
|
|
714
|
+
request_ids)
|
|
715
|
+
connection.execute(f"UPDATE session_requests SET status='answered' WHERE member_request_id IN ({placeholders})",
|
|
716
|
+
request_ids)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def complete_session_requests(connection, room_id, payload, actor, message_id):
|
|
720
|
+
"""Mark session requests covered by one free-room answer and continue the discussion plan."""
|
|
721
|
+
if actor == "用户" or actor is None:
|
|
722
|
+
return
|
|
723
|
+
request_ids = payload.get("request_ids", [])
|
|
724
|
+
if type(payload.get("execution_id")) is int:
|
|
725
|
+
rows = connection.execute("SELECT id FROM member_requests WHERE execution_id=?", (payload["execution_id"],)).fetchall()
|
|
726
|
+
request_ids = [row["id"] for row in rows]
|
|
727
|
+
if not request_ids:
|
|
728
|
+
return
|
|
729
|
+
placeholders = ",".join("?" for _ in request_ids)
|
|
730
|
+
rows = connection.execute(f"""SELECT sr.id,sr.session_id,sr.purpose,sr.member_id,sr.member_request_id,m.name
|
|
731
|
+
FROM member_requests r JOIN session_requests sr ON sr.member_request_id=r.id
|
|
732
|
+
JOIN room_members m ON m.id=sr.member_id WHERE r.id IN ({placeholders}) AND sr.status='open'""",
|
|
733
|
+
request_ids).fetchall()
|
|
734
|
+
for row in rows:
|
|
735
|
+
if row["purpose"] == "discuss":
|
|
736
|
+
connection.execute("UPDATE session_requests SET status='answered' WHERE id=?", (row["id"],))
|
|
737
|
+
_advance_after_answer(connection, dict(row), message_id)
|
|
738
|
+
else:
|
|
739
|
+
connection.execute("UPDATE session_requests SET status='answered' WHERE id=?", (row["id"],))
|
|
740
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (row["session_id"],))
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _advance_after_answer(connection, request, answer_id):
|
|
744
|
+
"""Promote the next queued speaker or ask the host to choose what comes next."""
|
|
745
|
+
session_id = request["session_id"]
|
|
746
|
+
session_row = connection.execute("SELECT * FROM discussion_sessions WHERE id=?", (session_id,)).fetchone()
|
|
747
|
+
roster = _session_roster(connection, session_id)
|
|
748
|
+
_advance_initial_phase(connection, session_id)
|
|
749
|
+
if enforce_session_limits(connection, session_row["room_id"]):
|
|
750
|
+
return
|
|
751
|
+
if advance_session(connection, session_id):
|
|
752
|
+
return
|
|
753
|
+
if connection.execute("SELECT 1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND status='queued' LIMIT 1",
|
|
754
|
+
(session_id,)).fetchone():
|
|
755
|
+
return
|
|
756
|
+
cause_kind = "round_complete" if not roster["waiting"] else "speaker_answered"
|
|
757
|
+
question = (f"专家 {request['name']} 已回答 #{answer_id},请继续安排本轮尚未发言的专家。"
|
|
758
|
+
if roster["waiting"] else f"第 {roster['round']} 轮已完成,请判断是否开始下一轮或收束讨论。")
|
|
759
|
+
create_host_request(connection, session_id, question,
|
|
760
|
+
{"kind": cause_kind, "message_id": answer_id, "request_id": request["member_request_id"],
|
|
761
|
+
"key": f"answer-{request['member_request_id']}"})
|
|
762
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session_id,))
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _advance_initial_phase(connection, session_id):
|
|
766
|
+
"""Switch out of independent opinions after all scheduled first-round experts answer."""
|
|
767
|
+
state = connection.execute("SELECT phase FROM session_state WHERE session_id=?", (session_id,)).fetchone()
|
|
768
|
+
if not state or state["phase"] != "initial":
|
|
769
|
+
return
|
|
770
|
+
denominator = _expert_denominator(connection, session_id, 1)
|
|
771
|
+
answered = connection.execute("SELECT COUNT(*) FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=1 AND status='answered'", (session_id,)).fetchone()[0]
|
|
772
|
+
if denominator and answered >= denominator:
|
|
773
|
+
connection.execute("UPDATE session_state SET phase='discussing',revision=revision+1 WHERE session_id=?", (session_id,))
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _expert_denominator(connection, session_id, round_number=None):
|
|
777
|
+
"""Count registered active experts, plus the host only if that round explicitly schedules them."""
|
|
778
|
+
room_id = connection.execute("SELECT room_id FROM discussion_sessions WHERE id=?", (session_id,)).fetchone()[0]
|
|
779
|
+
count = connection.execute("""SELECT COUNT(*) FROM room_members m LEFT JOIN member_registrations r ON r.member_id=m.id
|
|
780
|
+
WHERE m.room_id=? AND m.status='active' AND m.role<>'host'
|
|
781
|
+
AND (m.access<>'manual' OR r.token_hash=m.token_hash)""", (room_id,)).fetchone()[0]
|
|
782
|
+
host_sql = "SELECT 1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND member_id IN (SELECT id FROM room_members WHERE room_id=? AND role='host')"
|
|
783
|
+
values = [session_id, room_id]
|
|
784
|
+
if round_number is not None:
|
|
785
|
+
host_sql += " AND round=?"
|
|
786
|
+
values.append(round_number)
|
|
787
|
+
if connection.execute(host_sql, values).fetchone():
|
|
788
|
+
count += 1
|
|
789
|
+
return count
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _at_round_limit(connection, session_id):
|
|
793
|
+
"""Check answered-opinion rounds and active-time limits without waiting for full roster coverage."""
|
|
794
|
+
limits = connection.execute("SELECT max_rounds,max_minutes,started_at FROM session_limits WHERE session_id=?", (session_id,)).fetchone()
|
|
795
|
+
pause = connection.execute("SELECT paused_seconds FROM session_pauses WHERE session_id=?", (session_id,)).fetchone()[0]
|
|
796
|
+
denominator = _expert_denominator(connection, session_id)
|
|
797
|
+
answered = connection.execute("SELECT COUNT(*) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status='answered'", (session_id,)).fetchone()[0]
|
|
798
|
+
rounds_done = denominator and limits["max_rounds"] > 0 and answered // denominator >= limits["max_rounds"]
|
|
799
|
+
elapsed = max(0, int(time.time()) - limits["started_at"] - pause)
|
|
800
|
+
time_done = limits["max_minutes"] > 0 and elapsed >= limits["max_minutes"] * 60
|
|
801
|
+
return rounds_done or time_done
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def fail_session_request(connection, request_id, reason):
|
|
805
|
+
"""Fail one discussion speaker request and hand its recovery decision to the host."""
|
|
806
|
+
row = connection.execute("""SELECT sr.id,sr.session_id,sr.member_id,m.name,s.room_id
|
|
807
|
+
FROM session_requests sr JOIN discussion_sessions s ON s.id=sr.session_id
|
|
808
|
+
JOIN room_members m ON m.id=sr.member_id WHERE sr.member_request_id=? AND sr.purpose='discuss'""",
|
|
809
|
+
(request_id,)).fetchone()
|
|
810
|
+
if not row:
|
|
811
|
+
return
|
|
812
|
+
connection.execute("UPDATE session_requests SET status='failed' WHERE id=? AND status='open'", (row["id"],))
|
|
813
|
+
create_host_request(connection, row["session_id"],
|
|
814
|
+
f"专家 {row['name']} 的讨论请求失败:{reason}。请决定跳过、重排或暂停。",
|
|
815
|
+
{"kind": "request_failed", "request_id": request_id, "key": f"failed-{request_id}"})
|
|
816
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (row["session_id"],))
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def session_message_access(connection, room_id, params, token=""):
|
|
820
|
+
"""Resolve a member's read boundary while validating any request or execution binding."""
|
|
821
|
+
execution_id = _optional_identifier(params, "execution_id")
|
|
822
|
+
request_id = _optional_identifier(params, "request_id")
|
|
823
|
+
if execution_id and request_id:
|
|
824
|
+
raise ValueError("execution_id 与 request_id 不能同时使用")
|
|
825
|
+
member = None
|
|
826
|
+
if execution_id or request_id or token:
|
|
827
|
+
try:
|
|
828
|
+
member = require_member(connection, room_id, token) if token else None
|
|
829
|
+
except PermissionError:
|
|
830
|
+
if execution_id or request_id:
|
|
831
|
+
raise
|
|
832
|
+
if (execution_id or request_id) and not member:
|
|
833
|
+
raise PermissionError("读取执行上下文须使用该成员的凭证")
|
|
834
|
+
rows = []
|
|
835
|
+
if execution_id:
|
|
836
|
+
execution = connection.execute("SELECT member_id,status,lease_until FROM member_executions WHERE room_id=? AND id=?",
|
|
837
|
+
(room_id, execution_id)).fetchone()
|
|
838
|
+
if not execution or execution["member_id"] != member["id"]:
|
|
839
|
+
raise PermissionError("执行编号不属于该成员")
|
|
840
|
+
if execution["status"] not in ("dispatched", "started") or execution["lease_until"] <= int(time.time()):
|
|
841
|
+
raise FileExistsError("执行已结束或过期,不能继续读取其上下文")
|
|
842
|
+
rows = connection.execute("""SELECT sr.* FROM member_requests r JOIN session_requests sr
|
|
843
|
+
ON sr.member_request_id=r.id WHERE r.execution_id=?""", (execution_id,)).fetchall()
|
|
844
|
+
_require_live_session_rows(connection, rows, execution["status"], "delivering")
|
|
845
|
+
elif request_id:
|
|
846
|
+
request = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
|
|
847
|
+
if not request or request["member_id"] != member["id"]:
|
|
848
|
+
raise PermissionError("请求编号不属于该成员")
|
|
849
|
+
if request["status"] not in ("pending", "delivering"):
|
|
850
|
+
raise FileExistsError("请求已结束,不能继续读取其上下文")
|
|
851
|
+
if request["status"] == "delivering":
|
|
852
|
+
execution = connection.execute("SELECT status,lease_until FROM member_executions WHERE id=? AND member_id=?",
|
|
853
|
+
(request["execution_id"], member["id"])).fetchone() if request["execution_id"] else None
|
|
854
|
+
if not execution or execution["status"] not in ("dispatched", "started") or execution["lease_until"] <= int(time.time()):
|
|
855
|
+
raise FileExistsError("请求对应的执行已结束或过期,不能继续读取其上下文")
|
|
856
|
+
rows = connection.execute("SELECT * FROM session_requests WHERE member_request_id=?", (request_id,)).fetchall()
|
|
857
|
+
_require_live_session_rows(connection, rows, "", request["status"])
|
|
858
|
+
cutoffs = [row["read_until"] for row in rows
|
|
859
|
+
if row["purpose"] == "discuss" and row["read_until"] is not None]
|
|
860
|
+
cutoff = min(cutoffs) if cutoffs else None
|
|
861
|
+
if not rows and member and not execution_id and not request_id:
|
|
862
|
+
current = connection.execute("""SELECT sr.read_until FROM member_requests r
|
|
863
|
+
JOIN session_requests sr ON sr.member_request_id=r.id
|
|
864
|
+
JOIN discussion_sessions s ON s.id=sr.session_id
|
|
865
|
+
WHERE s.room_id=? AND s.status='active' AND sr.status='open' AND sr.purpose='discuss'
|
|
866
|
+
AND r.member_id=? AND r.status IN ('pending','delivering') ORDER BY sr.id LIMIT 1""",
|
|
867
|
+
(room_id, member["id"])).fetchone()
|
|
868
|
+
if current:
|
|
869
|
+
cutoff = current["read_until"]
|
|
870
|
+
if not execution_id and not request_id:
|
|
871
|
+
session = current_session(connection, room_id, active_only=True)
|
|
872
|
+
if session and connection.execute("SELECT phase FROM session_state WHERE session_id=?", (session["id"],)).fetchone()[0] == "initial":
|
|
873
|
+
owner_hash = connection.execute("SELECT control_hash FROM room_modes WHERE room_id=?", (room_id,)).fetchone()
|
|
874
|
+
is_owner = bool(token and owner_hash and token_hash(token) == owner_hash[0])
|
|
875
|
+
is_host = bool(member and member["role"] == "host")
|
|
876
|
+
if not is_owner and not is_host and cutoff is None:
|
|
877
|
+
cutoff = connection.execute("SELECT read_until FROM session_state WHERE session_id=?", (session["id"],)).fetchone()[0]
|
|
878
|
+
return cutoff
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _require_live_session_rows(connection, rows, execution_status, request_status):
|
|
882
|
+
"""Permit session context only while its request and session are still open."""
|
|
883
|
+
if not rows:
|
|
884
|
+
return
|
|
885
|
+
if execution_status and execution_status not in ("dispatched", "started"):
|
|
886
|
+
raise FileExistsError("讨论执行已结束,不能继续读取其上下文")
|
|
887
|
+
if request_status not in ("pending", "delivering"):
|
|
888
|
+
raise FileExistsError("讨论请求已失效,不能继续读取其上下文")
|
|
889
|
+
for row in rows:
|
|
890
|
+
session = connection.execute("SELECT status FROM discussion_sessions WHERE id=?", (row["session_id"],)).fetchone()
|
|
891
|
+
if not session or session["status"] != "active" or row["status"] != "open":
|
|
892
|
+
raise FileExistsError("讨论请求已暂停、结束或取消,不能继续读取其上下文")
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _optional_identifier(params, field):
|
|
896
|
+
"""Parse an optional positive identifier from request query parameters."""
|
|
897
|
+
value = params.get(field)
|
|
898
|
+
if value is None or value == "":
|
|
899
|
+
return None
|
|
900
|
+
try:
|
|
901
|
+
identifier = int(value)
|
|
902
|
+
except (TypeError, ValueError) as error:
|
|
903
|
+
raise ValueError(f"{field} 须为正整数") from error
|
|
904
|
+
if identifier <= 0:
|
|
905
|
+
raise ValueError(f"{field} 须为正整数")
|
|
906
|
+
return identifier
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _ask_receipt(connection, room_id, payload):
|
|
910
|
+
"""Return a durable matching conversion, rejecting reuse for another request or member."""
|
|
911
|
+
receipt = connection.execute("""SELECT s.source_request_id,r.*,m.name AS expert,e.status AS source_execution_status
|
|
912
|
+
FROM session_ask_submissions s JOIN member_requests r ON r.id=s.request_id
|
|
913
|
+
JOIN room_members m ON m.id=r.member_id JOIN member_requests original ON original.id=s.source_request_id
|
|
914
|
+
LEFT JOIN member_executions e ON e.id=original.execution_id WHERE s.room_id=? AND s.submission_id=?""",
|
|
915
|
+
(room_id, payload["submission_id"])).fetchone()
|
|
916
|
+
if not receipt:
|
|
917
|
+
return None
|
|
918
|
+
if receipt["source_request_id"] != payload.get("request_id") or receipt["expert"] != payload.get("expert"):
|
|
919
|
+
raise FileExistsError("提交编号已用于其他追问")
|
|
920
|
+
request = dict(receipt)
|
|
921
|
+
completed = request.pop("source_execution_status") == "completed"
|
|
922
|
+
return {"request": request, "duplicate": True, "execution_completed": completed}
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _finish_interjection(connection, session_id, source):
|
|
926
|
+
"""Close only this source request and finish its execution only when no work remains."""
|
|
927
|
+
connection.execute("UPDATE member_requests SET status='completed' WHERE id=?", (source["id"],))
|
|
928
|
+
connection.execute("UPDATE session_requests SET status='answered' WHERE id=?", (source["session_request_id"],))
|
|
929
|
+
execution_id = source["execution_id"]
|
|
930
|
+
completed = bool(execution_id) and not connection.execute(
|
|
931
|
+
"SELECT 1 FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,)).fetchone()
|
|
932
|
+
if completed:
|
|
933
|
+
connection.execute("""UPDATE member_executions SET status='completed',finished_at=?,result='interjection_asked'
|
|
934
|
+
WHERE id=? AND status IN ('dispatched','started')""", (int(time.time()), execution_id))
|
|
935
|
+
remaining = _host_interjections(connection, session_id)
|
|
936
|
+
held_since = min((item["message_id"] for item in remaining), default=0)
|
|
937
|
+
connection.execute("UPDATE session_state SET held_since=?,revision=revision+1 WHERE session_id=?", (held_since, session_id))
|
|
938
|
+
return completed
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def ask_interjection(connection, room_id, payload, room):
|
|
942
|
+
"""Convert one versioned host interjection into a normal ask without changing discussion turns."""
|
|
943
|
+
begin_write(connection)
|
|
944
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{8,100}", str(payload.get("submission_id", ""))):
|
|
945
|
+
raise ValueError("submission_id 须为 8–100 位字母、数字、下划线或短横线")
|
|
946
|
+
if payload.get("revision") is None:
|
|
947
|
+
raise ValueError("转交追问前须先读取 turn roster 并提交 revision")
|
|
948
|
+
duplicate = _ask_receipt(connection, room_id, payload)
|
|
949
|
+
if duplicate:
|
|
950
|
+
return duplicate
|
|
951
|
+
session = current_session(connection, room_id, active_only=True)
|
|
952
|
+
if not session or session["status"] != "active" or room["archived"]:
|
|
953
|
+
raise FileExistsError("讨论未处于进行状态")
|
|
954
|
+
roster = _session_roster(connection, session["id"])
|
|
955
|
+
require_revision(roster, payload, "转交追问")
|
|
956
|
+
source = next((item for item in roster["host_requests"] if item["id"] == payload.get("request_id")), None)
|
|
957
|
+
if not source:
|
|
958
|
+
raise FileExistsError("原用户留言请求已不可转交")
|
|
959
|
+
member = next((item for item in roster["experts"] if item["name"] == payload.get("expert") and item["registered"]), None)
|
|
960
|
+
if not member:
|
|
961
|
+
raise ValueError("请选择在场且已注册的成员")
|
|
962
|
+
connection.execute("""INSERT OR IGNORE INTO member_requests
|
|
963
|
+
(room_id,member_id,message_id,chain_id,purpose,created_by,created_at) VALUES(?,?,?,?,'ask','user',?)""",
|
|
964
|
+
(room_id, member["id"], source["message_id"], source["message_id"], int(time.time())))
|
|
965
|
+
request = dict(connection.execute("SELECT * FROM member_requests WHERE member_id=? AND message_id=?",
|
|
966
|
+
(member["id"], source["message_id"])).fetchone())
|
|
967
|
+
connection.execute("INSERT INTO session_ask_submissions VALUES(?,?,?,?)",
|
|
968
|
+
(room_id, payload["submission_id"], source["id"], request["id"]))
|
|
969
|
+
completed = _finish_interjection(connection, session["id"], source)
|
|
970
|
+
return {"request": request, "duplicate": False, "execution_completed": completed}
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def continue_plan(connection, room_id, payload, room):
|
|
974
|
+
"""Resume the next queued speaker after the host has reviewed the current queue."""
|
|
975
|
+
begin_write(connection)
|
|
976
|
+
session=current_session(connection,room_id,active_only=True)
|
|
977
|
+
if not session:
|
|
978
|
+
raise FileExistsError("房间没有进行中的讨论会话")
|
|
979
|
+
roster=_session_roster(connection,session['id'])
|
|
980
|
+
if payload.get("revision") is None:
|
|
981
|
+
raise ValueError("继续安排前须读取最新 turn roster 并带上 revision")
|
|
982
|
+
if payload['revision']!=roster['revision']:
|
|
983
|
+
raise FileExistsError("讨论队列已变化,请重新读取 turn roster")
|
|
984
|
+
host=connection.execute("SELECT id FROM room_members WHERE room_id=? AND name=? AND role='host' AND status='active'",(room_id,payload.get('actor'))).fetchone()
|
|
985
|
+
if not host:
|
|
986
|
+
raise ValueError("请使用当前 AI 主持人身份继续")
|
|
987
|
+
_complete_host_work(connection,session['id'],host['id'],"主持人已继续发言队列")
|
|
988
|
+
return advance_session(connection,session['id'])
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def cancel_plan(connection, room_id, plan_id, payload):
|
|
992
|
+
"""Cancel one known session plan and its linked member request/execution."""
|
|
993
|
+
begin_write(connection)
|
|
994
|
+
row=connection.execute("SELECT sr.* FROM session_requests sr JOIN discussion_sessions s ON s.id=sr.session_id WHERE s.room_id=? AND sr.id=?",(room_id,int(plan_id))).fetchone()
|
|
995
|
+
if not row:
|
|
996
|
+
raise LookupError("讨论安排不存在")
|
|
997
|
+
if row['status'] not in ('queued','open'):
|
|
998
|
+
raise FileExistsError("该讨论安排已结束,不能重复取消")
|
|
999
|
+
if row['member_request_id']:
|
|
1000
|
+
request=connection.execute("SELECT execution_id,status FROM member_requests WHERE id=?",(row['member_request_id'],)).fetchone()
|
|
1001
|
+
if request and request['status'] in ('pending','delivering'):
|
|
1002
|
+
if request['execution_id']:
|
|
1003
|
+
finish_execution(connection,request['execution_id'],'cancelled','用户取消讨论安排','cancelled','user_stop')
|
|
1004
|
+
else:
|
|
1005
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?",(row['member_request_id'],))
|
|
1006
|
+
connection.execute("UPDATE session_requests SET status='cancelled' WHERE id=?",(plan_id,))
|
|
1007
|
+
session_id=row['session_id']
|
|
1008
|
+
create_host_request(connection, session_id, f"主持人:发言安排 #{plan_id} 已取消,请决定是否重排。",
|
|
1009
|
+
{"kind": "member_change", "request_id": row["member_request_id"], "key": f"cancel-{plan_id}"})
|
|
1010
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?",(session_id,))
|
|
1011
|
+
return dict(connection.execute("SELECT * FROM session_requests WHERE id=?",(plan_id,)).fetchone())
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def update_session_state(connection, session_id, phase=None, held_since=None):
|
|
1015
|
+
"""Persist session phase or held message boundary and advance its revision."""
|
|
1016
|
+
fields=[]; values=[]
|
|
1017
|
+
if phase is not None:
|
|
1018
|
+
if phase not in ('initial','discussing'):
|
|
1019
|
+
raise ValueError("会话阶段须为 initial 或 discussing")
|
|
1020
|
+
fields.append("phase=?"); values.append(phase)
|
|
1021
|
+
if held_since is not None:
|
|
1022
|
+
fields.append("held_since=?"); values.append(held_since)
|
|
1023
|
+
fields.append("revision=revision+1"); values.append(session_id)
|
|
1024
|
+
connection.execute(f"UPDATE session_state SET {','.join(fields)} WHERE session_id=?",values)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
# Hooks the free room calls at its own decision points; each leaves the free room alone when no discussion is open.
|
|
1028
|
+
|
|
1029
|
+
def hold_user_message(connection, room_id, message_id, payload):
|
|
1030
|
+
"""Hand an unnamed user message to the running discussion's host; True when the discussion took it."""
|
|
1031
|
+
session = current_session(connection, room_id, active_only=True)
|
|
1032
|
+
if not session or session["status"] != "active" or payload.get("to") is not None:
|
|
1033
|
+
return False
|
|
1034
|
+
create_host_request(connection, session["id"],
|
|
1035
|
+
f"用户留言 #{message_id}:请处理并决定是否安排专家。",
|
|
1036
|
+
{"kind": "user_message", "message_id": message_id, "key": f"message-{message_id}"})
|
|
1037
|
+
connection.execute("UPDATE session_state SET held_since=?,revision=revision+1 WHERE session_id=?",
|
|
1038
|
+
(message_id, session["id"]))
|
|
1039
|
+
return True
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def validate_session_request_answer(connection, room_id, requests):
|
|
1043
|
+
"""Keep a discussion speaker request separate and live when answering it."""
|
|
1044
|
+
discussions = [request for request in requests if request["purpose"] == "discuss"]
|
|
1045
|
+
if not discussions:
|
|
1046
|
+
return
|
|
1047
|
+
if len(requests) != 1 or len(discussions) != 1:
|
|
1048
|
+
raise ValueError("讨论安排须单独回答,不能与普通请求合并")
|
|
1049
|
+
request_id = discussions[0]["id"]
|
|
1050
|
+
plan = connection.execute("""SELECT sr.status,s.status AS session_status FROM session_requests sr
|
|
1051
|
+
JOIN discussion_sessions s ON s.id=sr.session_id JOIN member_requests r ON r.id=sr.member_request_id
|
|
1052
|
+
WHERE s.room_id=? AND r.id=? AND sr.purpose='discuss'""", (room_id, request_id)).fetchone()
|
|
1053
|
+
if not plan or plan["session_status"] != "active" or plan["status"] != "open":
|
|
1054
|
+
raise FileExistsError("讨论安排已暂停、结束或取消,不能继续回答")
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def open_discussion_requests(connection, execution_id):
|
|
1058
|
+
"""Member requests of an execution that carry an open discussion turn; read before the execution ends."""
|
|
1059
|
+
return connection.execute("""SELECT r.id FROM member_requests r JOIN session_requests sr
|
|
1060
|
+
ON sr.member_request_id=r.id WHERE r.execution_id=? AND sr.purpose='discuss' AND sr.status='open'""",
|
|
1061
|
+
(execution_id,)).fetchall()
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def after_member_stopped(connection, member_id):
|
|
1065
|
+
"""Cancel the discussion turns a stopped member's cancelled requests carried, then relay or ask the host to re-plan."""
|
|
1066
|
+
member = connection.execute("SELECT room_id,name FROM room_members WHERE id=?", (member_id,)).fetchone()
|
|
1067
|
+
session = current_session(connection, member["room_id"], active_only=True) if member else None
|
|
1068
|
+
if not session:
|
|
1069
|
+
return
|
|
1070
|
+
plans = connection.execute("""SELECT id,member_request_id FROM session_requests
|
|
1071
|
+
WHERE session_id=? AND member_id=? AND purpose='discuss' AND status='open'
|
|
1072
|
+
AND member_request_id IN (SELECT id FROM member_requests WHERE status='cancelled')""",
|
|
1073
|
+
(session["id"], member_id)).fetchall()
|
|
1074
|
+
for plan in plans:
|
|
1075
|
+
connection.execute("UPDATE session_requests SET status='cancelled' WHERE id=?", (plan["id"],))
|
|
1076
|
+
if not plans:
|
|
1077
|
+
return
|
|
1078
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
|
|
1079
|
+
if advance_session(connection, session["id"]):
|
|
1080
|
+
return
|
|
1081
|
+
request_id = plans[0]["member_request_id"]
|
|
1082
|
+
create_host_request(connection, session["id"],
|
|
1083
|
+
f"专家 {member['name']} 的讨论发言已被打断,请检查余下安排并决定如何继续。",
|
|
1084
|
+
{"kind": "request_failed", "request_id": request_id, "key": f"interrupted-{request_id or plans[0]['id']}"})
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def session_speaking_order(connection, session):
|
|
1088
|
+
"""The open round's speakers so far, current turn and queued turns, later rounds apart, and the plan state for the revision."""
|
|
1089
|
+
items = []
|
|
1090
|
+
plan_state = [list(row) for row in connection.execute("""SELECT id,round,position,member_id,question,status,submission_id
|
|
1091
|
+
FROM session_requests WHERE session_id=? AND purpose='discuss' ORDER BY round,position,id""", (session["id"],))]
|
|
1092
|
+
active_round = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
|
|
1093
|
+
(session["id"],)).fetchone()[0]
|
|
1094
|
+
if active_round is None:
|
|
1095
|
+
active_round = connection.execute("SELECT COALESCE(MAX(round),1) FROM session_requests WHERE session_id=? AND purpose='discuss'",
|
|
1096
|
+
(session["id"],)).fetchone()[0]
|
|
1097
|
+
answered = [row["name"] for row in connection.execute("""SELECT m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
|
|
1098
|
+
WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.round=? AND sr.status='answered' ORDER BY sr.position,sr.id""",
|
|
1099
|
+
(session["id"], active_round))]
|
|
1100
|
+
items.extend({"name": name, "state": "done"} for name in answered)
|
|
1101
|
+
open_turn = connection.execute("""SELECT m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
|
|
1102
|
+
WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.round=? AND sr.status='open'
|
|
1103
|
+
ORDER BY sr.position,sr.id LIMIT 1""", (session["id"], active_round)).fetchone()
|
|
1104
|
+
if open_turn:
|
|
1105
|
+
items.append({"name": open_turn["name"], "state": "speaking"})
|
|
1106
|
+
queued = connection.execute("""SELECT sr.round,m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
|
|
1107
|
+
WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.status='queued' ORDER BY sr.round,sr.position,sr.id""",
|
|
1108
|
+
(session["id"],)).fetchall()
|
|
1109
|
+
for row in queued:
|
|
1110
|
+
if row["round"] == active_round:
|
|
1111
|
+
items.append({"name": row["name"], "state": "queued"})
|
|
1112
|
+
later = [[row["name"] for row in queued if row["round"] == round_number]
|
|
1113
|
+
for round_number in sorted({row["round"] for row in queued if row["round"] > active_round})]
|
|
1114
|
+
return items, later, plan_state
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def reorder_session_turns(connection, session, order, names):
|
|
1118
|
+
"""Renumber the open round's queued turns in the owner's order, after those already answered or speaking."""
|
|
1119
|
+
active_round = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
|
|
1120
|
+
(session["id"],)).fetchone()[0]
|
|
1121
|
+
active_round = active_round or 1
|
|
1122
|
+
open_position = connection.execute("SELECT COALESCE(MAX(position),0) FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=? AND status IN ('answered','open')",
|
|
1123
|
+
(session["id"], active_round)).fetchone()[0]
|
|
1124
|
+
rows = connection.execute("SELECT id FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=? AND status='queued'",
|
|
1125
|
+
(session["id"], active_round)).fetchall()
|
|
1126
|
+
connection.execute("UPDATE session_requests SET position=-id WHERE id IN (%s)" % ",".join("?" for _ in rows),
|
|
1127
|
+
[row["id"] for row in rows]) if rows else None
|
|
1128
|
+
for offset, name in enumerate(order, 1):
|
|
1129
|
+
connection.execute("UPDATE session_requests SET position=? WHERE session_id=? AND purpose='discuss' AND round=? AND status='queued' AND member_id=?",
|
|
1130
|
+
(open_position + offset, session["id"], active_round, names[name]))
|
|
1131
|
+
connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def queue_session_invite(connection, session, member):
|
|
1135
|
+
"""Queue a newly invited member at the tail of the session's open round, once per invitation; with no live plan it waits."""
|
|
1136
|
+
planned = connection.execute("SELECT 1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued') LIMIT 1",
|
|
1137
|
+
(session["id"],)).fetchone()
|
|
1138
|
+
if not planned:
|
|
1139
|
+
return
|
|
1140
|
+
round_number = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
|
|
1141
|
+
(session["id"],)).fetchone()[0]
|
|
1142
|
+
if round_number is None:
|
|
1143
|
+
round_number = 1
|
|
1144
|
+
position = connection.execute("SELECT COALESCE(MAX(position),0)+1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=?",
|
|
1145
|
+
(session["id"], round_number)).fetchone()[0]
|
|
1146
|
+
invitation = connection.execute("SELECT COUNT(*) FROM session_requests WHERE session_id=? AND member_id=? AND submission_id LIKE 'invite:%'",
|
|
1147
|
+
(session["id"], member["id"])).fetchone()[0] + 1
|
|
1148
|
+
state = connection.execute("SELECT phase,read_until FROM session_state WHERE session_id=?", (session["id"],)).fetchone()
|
|
1149
|
+
question = ("欢迎加入:请先读取议题,独立给出你的开场观点。" if state and state["phase"] == "initial"
|
|
1150
|
+
else "欢迎加入:请先读取议题和已有回答,给出你的观点。")
|
|
1151
|
+
read_until = state["read_until"] if state and state["phase"] == "initial" and round_number == 1 else None
|
|
1152
|
+
connection.execute("""INSERT OR IGNORE INTO session_requests
|
|
1153
|
+
(session_id,round,position,member_id,purpose,question,status,submission_id,read_until,created_at)
|
|
1154
|
+
VALUES(?,?,?,?,'discuss',?,'queued',?,?,?)""",
|
|
1155
|
+
(session["id"], round_number, position, member["id"], question,
|
|
1156
|
+
f"invite:{member['id']}:{invitation}", read_until,
|
|
1157
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def blocks_message_change(connection, room_id, message_id):
|
|
1161
|
+
"""True while a discussion is open, or once one started after the message: its host reads user messages without a request."""
|
|
1162
|
+
return bool(connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND (status IN ('active','paused') OR started_message_id>=?)",
|
|
1163
|
+
(room_id, message_id)).fetchone())
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def session_request_origin(connection, member_request_id):
|
|
1167
|
+
"""The discussion turn a member request carries, with a visible transcript anchor; None outside a discussion."""
|
|
1168
|
+
row = connection.execute("""SELECT q.id AS session_request_id,q.session_id AS discussion_session_id,q.round,
|
|
1169
|
+
q.question AS source_question,t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id,
|
|
1170
|
+
CASE WHEN q.id IS NULL THEN r.message_id ELSE COALESCE((SELECT MAX(m.id) FROM messages m
|
|
1171
|
+
WHERE m.room_id=r.room_id AND m.id<r.message_id
|
|
1172
|
+
AND NOT EXISTS(SELECT 1 FROM internal_task_messages hidden WHERE hidden.message_id=m.id)),0) END AS session_anchor
|
|
1173
|
+
FROM member_requests r LEFT JOIN session_requests q ON q.member_request_id=r.id
|
|
1174
|
+
LEFT JOIN task_message_sources t ON t.session_request_id=q.id WHERE r.id=?""", (member_request_id,)).fetchone()
|
|
1175
|
+
if not row or row["session_request_id"] is None:
|
|
1176
|
+
return None
|
|
1177
|
+
return {"session_request_id": row["session_request_id"], "discussion_session_id": row["discussion_session_id"],
|
|
1178
|
+
"round": row["round"], "source_question": row["source_question"],
|
|
1179
|
+
"source_message_id": row["source_message_id"], "cause_kind": row["cause_kind"],
|
|
1180
|
+
"cause_request_id": row["cause_request_id"], "after_message_id": row["session_anchor"]}
|