ai_co_discuss 0.2.67 → 0.2.69

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/backend/server.py CHANGED
@@ -25,7 +25,7 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
25
25
  inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
26
26
  discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
27
27
  from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
28
- from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member
28
+ from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member, route_held_messages
29
29
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
30
30
  from .room_import import import_room, imported_attachment
31
31
  from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
@@ -82,6 +82,7 @@ MAX_DECISION_CONTEXT = 500
82
82
  DECISION_SELECT = """SELECT d.*,f.rating AS feedback,COALESCE(f.should_be,'') AS should_be,COALESCE(f.note,'') AS note,c.context
83
83
  FROM scheduler_decisions d LEFT JOIN scheduler_feedback f ON f.decision_id=d.id LEFT JOIN scheduler_decision_context c ON c.decision_id=d.id"""
84
84
  MAX_DECISION_LABEL = 200
85
+ ROUTING_VISIBLE_SECONDS = 120
85
86
  MAX_DECISION_DETAIL = 4000
86
87
  SCHEDULER_PROVIDERS = ("jev", "decisions")
87
88
  ROOM_BACKUP_QUERIES = {
@@ -235,6 +236,8 @@ def require_room(connection, room_id):
235
236
  "SELECT decision_id,detail FROM scheduler_decision_details WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))}
236
237
  for decision in room["scheduler_decisions"]:
237
238
  decision["detail"] = details.get(decision["id"])
239
+ room["scheduler_routing"] = [row[0] for row in connection.execute(
240
+ "SELECT message_id FROM scheduler_routing WHERE room_id=? AND created_at>? ORDER BY message_id", (room_id, int(time.time()) - ROUTING_VISIBLE_SECONDS))]
238
241
  room["host_arrangements"] = host_arrangements(connection, room_id, room["scheduler_decisions"])
239
242
  room["cancelled_turns"] = cancelled_turns(connection, room_id)
240
243
  parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
@@ -488,6 +491,8 @@ def update_topic(connection, room_id, payload):
488
491
  connection.execute("UPDATE rooms SET title=?,goal=? WHERE id=?", (settings["title"], settings["goal"], room_id))
489
492
  sync_discussion_members(connection, room_id, payload, settings)
490
493
  save_topic_settings(connection, room_id, settings)
494
+ if settings["phase"] == "concluded" and room["phase"] != "concluded" and room["mode"] != "free" and not room["archived"]:
495
+ return_to_free(connection, {**room, "host": settings["host"]})
491
496
  return require_room(connection, room_id)
492
497
 
493
498
 
@@ -629,8 +634,8 @@ def manage_room(connection, room_id, action, payload):
629
634
 
630
635
  def delete_room_records(connection, room_id):
631
636
  """Remove a room's dependent records in the caller's transaction."""
632
- for table in ("member_recommendations", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
633
- "discussion_options", "round_budgets", "scheduler_decisions", "discussion_sessions"):
637
+ for table in ("member_recommendations", "free_queue", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
638
+ "discussion_options", "round_budgets", "scheduler_decisions", "scheduler_routing", "discussion_sessions"):
634
639
  if table == "scheduler_decisions":
635
640
  for child in ("scheduler_feedback", "scheduler_decision_context"):
636
641
  connection.execute(f"DELETE FROM {child} WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
@@ -773,13 +778,20 @@ def end_discussion(connection, room_id, token, payload):
773
778
  room = require_room(connection, room_id)
774
779
  if room["mode"] == "free":
775
780
  raise FileExistsError("房间没有进行中的讨论")
776
- if room["runtime"]["status"] in ("running", "ready", "paused"):
777
- configure_runtime(connection, room_id, token, {"action": "stop", "revision": room["runtime"]["revision"]})
781
+ return_to_free(connection, room)
782
+ return require_room(connection, room_id)
783
+
784
+
785
+ def return_to_free(connection, room):
786
+ """End the discussion in place: stop its run and turns, switch back to free collaboration and route held user messages."""
787
+ room_id = room["id"]
788
+ connection.execute("""UPDATE discussion_runtime SET status='stopped',generation=generation+1,revision=revision+1
789
+ WHERE room_id=? AND status IN ('running','ready','paused')""", (room_id,))
778
790
  connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND status IN ('pending','notified')", (room_id,))
779
791
  connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
780
792
  connection.execute("UPDATE topic_settings SET phase='concluded' WHERE room_id=?", (room_id,))
781
793
  connection.execute("UPDATE room_modes SET mode='free' WHERE room_id=?", (room_id,))
782
- return require_room(connection, room_id)
794
+ route_held_messages(connection, room_id, room["host"])
783
795
 
784
796
 
785
797
  def message_values(payload):
@@ -1040,6 +1052,10 @@ def append_message(connection, room_id, payload, token="", free_actor=None):
1040
1052
  begin_write(connection)
1041
1053
  room = require_room(connection, room_id)
1042
1054
  if room["mode"] == "free" or free_actor is not None:
1055
+ previous = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND submission_id=?", (room_id, submission_id)).fetchone()
1056
+ # An exact retry of a discussion message saved before the discussion ended keeps its original result.
1057
+ if previous and previous["kind"] != "chat" and (previous["author"], previous["text"]) == (author, text):
1058
+ return {"message": message_with_attachments(connection, previous), "duplicate": True}
1043
1059
  return append_free_message(connection, room, payload, free_actor)
1044
1060
  details = message_details(room, {**payload, "author": author})
1045
1061
  # An expert's opinion answers the arrangement open for that expert; naming it with turn_id is optional.
@@ -1214,6 +1230,20 @@ def rate_scheduler_decision(connection, room_id, decision_id, token, payload):
1214
1230
  return {"decision_id": decision_id, "feedback": rating, "should_be": should_be.strip(), "note": note.strip()}
1215
1231
 
1216
1232
 
1233
+ def mark_scheduler_routing(connection, room_id, token, payload):
1234
+ """The local service marks a message while the decision model is analysing it, and clears the mark when done."""
1235
+ begin_write(connection)
1236
+ require_owner(connection, room_id, token)
1237
+ message_id, active = payload.get("message_id"), payload.get("active")
1238
+ if type(message_id) is not int or message_id <= 0 or type(active) is not bool:
1239
+ raise ValueError("须提供正整数 message_id 与布尔 active")
1240
+ if active:
1241
+ connection.execute("INSERT OR REPLACE INTO scheduler_routing (room_id,message_id,created_at) VALUES (?,?,?)", (room_id, message_id, int(time.time())))
1242
+ else:
1243
+ connection.execute("DELETE FROM scheduler_routing WHERE room_id=? AND message_id=?", (room_id, message_id))
1244
+ return {"message_id": message_id, "active": active}
1245
+
1246
+
1217
1247
  def record_scheduler_decision(connection, room_id, token, payload, owner=False):
1218
1248
  """The dispatcher records one smart-scheduling decision so the transcript shows what it did and how sure it was.
1219
1249
 
@@ -1263,27 +1293,24 @@ def continue_turns(connection, room_id, payload):
1263
1293
  connection.execute("UPDATE discussion_turns SET status='pending' WHERE id=? AND status='queued'", (turn_id,))
1264
1294
  return {"turn": get_turn(connection, room_id, turn_id), "queued": room["queued_turns"][1:]}
1265
1295
 
1266
- def adjust_limits(connection, room_id, payload, action):
1267
- """User-only follow-ups after a limit: append rounds, or reopen a concluded discussion."""
1296
+ def reopen_limits(connection, room_id, payload):
1297
+ """User-only: reopen a concluded discussion that has not returned to free collaboration."""
1268
1298
  begin_write(connection)
1269
1299
  room = require_room(connection, room_id)
1270
1300
  if payload.get("actor") != "用户":
1271
- raise ValueError("仅用户可以追加轮次或重启已收束的讨论")
1301
+ raise ValueError("仅用户可以重启已收束的讨论")
1272
1302
  if room["archived"]:
1273
1303
  raise ValueError("请先恢复已存档房间")
1304
+ if room["mode"] == "free":
1305
+ raise ValueError("房间已回到自由协作;需要讨论时请重新开启讨论")
1274
1306
  if not room["round_budget"]:
1275
- raise ValueError("该房间沿用旧轮次计数,不支持追加轮次或重启")
1276
- required_phase = "concluded" if action == "reopen" else "paused"
1277
- if room["phase"] != required_phase:
1278
- raise ValueError("仅已收束的讨论可以重启" if action == "reopen" else "仅已暂停的讨论可以追加轮次")
1279
- rounds = payload.get("rounds") if action == "extend" else payload.get("max_rounds", room["limits"]["max_rounds"])
1280
- if type(rounds) is not int or not (1 if action == "extend" else 0) <= rounds <= 100:
1281
- raise ValueError("轮次须为整数(追加 1–100,重启 0–100,0 表示不限)")
1307
+ raise ValueError("该房间沿用旧轮次计数,不支持重启")
1308
+ if room["phase"] != "concluded":
1309
+ raise ValueError("仅已收束的讨论可以重启")
1310
+ rounds = payload.get("max_rounds", room["limits"]["max_rounds"])
1311
+ if type(rounds) is not int or not 0 <= rounds <= 100:
1312
+ raise ValueError("轮次须为 0–100 的整数,0 表示不限")
1282
1313
  count = opinion_count(connection, room_id, room["experts"])
1283
- if action == "extend":
1284
- connection.execute("UPDATE round_budgets SET base=?,cap=?,extra=extra+? WHERE room_id=?", (count, rounds, rounds, room_id))
1285
- connection.execute("UPDATE discussion_limits SET stop_reason='',revision=revision+1 WHERE room_id=?", (room_id,))
1286
- return require_room(connection, room_id)
1287
1314
  limits = limit_values({"max_rounds": rounds, "max_minutes": payload.get("max_minutes", room["limits"]["max_minutes"])}, room["limits"])
1288
1315
  connection.execute("UPDATE round_budgets SET base=?,cap=0,extra=0 WHERE room_id=?", (count, room_id))
1289
1316
  connection.execute("""UPDATE discussion_limits SET max_rounds=?,max_minutes=?,started_at=0,stop_reason='',revision=revision+1
@@ -1294,29 +1321,27 @@ def adjust_limits(connection, room_id, payload, action):
1294
1321
 
1295
1322
 
1296
1323
  def check_limits(connection, room_id, payload):
1297
- """Pause at a reached boundary (or once the host declares the goal done); the user then extends or ends the discussion."""
1324
+ """End the discussion at a reached boundary (or once the host declares the goal done); the room returns to free collaboration."""
1298
1325
  action = payload.get("action", "check")
1299
- if action not in ("check", "done", "extend", "reopen"):
1300
- raise ValueError("action 须为 check/done/extend/reopen")
1301
- if action in ("extend", "reopen"):
1302
- return adjust_limits(connection, room_id, payload, action)
1326
+ if action not in ("check", "done", "reopen"):
1327
+ raise ValueError("action 须为 check/done/reopen")
1328
+ if action == "reopen":
1329
+ return reopen_limits(connection, room_id, payload)
1303
1330
  begin_write(connection)
1304
1331
  room = require_room(connection, room_id)
1305
1332
  require_host(room, payload)
1306
1333
  limits = room["limits"]
1307
1334
  done = action == "done"
1308
- inactive = room["phase"] in ("paused", "concluded") or room["archived"] or (done and room["phase"] == "preparing")
1335
+ inactive = (room["mode"] == "free" or room["phase"] in ("paused", "concluded") or room["archived"]
1336
+ or (done and room["phase"] == "preparing"))
1309
1337
  if (not limits["reached"] and not done) or inactive:
1310
1338
  return room
1311
1339
  if done:
1312
1340
  require_conclusion_ready(connection, room, payload, allow_queued=True)
1313
1341
  reason = "goal_done" if done else limits["reason"] or "user_stop"
1314
1342
  connection.execute("UPDATE discussion_limits SET stop_reason=?,revision=revision+1 WHERE room_id=?", (reason, room_id))
1315
- connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND status IN ('pending','notified')", (room_id,))
1316
- connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
1317
- connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
1318
- # Pausing at the limit also pauses the run, so the board offers restart and the old execution is superseded.
1319
- connection.execute("UPDATE discussion_runtime SET status='paused',generation=generation+1,revision=revision+1 WHERE room_id=? AND status='running'", (room_id,))
1343
+ # The run's generation moves on, so the old execution is superseded; unnamed messages now go to the host as usual.
1344
+ return_to_free(connection, room)
1320
1345
  return require_room(connection, room_id)
1321
1346
 
1322
1347
 
@@ -1443,7 +1468,7 @@ def room_text(connection, room_id, params):
1443
1468
  f"主持人:{room['host']};阶段:{PHASES[room['phase']]};当前焦点:{room['focus'] or '尚未填写'}",
1444
1469
  f"当前记录截止 #{room['latest_id']};共 {room['message_count']} 条;已完成 {room['completed_rounds']} 轮讨论(所有参与专家各发言一次)。"]
1445
1470
  limits = room["limits"]
1446
- content.append(f"讨论上限:{limits['max_rounds'] or '不限'} 轮;总时长 {limits['max_minutes'] or '不限'} 分钟;单次自动执行 {str(limits['execution_minutes']) + ' 分钟' if limits['execution_minutes'] else '不限时长'};到限后暂停,由用户决定追加轮次或结束。到限:{limits['reached']}。")
1471
+ content.append(f"讨论上限:{limits['max_rounds'] or '不限'} 轮;总时长 {limits['max_minutes'] or '不限'} 分钟;单次自动执行 {str(limits['execution_minutes']) + ' 分钟' if limits['execution_minutes'] else '不限时长'};到限后讨论结束,房间回到自由协作。到限:{limits['reached']}。")
1447
1472
  if room["mode"] == "free":
1448
1473
  members = ";".join(f"{item['name']}(已读至 #{item['read_until']},待投递 {item['pending']})" for item in room["free"]["members"])
1449
1474
  content.append(f"自由协作房间:无发言权、安排与轮次。成员:{members or '无'}。用户的消息点名成员后生成请求;成员回答须带本次执行编号,执行结束、被打断或过期后的回答会被拒绝,不能改作普通发言。")
@@ -1526,7 +1551,7 @@ def protocol_text(room):
1526
1551
  "API 参与者由本机 runner 使用相同房间授权和执行约束提交,消息 source=api;CLI 仍使用 source=cli。兼容字段 experts 中的 cli 表示可自行提交,实际接入方式见成员 adapter。",
1527
1552
  "写入使用 POST,通常为 Content-Type: application/json;附件上传使用 application/octet-stream 原始字节。主持操作填写 actor 为当前主持人或用户;这些是声明身份,不是认证凭据。",
1528
1553
  f"附件:POST {base}/attachments?actor=身份&file_name=文件名&submission_id=新编号,原文件最多 20 MiB;GET 同路径默认列已发送附件,加 submission_id 精确核对上传。GET {base}/attachments/ID 读元数据,/ID/content 下载原文件。消息可带 attachment_ids(最多 10 个有序编号);只关联同房间、同提交者的未使用附件,可只有附件无正文。自动任务须在回答完成前上传;消息查询包含附件,按需下载后由当前软件读取,不自动解析。",
1529
- "讨论上限:room.limits 返回轮次、总时长、单次执行和实际进度。准备/暂停时通过 topic 的 limits 对象修改;0 表示不预设轮次/总时长/单次执行时限。POST /api/rooms/ROOM/limits(actor)或 room limits ROOM --check 检查到限处理;到限后暂停并停止新专家安排与观点,由用户决定追加轮次或结束;暂停期间不计时也不消耗总时长。",
1554
+ "讨论上限:room.limits 返回轮次、总时长、单次执行和实际进度。准备/暂停时通过 topic 的 limits 对象修改;0 表示不预设轮次/总时长/单次执行时限。POST /api/rooms/ROOM/limits(actor)或 room limits ROOM --check 检查到限处理;到限后讨论结束并停止新专家安排与观点,房间回到自由协作;暂停期间不计时也不消耗总时长。",
1530
1555
  "运行方式:room.runtime 显示 manual/automatic、状态及真实执行回执。所有房间写入须带 X-Discussion-Token,CLI 用 --access-file 读取私有文件。用户及运行控制使用房主凭据,手动本地成员使用自己的成员凭证,不能相互冒充;在线成员只读,由房主或讨论主持人代发。自动讨论先 execution enter,创建安排或保存关联回答即完成本步,不创建原生定时任务;本机唤醒成员的持久凭证不能替代自动执行,手动接入的专家用自己的成员凭证回答安排,暂停、停止、换代、租约到期后的旧执行失效。run view/start/pause/stop/configure 字段查 CLI schema。",
1531
1556
  "没有发言权:同一话题同时只有一个未完成的发言安排,被安排的专家直接回答它;专家观点自动回答该专家当前有效的安排,turn_id 可省略(填写则须与之相符),没有轮到你时被拒绝。主持发言(moderation)只能由当前主持人署名,任何时候都可发出;创建、取消安排由主持人或用户操作。用户的 @ 直接提问不占安排也不计轮次。失败的写入不会改变安排状态,同编号重试沿用原内容。",
1532
1557
  '创建话题:POST /api/rooms,携带房主控制凭据;字段 title、goal、user_request、background、boundaries、host、experts、members、submission_id(新 UUID)。members 与 experts 名单一致,各项含 name、独立 token、access(手动本地 manual、自动 runner、在线 online)。AI 主持人必须为本地可写成员。CLI room create --access-file 私有房主文件 在请求前保存凭据,确认后返回各本地成员文件路径;同编号重试沿用原资料和全部凭据。名单、主持职责变更仅房主可做。',
@@ -1933,8 +1958,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1933
1958
  return self.respond(result)
1934
1959
  if parts[3:4] == ["attachments"]:
1935
1960
  return self.handle_attachments(connection, parts, params, method)
1936
- if parts[3:4] in (["runtime"], ["events"], ["scheduler-decisions"]):
1937
- if free and method == "POST" and parts[3:4] != ["scheduler-decisions"]:
1961
+ if parts[3:4] in (["runtime"], ["events"], ["scheduler-decisions"], ["scheduler-routing"]):
1962
+ if free and method == "POST" and parts[3:4] not in (["scheduler-decisions"], ["scheduler-routing"]):
1938
1963
  raise ValueError("自由房间不使用讨论运行控制")
1939
1964
  return self.handle_runtime_api(connection, parts, params, method)
1940
1965
  free_actor = None
@@ -2065,6 +2090,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2065
2090
  result = event_action(connection, room_id, int(parts[4]), token, self.read_payload())
2066
2091
  elif parts[3:] == ["scheduler-decisions"] and method == "POST":
2067
2092
  result = record_scheduler_decision(connection, room_id, token, self.read_payload(), owner=room_mode(connection, room_id) == "free")
2093
+ elif parts[3:] == ["scheduler-routing"] and method == "POST":
2094
+ result = mark_scheduler_routing(connection, room_id, token, self.read_payload())
2068
2095
  elif len(parts) == 5 and parts[3] == "scheduler-decisions" and method == "POST":
2069
2096
  result = rate_scheduler_decision(connection, room_id, int(parts[4]), token, self.read_payload())
2070
2097
  else: