ai_co_discuss 0.2.70 → 0.2.71

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.
@@ -105,7 +105,11 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
105
105
 
106
106
  ## 上限与到限
107
107
 
108
- 轮次由程序按专家发言量统计并判定到限(`limits.segment_rounds` / `round_cap`),不要用自己的计划结构推算;暂停、准备与收束期间不计时。到限后讨论结束、房间回到自由协作,用 `room limits ROOM --check --as 本人`(凭据方式遵循参与技能)处理:
108
+ 轮次由程序按专家发言量统计并判定到限(`limits.segment_rounds` / `round_cap`),不要用自己的计划结构推算;暂停、准备与收束期间不计时。
109
+
110
+ 开场安排第一位专家前,先核对目标与上限:若按目标推算,本次轮次或时长上限显然不够完成(如要求报数到 10,3 人 3 轮只有 9 次发言),先发一条主持消息告诉用户冲突所在、上限内能做到哪一步,以及调整的办法(结束后在中间区点“继续讨论”设新上限);然后在上限内照常推进,不等待答复,也不自行放宽上限。只写进 state 或简报不算告知。
111
+
112
+ 到限后讨论结束、房间回到自由协作,用 `room limits ROOM --check --as 本人`(凭据方式遵循参与技能)处理:
109
113
 
110
114
  - 返回后房间已回到自由协作,本次执行随之换代,停止即可;之后用户的未点名留言照常交给你处理。用户要总结时,按“收束”一节写出最终 `moderation`。
111
115
  - 目标提前完成时,先保存有原文依据的结论、state 与简报,再 `room conclusion` 取得 `revision`,用 `room limits ROOM --done --conclusion <revision> --as 本人` 通知程序(未带、过期或有未处理留言、state 落后都会被拒绝):讨论结束,房间回到自由协作。
@@ -336,12 +336,18 @@ def recipients(connection, room_id, payload):
336
336
 
337
337
  A single active member is named by default. With several, an unnamed message goes to the
338
338
  AI host for routing; only a room of online members has none, and then it stays a plain note.
339
+ Naming everyone with order auto leaves the host a group request: the runner has everyone answer
340
+ at once or one after another; a host the runner does not wake has them all answer at once.
339
341
  """
340
342
  # Rooms created while the user could host get their AI host on the next message.
341
343
  sync_participants(connection, room_id)
342
344
  active = active_members(connection, room_id)
343
345
  if payload.get("to") is not None:
344
- return [(member_id, "ask") for member_id in named_members(active, payload["to"])]
346
+ named = named_members(active, payload["to"])
347
+ host = next((row for row in active.values() if row["role"] == "host"), None)
348
+ if payload.get("order") == "auto" and len(named) > 1 and host and host["access"] == "runner":
349
+ return [(host["id"], "group")]
350
+ return [(member_id, "ask") for member_id in named]
345
351
  if len(active) == 1:
346
352
  return [(next(iter(active.values()))["id"], "ask")]
347
353
  host = next((row for row in active.values() if row["role"] == "host"), None)
@@ -587,12 +593,13 @@ def speaking_order(connection, room):
587
593
  """Who spoke, who speaks now and who is next, shared by free rooms and discussions; only the not-yet-spoken part may be reordered.
588
594
 
589
595
  A free room shows its latest unfinished one-after-another queue, else every member in the room's default order;
