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.
- package/.agents/skills/discuss-host/SKILL.md +5 -1
- package/backend/free_room.py +70 -24
- package/backend/schema.sql +15 -0
- package/backend/server.py +44 -3
- package/docs/architecture.md +280 -279
- package/docs/cli.md +1 -1
- package/docs/runner.md +2 -1
- package/docs/workflow.md +155 -155
- package/package.json +1 -1
- package/runner/discussion_runner.mjs +1 -1
- package/runner/free_runner.mjs +39 -3
- package/runner/scheduler.mjs +48 -24
- package/web/app.js +3384 -3382
- package/web/modern.css +1 -1
- package/web/modern.js +4286 -4146
|
@@ -105,7 +105,11 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
|
|
|
105
105
|
|
|
106
106
|
## 上限与到限
|
|
107
107
|
|
|
108
|
-
轮次由程序按专家发言量统计并判定到限(`limits.segment_rounds` / `round_cap
|
|
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 落后都会被拒绝):讨论结束,房间回到自由协作。
|
package/backend/free_room.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
|
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,
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
1222
|
-
connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND purpose
|
|
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:
|
package/backend/schema.sql
CHANGED
|
@@ -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})
|