ai_co_discuss 0.2.60 → 0.2.61

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.
@@ -506,7 +506,8 @@ def settle_batches(connection, room_id):
506
506
  host = connection.execute("SELECT * FROM room_members WHERE id=? AND status='active' AND role='host'", (batch["host_id"],)).fetchone()
507
507
  if "completed" not in statuses:
508
508
  halt_chain(connection, room_id, batch["chain_id"], "all_failed")
509
- elif not host:
509
+ elif not host or len(statuses) < 2:
510
+ # One member's answer already reaches the user; only several answers need the host to put them together.
510
511
  continue
511
512
  elif chain_budget(connection, room_id, batch["chain_id"]) < 1:
512
513
  halt_chain(connection, room_id, batch["chain_id"], "budget")
@@ -138,10 +138,16 @@ def settings_statements(tables, room, maps, now):
138
138
  statements.append(("INSERT INTO discussion_options (room_id,named_followup) VALUES (?,?)", (room_id, row["named_followup"])))
139
139
  for row in tables.get("round_budgets", [])[:1]:
140
140
  statements.append(("INSERT INTO round_budgets (room_id,base,cap,extra) VALUES (?,?,?,?)", (room_id, row["base"], row["cap"], row["extra"])))
141
- statements += [("""INSERT INTO scheduler_decisions (room_id,after_message_id,kind,label,confidence,applied,provider,created_at)
141
+ for row in tables.get("scheduler_decisions", []):
142
+ statements.append(("""INSERT INTO scheduler_decisions (room_id,after_message_id,kind,label,confidence,applied,provider,created_at)
142
143
  VALUES (?,?,?,?,?,?,?,?)""", (room_id, messages.get(row["after_message_id"], 0), row["kind"], row["label"],
143
- row.get("confidence"), row["applied"], row["provider"], row["created_at"]))
144
- for row in tables.get("scheduler_decisions", [])]
144
+ row.get("confidence"), row["applied"], row["provider"], row["created_at"])))
145
+ if row.get("feedback"):
146
+ statements.append(("""INSERT INTO scheduler_feedback (decision_id,rating,should_be,note,created_at) VALUES (last_insert_rowid(),?,?,?,?)""",
147
+ (row["feedback"], row.get("should_be", ""), row.get("note", ""), row["created_at"])))
148
+ if row.get("context"):
149
+ statements.append(("INSERT INTO scheduler_decision_context (decision_id,context) SELECT id,? FROM scheduler_decisions WHERE room_id=? ORDER BY id DESC LIMIT 1",
150
+ (row["context"], room_id)))
145
151
  return statements
146
152
 
147
153
 
@@ -586,6 +586,20 @@ CREATE TABLE IF NOT EXISTS scheduler_decision_details (
586
586
  decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
587
587
  detail TEXT NOT NULL
588
588
  );
589
+ -- The owner's verdict on one decision; a 'bad' one may also say who should have answered and why.
590
+ -- Kept apart from the decision so older databases need no column change.
591
+ CREATE TABLE IF NOT EXISTS scheduler_feedback (
592
+ decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
593
+ rating TEXT NOT NULL CHECK(rating IN ('good','bad')),
594
+ should_be TEXT NOT NULL DEFAULT '',
595
+ note TEXT NOT NULL DEFAULT '',
596
+ created_at TEXT NOT NULL
597
+ );
598
+ -- What produced a decision (model, threshold, prompt and package versions), so ratings stay comparable across versions.
599
+ CREATE TABLE IF NOT EXISTS scheduler_decision_context (
600
+ decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
601
+ context TEXT NOT NULL
602
+ );
589
603
  -- Smart scheduling's suggestion that another member answer an unnamed message. The host keeps the message;
590
604
  -- the user may adopt before expires_at (waiting until the host's request ends for mode 'after'), otherwise it lapses.
591
605
  CREATE TABLE IF NOT EXISTS member_recommendations (
package/backend/server.py CHANGED
@@ -76,6 +76,11 @@ MAX_TOPIC_DETAIL = 4000
76
76
  MAX_QUESTION_LENGTH = 1000
77
77
  MAX_PLAN_TURNS = 10
78
78
  RECENT_DECISIONS = 100
79
+ MAX_DECISION_NOTE = 200
80
+ MAX_DECISION_MEMBER = 60
81
+ MAX_DECISION_CONTEXT = 500
82
+ DECISION_SELECT = """SELECT d.*,f.rating AS feedback,COALESCE(f.should_be,'') AS should_be,COALESCE(f.note,'') AS note,c.context
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"""
79
84
  MAX_DECISION_LABEL = 200
80
85
  MAX_DECISION_DETAIL = 4000
81
86
  SCHEDULER_PROVIDERS = ("jev", "decisions")
@@ -84,7 +89,7 @@ ROOM_BACKUP_QUERIES = {
84
89
  **{table: f"SELECT * FROM {table} WHERE room_id=?" for table in (
85
90
  "room_directory", "room_folder_assignments", "room_creations", "room_briefs", "room_user_requests",
86
91
  "topic_settings", "discussion_limits", "discussion_options", "round_budgets")},
87
- "scheduler_decisions": "SELECT * FROM scheduler_decisions WHERE room_id=? ORDER BY id",
92
+ "scheduler_decisions": f"{DECISION_SELECT} WHERE d.room_id=? ORDER BY d.id",
88
93
  "topic_folders": "SELECT * FROM topic_folders WHERE id IN (SELECT folder_id FROM room_folder_assignments WHERE room_id=?)",
89
94
  "messages": "SELECT * FROM messages WHERE room_id=? ORDER BY id",
90
95
  "attachments": "SELECT * FROM attachments WHERE room_id=? AND message_id IS NOT NULL ORDER BY message_id,position",
@@ -225,7 +230,7 @@ def require_room(connection, room_id):
225
230
  # Rooms created before the option keep the host's named follow-ups.
226
231
  room["named_followup"] = bool(option[0]) if option else True
227
232
  room["scheduler_decisions"] = [dict(row) for row in connection.execute(
228
- "SELECT * FROM scheduler_decisions WHERE room_id=? ORDER BY id DESC LIMIT ?", (room_id, RECENT_DECISIONS))][::-1]
233
+ f"{DECISION_SELECT} WHERE d.room_id=? ORDER BY d.id DESC LIMIT ?", (room_id, RECENT_DECISIONS))][::-1]
229
234
  details = {row[0]: json.loads(row[1]) for row in connection.execute(
230
235
  "SELECT decision_id,detail FROM scheduler_decision_details WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))}
231
236
  for decision in room["scheduler_decisions"]:
@@ -627,6 +632,8 @@ def delete_room_records(connection, room_id):
627
632
  for table in ("member_recommendations", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
628
633
  "discussion_options", "round_budgets", "scheduler_decisions", "discussion_sessions"):
629
634
  if table == "scheduler_decisions":
635
+ for child in ("scheduler_feedback", "scheduler_decision_context"):
636
+ connection.execute(f"DELETE FROM {child} WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
630
637
  connection.execute("DELETE FROM scheduler_decision_details WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
631
638
  connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
632
639
  connection.execute("DELETE FROM message_timings WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
@@ -1187,10 +1194,33 @@ def create_turn(connection, room_id, payload):
1187
1194
  "queued": [get_turn(connection, room_id, turn_id) for turn_id in ids[1:]], "duplicate": False}
1188
1195
 
1189
1196
 
1190
- def record_scheduler_decision(connection, room_id, token, payload):
1191
- """The dispatcher records one smart-scheduling decision so the transcript shows what it did and how sure it was."""
1197
+ def rate_scheduler_decision(connection, room_id, decision_id, token, payload):
1198
+ """The owner marks a decision good or bad (a bad one may name who should have answered and why); rating null withdraws it."""
1192
1199
  begin_write(connection)
1193
- require_control(connection, room_id, token)
1200
+ require_owner(connection, room_id, token)
1201
+ rating, should_be, note = payload.get("rating"), payload.get("should_be", ""), payload.get("note", "")
1202
+ if rating not in ("good", "bad", None):
1203
+ raise ValueError("rating 须为 good、bad 或 null")
1204
+ if not isinstance(should_be, str) or not isinstance(note, str) or len(should_be) > MAX_DECISION_MEMBER or len(note.strip()) > MAX_DECISION_NOTE:
1205
+ raise ValueError(f"should_be 须为成员名,note 不超过 {MAX_DECISION_NOTE} 字")
1206
+ if not connection.execute("SELECT 1 FROM scheduler_decisions WHERE id=? AND room_id=?", (decision_id, room_id)).fetchone():
1207
+ raise LookupError("没有这条智能调度决策")
1208
+ if rating is None:
1209
+ connection.execute("DELETE FROM scheduler_feedback WHERE decision_id=?", (decision_id,))
1210
+ else:
1211
+ connection.execute("""INSERT INTO scheduler_feedback (decision_id,rating,should_be,note,created_at) VALUES (?,?,?,?,?)
1212
+ ON CONFLICT(decision_id) DO UPDATE SET rating=excluded.rating,should_be=excluded.should_be,note=excluded.note,created_at=excluded.created_at""",
1213
+ (decision_id, rating, should_be.strip(), note.strip(), utc_now()))
1214
+ return {"decision_id": decision_id, "feedback": rating, "should_be": should_be.strip(), "note": note.strip()}
1215
+
1216
+
1217
+ def record_scheduler_decision(connection, room_id, token, payload, owner=False):
1218
+ """The dispatcher records one smart-scheduling decision so the transcript shows what it did and how sure it was.
1219
+
1220
+ A free room has no run control: its local service records with the owner credential.
1221
+ """
1222
+ begin_write(connection)
1223
+ (require_owner if owner else require_control)(connection, room_id, token)
1194
1224
  kind, label, confidence = payload.get("kind"), payload.get("label"), payload.get("confidence")
1195
1225
  after = payload.get("after_message_id", 0)
1196
1226
  if kind not in ("interjection", "round") or not isinstance(label, str) or not 0 < len(label.strip()) <= MAX_DECISION_LABEL:
@@ -1201,6 +1231,12 @@ def record_scheduler_decision(connection, room_id, token, payload):
1201
1231
  raise ValueError("须提供供应商 provider、是否执行 applied 与非负的 after_message_id")
1202
1232
  cursor = connection.execute("""INSERT INTO scheduler_decisions (room_id,after_message_id,kind,label,confidence,applied,provider,created_at)
1203
1233
  VALUES (?,?,?,?,?,?,?,?)""", (room_id, after, kind, label.strip(), confidence, int(payload["applied"]), payload["provider"], utc_now()))
1234
+ context = payload.get("context")
1235
+ if context is not None:
1236
+ text = json.dumps(context, ensure_ascii=False)
1237
+ if not isinstance(context, dict) or len(text) > MAX_DECISION_CONTEXT:
1238
+ raise ValueError(f"context 须为不超过 {MAX_DECISION_CONTEXT} 字的对象")
1239
+ connection.execute("INSERT INTO scheduler_decision_context (decision_id,context) VALUES (?,?)", (cursor.lastrowid, text))
1204
1240
  detail = payload.get("detail")
1205
1241
  if detail is not None:
1206
1242
  text = json.dumps(detail, ensure_ascii=False)
@@ -1898,7 +1934,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1898
1934
  if parts[3:4] == ["attachments"]:
1899
1935
  return self.handle_attachments(connection, parts, params, method)
1900
1936
  if parts[3:4] in (["runtime"], ["events"], ["scheduler-decisions"]):
1901
- if free and method == "POST":
1937
+ if free and method == "POST" and parts[3:4] != ["scheduler-decisions"]:
1902
1938
  raise ValueError("自由房间不使用讨论运行控制")
1903
1939
  return self.handle_runtime_api(connection, parts, params, method)
1904
1940
  free_actor = None
@@ -2028,7 +2064,9 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2028
2064
  elif len(parts) == 5 and parts[3] == "events" and method == "POST":
2029
2065
  result = event_action(connection, room_id, int(parts[4]), token, self.read_payload())
2030
2066
  elif parts[3:] == ["scheduler-decisions"] and method == "POST":
2031
- result = record_scheduler_decision(connection, room_id, token, self.read_payload())
2067
+ result = record_scheduler_decision(connection, room_id, token, self.read_payload(), owner=room_mode(connection, room_id) == "free")
2068
+ elif len(parts) == 5 and parts[3] == "scheduler-decisions" and method == "POST":
2069
+ result = rate_scheduler_decision(connection, room_id, int(parts[4]), token, self.read_payload())
2032
2070
  else:
2033
2071
  raise LookupError("地址不存在")
2034
2072
  connection.commit()
@@ -257,7 +257,7 @@ running 状态即使只有 pending 事件,也必须先暂停才能重新启动
257
257
  - **手动成员注册**:`member_registrations`(`member_id`、`room_id`、`token_hash`、`registered_at`)记录手动成员自己发起的注册,`POST /api/rooms/ID/register` 只接受该成员自己的凭证(房主凭据与他人凭证返回 403),重复调用保留首次时间。记录绑定注册时的凭证哈希:请回换发新凭证后旧记录不再匹配,视图中的 `registered_at` 回到空串,须重新注册。会话 ID 不属于注册内容,仍由 CLI 头部采集进 `member_sessions`;`adopt` 要求已注册。讨论房间的成员视图与自由房间 `free.members` 都带 `registered_at`;新表为独立表,D1 重新应用 schema 即可。
258
258
  - **请求**:用户消息按结构化接收者 `to`(成员名)为每位成员生成一条请求;省略且仅有一位在场成员时默认点名该成员,多位成员时交给主持人(`route`),只有在线成员而没有主持人时只作留言,不广播。网页只从正文中代码与引用之外的 `@成员` 生成 `to`,没有 @ 时省略 `to`。请求状态为 `pending`(待投递)→ `delivering`(处理中)→ `completed` / `cancelled` / `failed`。
259
259
  - **执行**:`member_executions` 每位成员最多一个 `dispatched/started`(部分唯一索引),不同成员可并行。接入程序用控制凭据 `GET inbox`、`POST executions {member_id, client_id}`(同一 `client_id` 返回同一执行)把成员的全部待投递请求打包;`POST executions/ID {action}` 支持 `renew`(租约 120 秒,不超过单次执行时限,默认 20 分钟)、`fail`(请求标记失败)和 `release`(仅确认未送达时退回队列)。租约过期的执行在下次读取或领取时标记失败,不自动重发。
260
- - **主持人与任务链**:用户是需求方,不担任主持人。只要有在场的非在线成员,房间就保持恰好一位主持人:成员变动时,离开或移出的主持人失去角色,由第一位在场的 runner 成员(没有则手动成员)接任;建房和每条房主消息前也做同样的补位,因此旧房间在下一条消息时自动获得主持人。房主用 `POST members/ID {action:"role", role:"host"}` 更换主持人(在线成员不能担任),把现任主持人改为 `member` 会被拒绝。多位成员时未点名的房主消息生成给主持人的 `route` 请求。主持人的回答可带 `to` 点名其他成员(不能点名自己),这些请求组成一个批次(`free_batches`);批次内全部进入终态后,只要有一个完成就给主持人生成一次 `handoff` 请求,全部失败则停下该任务链。每条房主消息开启一条任务链(`chain_id` 为该消息编号),链上 AI 发起的请求(派活与回交)最多 8 次,超出即停下(`free_chains`,原因 `budget` / `all_failed`),房主发新消息开启新链。Worker 用同一批次内的集合 SQL 完成回交,回交请求按(派活消息,主持人)唯一,重复执行不会重复唤醒。
260
+ - **主持人与任务链**:用户是需求方,不担任主持人。只要有在场的非在线成员,房间就保持恰好一位主持人:成员变动时,离开或移出的主持人失去角色,由第一位在场的 runner 成员(没有则手动成员)接任;建房和每条房主消息前也做同样的补位,因此旧房间在下一条消息时自动获得主持人。房主用 `POST members/ID {action:"role", role:"host"}` 更换主持人(在线成员不能担任),把现任主持人改为 `member` 会被拒绝。多位成员时未点名的房主消息生成给主持人的 `route` 请求。主持人的回答可带 `to` 点名其他成员(不能点名自己),这些请求组成一个批次(`free_batches`);批次内全部进入终态后:只有一个请求的批次不再回交(该成员的回答直接面向用户);多个请求的批次只要有一个完成就给主持人生成一次 `handoff` 请求;全部失败则停下该任务链。每条房主消息开启一条任务链(`chain_id` 为该消息编号),链上 AI 发起的请求(派活与回交)最多 8 次,超出即停下(`free_chains`,原因 `budget` / `all_failed`),房主发新消息开启新链。Worker 用同一批次内的集合 SQL 完成回交,回交请求按(派活消息,主持人)唯一,重复执行不会重复唤醒。
261
261
  - **智能调度推荐**:接入程序用控制凭据 `POST recommendations {message_id, member_id, provider, confidence}` 为一条已交给主持人(`route`)的未点名消息记录推荐对象(在场的非该主持人成员,含手动与在线成员),写入 `member_recommendations`(每条消息一条,重复提交忽略;独立表,D1 重新应用 schema 即可),有效期 60 秒,主持人的请求不受影响。房主用 `POST recommendations/MESSAGE_ID {action:"adopt", mode}` 采纳:`interrupt` 取消主持人这条请求(已投递则作废主持人本次整个执行),`after` 等主持人这条请求进入终态。两种方式都先置为 `waiting`,由 `release`(采纳时与每次 `inbox` 读取时执行)在主持人请求已终态时为推荐成员建一条 `ask` 请求(`created_by=user`、`chain_id` 为原消息),状态改为 `adopted`。过期 5 秒宽限后采纳返回 409,重复采纳返回原结果。房间视图 `free.recommendations` 带成员名、状态、方式、置信度与剩余秒数 `expires_in`;备份、导入(仅作历史,不再生效)与删除一并处理。`inbox` 的待投递项带 `purpose` 供接入程序识别。`inbox.members` 只列由本机唤醒的成员;手动与在线成员另列于 `inbox.observers`(含待处理请求,供智能调度识别主持人与推荐对象),接入程序从不向它们投递。
262
262
  - **回应建议**:普通成员新发言的结构化 `to` 与正文在场成员 @ 合并保存到 `member_suggestions`(原消息编号、稳定成员编号),排除自己、代码与引用;不会创建请求。消息读取返回 `suggestions`,包含目标在场状态及已转换的请求编号。房主用 `POST suggestions {message_id,member_id}` 转换:同一事务记录用户确认消息,调用现有建请求路径,`message_id` 保留原回答、`chain_id` 使用用户确认消息编号、`created_by=user`。`UNIQUE(message_id,member_id)` 使重复或并发点击只生成一个请求及一条确认,终态不重新派发。权限、在场状态和未存档条件在写入事务内校验;Worker 使用批次断言。升级须应用包含新表的 schema;Python 重启时自动建表,D1 发布前显式执行。已有历史消息不补写建议。
263
263
  - **讨论中的直接提问**:讨论房间的用户消息带 `to`(点名成员名列表)时,除保存消息外为每位被点名的在场成员建一条 `ask` 请求(`created_by=user`,不入 `free_batches`,不占安排);讨论运行中点名主持人不重复建请求。`GET inbox`、`POST executions` 与 `executions/ID` 对讨论房间同样可用,成员以 `execution_id`(接入程序交付)或 `request_ids`(手动 / 代发)提交 `chat` 回答,走自由房间的写入校验。同一成员的 `member_executions` 与 `discussion_events` 互斥(任一方领取时另一方有进行中则返回 409)。房间视图 `asks` 给出最近请求与执行,供消息旁的状态徽标。本机服务以 `DiscussionWithQuestions`(`runner/discussion_service.mjs`)并行运行讨论 runner 与 `FreeRoomRunner`,共用同一成员会话绑定。
package/docs/cli.md CHANGED
@@ -150,7 +150,7 @@ codiscuss message post ROOM --as Claude --kind chat --execution 执行编号 --b
150
150
 
151
151
  手动接入成员首次读取房间后执行一次 `member register ROOM --as 本人 --access-file FILE`:网站记录该成员已用自己的凭证注册,重复执行保留首次时间,房主凭据或他人凭证被拒绝(403),自动接入成员不需要注册(400)。命令不带会话 ID,CLI 在每次请求头中自动附带宿主会话(`CLAUDE_CODE_SESSION_ID` / `CODEX_THREAD_ID`)。未注册的成员在网页显示“待注册”,也不会被本机服务接管。
152
152
 
153
- 主持人在回答中用 `--to 成员名`(可重复)点名派活,例如 `--to Codex --to Gemini`;不能点名自己。被点名的成员都结束后,主持人会再收到一次 `handoff` 请求。普通成员也可用 `--to` 或正文 `@成员` 提出回应建议,但不会派发;房主在网页点击“转为请求”后才建立关联原消息的新用户任务链。代码与引用里的正文 @ 不生成建议。
153
+ 主持人在回答中用 `--to 成员名`(可重复)点名派活,例如 `--to Codex --to Gemini`;不能点名自己。只点名一位成员时,主持人不再收到后续请求;点名多位成员时,都结束后主持人会再收到一次 `handoff` 请求。普通成员也可用 `--to` 或正文 `@成员` 提出回应建议,但不会派发;房主在网页点击“转为请求”后才建立关联原消息的新用户任务链。代码与引用里的正文 @ 不生成建议。
154
154
 
155
155
  ## 写入核对与错误处理
156
156