590
- a discussion shows the open round: speakers so far, the current turn, then queued turns. The revision changes with any of it.
596
+ a discussion shows the open round: speakers so far, the current turn, then this round's queued turns; a plan reaching into
597
+ later rounds lists them apart in `later` (one name list per round), read-only. The revision changes with any of it.
591
598
  """
592
599
  active = [member for member in room["members"] if member["status"] == "active"]
593
600
  by_id = {member["id"]: member for member in active}
594
601
  ordered = [member["name"] for member in default_order(connection, room["id"], active)]
595
- items, kind, target = [], "default", 0
602
+ items, kind, target, later = [], "default", 0, []
596
603
  if room["mode"] == "free":
597
604
  target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
598
605
  AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
@@ -606,22 +613,37 @@ def speaking_order(connection, room):
606
613
  else:
607
614
  kind = "turns"
608
615
  last = room["rounds"][-1] if room["rounds"] else None
609
- items = [{"name": name, "state": "done"} for name in (last["speakers"] if last and not last["completed"] else [])]
610
616
  turn = room["current_turn"]
611
- if turn and turn["status"] in ("pending", "notified"):
612
- items.append({"name": turn["expert"], "state": "speaking"})
613
- items += [{"name": item["expert"], "state": "queued"} for item in room["queued_turns"]]
617
+ speaking = turn["expert"] if turn and turn["status"] in ("pending", "notified") else None
618
+ items = [{"name": name, "state": "done"} for name in (last["speakers"] if last and not last["completed"] else []) if name != speaking]
619
+ if speaking:
620
+ items.append({"name": speaking, "state": "speaking"})
621
+ rounds = split_rounds([item["expert"] for item in room["queued_turns"]], {item["name"] for item in items})
622
+ items += [{"name": name, "state": "queued"} for name in rounds[0]]
623
+ later = rounds[1:]
614
624
  # The user may still hold the host seat of an old discussion; only AI participants have a place.
615
625
  ordered = [name for name in [room["host"], *ordered, *room["experts"]] if (name in room["experts"] or name == room["host"]) and name != "用户"]
616
626
  listed = {item["name"] for item in items}
617
627
  items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
618
628
  movable = "queued" if kind != "default" else "idle"
619
- state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items]], ensure_ascii=False)
620
- return {"kind": kind, "message_id": target, "items": items, "movable": [item["name"] for item in items if item["state"] == movable],
629
+ state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later], ensure_ascii=False)
630
+ return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
621
631
  "default": [member["name"] for member in default_order(connection, room["id"], active)],
622
632
  "revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
623
633
 
624
634
 
635
+ def split_rounds(names, spoken):
636
+ """Cut a turn queue into rounds: an expert who already spoke in the round being filled starts the next one."""
637
+ rounds, seen = [[]], set(spoken)
638
+ for name in names:
639
+ if name in seen:
640
+ rounds.append([])
641
+ seen = set()
642
+ rounds[-1].append(name)
643
+ seen.add(name)
644
+ return rounds
645
+
646
+
625
647
  def reorder_speakers(connection, room, token, payload):
626
648
  """Owner reorders the not-yet-spoken part of the speaking order; who already spoke or speaks now stays put.
627
649
 
@@ -654,13 +676,18 @@ def reorder_speakers(connection, room, token, payload):
654
676
 
655
677
 
656
678
  def reorder_turns(connection, room_id, order):
657
- """Move queued turns into the new order; ids keep the relay order, so each turn's content moves to the id at its new place."""
658
- rows = [dict(row) for row in connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id", (room_id,))]
679
+ """Move this round's queued turns into the new order; ids keep the relay order, so each turn's content moves to the id at its new place.
680
+
681
+ This round's turns lead the queue, so they are its first rows; later rounds keep theirs.
682
+ """
683
+ rows = [dict(row) for row in connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id LIMIT ?",
684
+ (room_id, len(order)))]
659
685
  remaining, moved = list(rows), []
660
686
  for name in order:
661
687
  moved.append(remaining.pop(next(index for index, row in enumerate(remaining) if row["expert"] == name)))
662
688
  # Submission ids are unique, so free them before writing them to their new rows.
663
- connection.execute("UPDATE discussion_turns SET submission_id=submission_id||':moving:'||id WHERE room_id=? AND status='queued'", (room_id,))
689
+ for slot in rows:
690
+ connection.execute("UPDATE discussion_turns SET submission_id=submission_id||':moving:'||id WHERE id=?", (slot["id"],))
664
691
  for slot, content in zip(rows, moved):
665
692
  connection.execute("UPDATE discussion_turns SET expert=?,question=?,submission_id=?,created_at=? WHERE id=?",
666
693
  (content["expert"], content["question"], content["submission_id"], content["created_at"], slot["id"]))
@@ -772,19 +799,20 @@ def finish_execution(connection, execution_id, status, result, request_status, c
772
799
 
773
800
 
774
801
  def open_route_messages(connection, member_id, pending_only=False):
775
- """Messages whose host request is still open, in order; they need a new host if this one leaves."""
802
+ """(message, purpose) of host requests still open, in order; they need a new host if this one leaves."""
776
803
  states = "'pending'" if pending_only else "'pending','delivering'"
777
- return [row[0] for row in connection.execute(
778
- f"SELECT message_id FROM member_requests WHERE member_id=? AND purpose='route' AND status IN ({states}) ORDER BY message_id", (member_id,))]
804
+ return [(row[0], row[1]) for row in connection.execute(
805
+ f"""SELECT message_id,purpose FROM member_requests WHERE member_id=? AND purpose IN ('route','group')
806
+ AND status IN ({states}) ORDER BY message_id""", (member_id,))]
779
807
 
780
808
 
781
- def hand_over_routes(connection, room_id, message_ids, departed_id):
809
+ def hand_over_routes(connection, room_id, routes, departed_id):
782
810
  """Ask the current host to route messages left behind by the previous host."""
783
811
  host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active' AND id<>?",
784
812
  (room_id, departed_id or 0)).fetchone()
785
- for message_id in message_ids if host else []:
813
+ for message_id, purpose in routes if host else []:
786
814
  connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
787
- VALUES (?,?,?,?,'route','user',?)""", (room_id, host["id"], message_id, message_id, int(time.time())))
815
+ VALUES (?,?,?,?,?,'user',?)""", (room_id, host["id"], message_id, message_id, purpose, int(time.time())))
788
816
 
789
817
 
790
818
  def stop_member_work(connection, member_id, reason, cancel_reason):
@@ -865,9 +893,26 @@ def release_recommendations(connection, room_id):
865
893
  def route_wide(connection, room_id, message_id, action):
866
894
  """Smart scheduling answers an unnamed message with everyone (each member once, as if the user named them all) or no one.
867
895
 
896
+ A message naming everyone (a group request) is answered by all at once or, with action sequential, one after another.
868
897
  Only a host request that has not started changes; a started host already handles the message its own way.
869
898
  """
870
899
  route = host_route_request(connection, message_id)
900
+ group = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND purpose='group'", (message_id,)).fetchone()
901
+ if group and group["room_id"] == room_id:
902
+ if action not in ("all", "sequential"):
903
+ raise ValueError("请全体成员回应的消息只能选 action=all 或 sequential")
904
+ if group["status"] != "pending":
905
+ raise FileExistsError("这条消息已不再等待安排")
906
+ if action == "sequential":
907
+ # The row is removed rather than ended so the host takes its place in the queue like everyone else.
908
+ connection.execute("DELETE FROM member_requests WHERE id=?", (group["id"],))
909
+ members = connection.execute(f"SELECT id FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))
910
+ queue_in_turn(connection, room_id, message_id, [row["id"] for row in members])
911
+ requests = connection.execute("SELECT * FROM member_requests WHERE message_id=? ORDER BY id", (message_id,)).fetchall()
912
+ return {"action": action, "requests": [dict(row) for row in requests]}
913
+ route = group
914
+ elif action == "sequential":
915
+ raise ValueError("只有请全体成员回应的消息可以依次回答")
871
916
  if not route or route["room_id"] != room_id:
872
917
  raise LookupError("只能处理交给主持人的未点名消息")
873
918
  if route["status"] != "pending":
@@ -892,10 +937,10 @@ def adopt_recommendation(connection, room_id, token, message_id, payload):
892
937
  """
893
938
  require_owner(connection, room_id, token)
894
939
  begin_write(connection)
895
- if payload.get("action") in ("all", "none"):
940
+ if payload.get("action") in ("all", "none", "sequential"):
896
941
  return route_wide(connection, room_id, message_id, payload["action"])
897
942
  if payload.get("action") != "adopt" or payload.get("mode") not in ("interrupt", "after"):
898
- raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体或无需回应为 action=all 或 none")
943
+ raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体、依次或无需回应为 action=all、sequential 或 none")
899
944
  row = connection.execute("SELECT * FROM member_recommendations WHERE room_id=? AND message_id=?", (room_id, message_id)).fetchone()
900
945
  if not row:
901
946
  raise LookupError("这条消息没有智能调度推荐")
@@ -950,12 +995,13 @@ def reserve_execution(connection, room_id, token, payload):
950
995
  if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND participant=? AND status IN ('dispatched','started')",
951
996
  (room_id, member["name"])).fetchone():
952
997
  raise FileExistsError("该成员正在处理讨论安排;同一会话不并发续接")
953
- if not connection.execute("SELECT 1 FROM member_requests WHERE member_id=? AND status='pending'", (member["id"],)).fetchone():
998
+ # A group request waits for the runner to choose how everyone answers; it is never delivered itself.
999
+ if not connection.execute("SELECT 1 FROM member_requests WHERE member_id=? AND status='pending' AND purpose<>'group'", (member["id"],)).fetchone():
954
1000
  raise FileExistsError("该成员没有待投递的请求")
955
1001
  now = int(time.time())
956
1002
  cursor = connection.execute("""INSERT INTO member_executions (room_id,member_id,client_id,lease_until,created_at,cwd)
957
1003
  VALUES (?,?,?,?,?,?)""", (room_id, member["id"], client_id, now + LEASE_SECONDS, now, str(payload.get("cwd") or "")[:MAX_PATH_LENGTH]))
958
- connection.execute("UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending'",
1004
+ connection.execute("UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending' AND purpose<>'group'",
959
1005
  (cursor.lastrowid, member["id"]))
960
1006
  return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (cursor.lastrowid,)).fetchone())
961
1007
 
@@ -1218,8 +1264,8 @@ def member_action(connection, room_id, member_id, token, payload):
1218
1264
  old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
1219
1265
  if old_host:
1220
1266
  routed = open_route_messages(connection, old_host["id"], pending_only=True)
1221
- record_cancel_reasons(connection, "member_id=? AND purpose='route' AND status='pending'", (old_host["id"],), "rerouted")
1222
- connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND purpose='route' AND status='pending'", (old_host["id"],))
1267
+ record_cancel_reasons(connection, "member_id=? AND purpose IN ('route','group') AND status='pending'", (old_host["id"],), "rerouted")
1268
+ connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND purpose IN ('route','group') AND status='pending'", (old_host["id"],))
1223
1269
  connection.execute("UPDATE room_members SET role='member' WHERE room_id=? AND role='host'", (room_id,))
1224
1270
  connection.execute("UPDATE room_members SET role=? WHERE id=?", (role, member_id))
1225
1271
  else:
@@ -612,6 +612,21 @@ CREATE TABLE IF NOT EXISTS scheduler_decision_context (
612
612
  decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
613
613
  context TEXT NOT NULL
614
614
  );
615
+ -- The exact input the decision model saw (state and full questions with option descriptions); not part of backups.
616
+ CREATE TABLE IF NOT EXISTS scheduler_decision_inputs (
617
+ decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
618
+ input TEXT NOT NULL
619
+ );
620
+ -- A self-contained copy of each decision the owner currently rates bad, kept when its room is deleted so prompts can be reviewed.
621
+ CREATE TABLE IF NOT EXISTS scheduler_feedback_cases (
622
+ decision_id INTEGER PRIMARY KEY,
623
+ room_id TEXT NOT NULL,
624
+ room_title TEXT NOT NULL,
625
+ snapshot TEXT NOT NULL,
626
+ should_be TEXT NOT NULL DEFAULT '',
627
+ note TEXT NOT NULL DEFAULT '',
628
+ rated_at TEXT NOT NULL
629
+ );
615
630
  -- Smart scheduling's suggestion that another member answer an unnamed message. The host keeps the message;
616
631
  -- the user may adopt before expires_at (waiting until the host's request ends for mode 'after'), otherwise it lapses.
617
632
  CREATE TABLE IF NOT EXISTS member_recommendations (
package/backend/server.py CHANGED
@@ -85,6 +85,8 @@ FROM scheduler_decisions d LEFT JOIN scheduler_feedback f ON f.decision_id=d.id
85
85
  MAX_DECISION_LABEL = 200
86
86
  ROUTING_VISIBLE_SECONDS = 120
87
87
  MAX_DECISION_DETAIL = 4000
88
+ MAX_DECISION_INPUT = 60000
89
+ FEEDBACK_CASES_PAGE = 50
88
90
  SCHEDULER_PROVIDERS = ("jev", "decisions")
89
91
  ROOM_BACKUP_QUERIES = {
90
92
  "rooms": "SELECT * FROM rooms WHERE id=?",
@@ -111,10 +113,14 @@ FREE_BACKUP_QUERIES = {
111
113
  MESSAGE_QUERY = """SELECT messages.*, COALESCE(details.kind,'opinion') AS kind,
112
114
  COALESCE(details.submitted_by, CASE WHEN messages.source IN ('cli','api','url','user')
113
115
  THEN messages.author ELSE '用户' END) AS submitted_by, details.turn_id,
114
- COALESCE(summaries.text,'') AS summary, COALESCE(summaries.author,'') AS summary_by, timing.elapsed_seconds
116
+ COALESCE(summaries.text,'') AS summary, COALESCE(summaries.author,'') AS summary_by, timing.elapsed_seconds,
117
+ answered.execution_id, answered.request_message_id
115
118
  FROM messages LEFT JOIN message_details AS details ON details.message_id=messages.id
116
119
  LEFT JOIN message_summaries AS summaries ON summaries.message_id=messages.id
117
- LEFT JOIN message_timings AS timing ON timing.message_id=messages.id"""
120
+ LEFT JOIN message_timings AS timing ON timing.message_id=messages.id
121
+ LEFT JOIN (SELECT answer_id, MIN(execution_id) AS execution_id, MIN(message_id) AS request_message_id
122
+ FROM member_requests WHERE answer_id IS NOT NULL AND execution_id IS NOT NULL GROUP BY answer_id) AS answered
123
+ ON answered.answer_id=messages.id"""
118
124
 
119
125
 
120
126
  def room_backup(connection, room_id):
@@ -642,7 +648,7 @@ def delete_room_records(connection, room_id):
642
648
  for table in ("member_recommendations", "free_queue", "speaking_orders", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
643
649
  "discussion_options", "round_budgets", "scheduler_decisions", "scheduler_routing", "discussion_sessions"):
644
650
  if table == "scheduler_decisions":
645
- for child in ("scheduler_feedback", "scheduler_decision_context"):
651
+ for child in ("scheduler_feedback", "scheduler_decision_context", "scheduler_decision_inputs"):
646
652
  connection.execute(f"DELETE FROM {child} WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
647
653
  connection.execute("DELETE FROM scheduler_decision_details WHERE decision_id IN (SELECT id FROM scheduler_decisions WHERE room_id=?)", (room_id,))
648
654
  connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
@@ -1234,9 +1240,34 @@ def rate_scheduler_decision(connection, room_id, decision_id, token, payload):
1234
1240
  connection.execute("""INSERT INTO scheduler_feedback (decision_id,rating,should_be,note,created_at) VALUES (?,?,?,?,?)
1235
1241
  ON CONFLICT(decision_id) DO UPDATE SET rating=excluded.rating,should_be=excluded.should_be,note=excluded.note,created_at=excluded.created_at""",
1236
1242
  (decision_id, rating, should_be.strip(), note.strip(), utc_now()))
1243
+ if rating == "bad":
1244
+ save_feedback_case(connection, room_id, decision_id, should_be.strip(), note.strip())
1245
+ else:
1246
+ connection.execute("DELETE FROM scheduler_feedback_cases WHERE decision_id=?", (decision_id,))
1237
1247
  return {"decision_id": decision_id, "feedback": rating, "should_be": should_be.strip(), "note": note.strip()}
1238
1248
 
1239
1249
 
1250
+ def save_feedback_case(connection, room_id, decision_id, should_be, note):
1251
+ """Copy a bad-rated decision with everything the model saw, so it outlives its room for prompt review."""
1252
+ decision = dict(connection.execute(f"{DECISION_SELECT} WHERE d.id=?", (decision_id,)).fetchone())
1253
+ stored = {name: connection.execute(f"SELECT {column} FROM {table} WHERE decision_id=?", (decision_id,)).fetchone()
1254
+ for name, table, column in (("detail", "scheduler_decision_details", "detail"), ("input", "scheduler_decision_inputs", "input"))}
1255
+ snapshot = {key: decision[key] for key in ("kind", "after_message_id", "label", "confidence", "applied", "provider", "created_at")}
1256
+ snapshot["context"] = json.loads(decision["context"]) if decision["context"] else None
1257
+ snapshot.update({name: json.loads(row[0]) if row else None for name, row in stored.items()})
1258
+ title = connection.execute("SELECT title FROM rooms WHERE id=?", (room_id,)).fetchone()[0]
1259
+ connection.execute("""INSERT OR REPLACE INTO scheduler_feedback_cases (decision_id,room_id,room_title,snapshot,should_be,note,rated_at)
1260
+ VALUES (?,?,?,?,?,?,?)""", (decision_id, room_id, title, json.dumps(snapshot, ensure_ascii=False), should_be, note, utc_now()))
1261
+
1262
+
1263
+ def list_feedback_cases(connection, after):
1264
+ """Bad-rated decision snapshots after a decision id, oldest first, for the administrator's prompt review."""
1265
+ rows = connection.execute("SELECT * FROM scheduler_feedback_cases WHERE decision_id>? ORDER BY decision_id LIMIT ?",
1266
+ (after, FEEDBACK_CASES_PAGE)).fetchall()
1267
+ cases = [{**dict(row), "snapshot": json.loads(row["snapshot"])} for row in rows]
1268
+ return {"cases": cases, "next_after": cases[-1]["decision_id"] if len(cases) == FEEDBACK_CASES_PAGE else None}
1269
+
1270
+
1240
1271
  def mark_scheduler_routing(connection, room_id, token, payload):
1241
1272
  """The local service marks a message while the decision model is analysing it, and clears the mark when done."""
1242
1273
  begin_write(connection)
@@ -1280,6 +1311,12 @@ def record_scheduler_decision(connection, room_id, token, payload, owner=False):
1280
1311
  if not isinstance(detail, list) or len(detail) > 4 or len(text) > MAX_DECISION_DETAIL:
1281
1312
  raise ValueError(f"detail 须为最多 4 项、不超过 {MAX_DECISION_DETAIL} 字的列表")
1282
1313
  connection.execute("INSERT INTO scheduler_decision_details (decision_id,detail) VALUES (?,?)", (cursor.lastrowid, text))
1314
+ model_input = payload.get("input")
1315
+ if model_input is not None:
1316
+ text = json.dumps(model_input, ensure_ascii=False)
1317
+ if not isinstance(model_input, dict) or len(text) > MAX_DECISION_INPUT:
1318
+ raise ValueError(f"input 须为不超过 {MAX_DECISION_INPUT} 字的对象")
1319
+ connection.execute("INSERT INTO scheduler_decision_inputs (decision_id,input) VALUES (?,?)", (cursor.lastrowid, text))
1283
1320
  return dict(connection.execute("SELECT * FROM scheduler_decisions WHERE id=?", (cursor.lastrowid,)).fetchone())
1284
1321
 
1285
1322
 
@@ -1902,6 +1939,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1902
1939
  return self.respond({"ok": True})
1903
1940
  if parts[:2] == ["api", "admin"]:
1904
1941
  return self.handle_admin_api(connection, parts, method)
1942
+ if parts == ["api", "scheduler-feedback"] and method == "GET":
1943
+ if not self.admin_session(connection):
1944
+ raise AdminError("需要登录管理员账号", 401)
1945
+ return self.respond(list_feedback_cases(connection, int(params.get("after", 0))))
1905
1946
  if parts == ["api", "rooms"]:
1906
1947
  if method == "GET":
1907
1948
  return self.respond({**list_rooms(connection, params), "participants": PARTICIPANTS})