ai_co_discuss 0.2.42 → 0.2.43

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.
@@ -49,6 +49,7 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
49
49
 
50
50
  遵守用户设定的轮次、总时长、单次执行及到限方式,不自行放宽。轮次以服务端 `completed_rounds` 为准;暂停、准备与收束期间不计时。到限而尚未进入收束时,用 `room limits ROOM --check --as 本人`(凭据方式遵循参与技能)处理:
51
51
 
52
+ - 到限且方式为暂停时,即使目标已完成也不得自行设为 `concluded`,以 `--check` 暂停保留现场,由用户决定后续。
52
53
  - 返回暂停就停止;自动执行若换代,结束旧执行,等待新任务。
53
54
  - `limits.closing=true` 且本次执行仍有效时,只根据已有材料总结,不再安排专家。预留时间包含在总时限内,来不及完成就保留未决,不谎称收束。
54
55
 
package/backend/server.py CHANGED
@@ -430,6 +430,10 @@ def update_topic(connection, room_id, payload):
430
430
  if turn and turn["status"] in ("pending", "notified"):
431
431
  if settings["phase"] == "concluded" or turn["expert"] not in settings["experts"]:
432
432
  raise ValueError("请先完成或取消当前发言安排")
433
+ limits = room["limits"]
434
+ if settings["phase"] == "concluded" and room["phase"] != "concluded" and payload.get("actor") != "用户" \
435
+ and limits["reached"] and not limits["closing"] and limits["on_limit"] == "pause":
436
+ raise ValueError("已到讨论上限且到限方式为暂停保留现场,请先执行 room limits --check,不得直接收束")
433
437
  running = connection.execute("SELECT 1 FROM discussion_runtime WHERE room_id=? AND status='running'", (room_id,)).fetchone()
434
438
  if running and settings["experts"] != room["experts"]:
435
439
  raise ValueError("讨论运行中请在进度看板邀请、暂离或移除专家")
package/docs/runner.md CHANGED
@@ -324,7 +324,7 @@ Codex 要求 CLI 0.163.0 或更新版本,使用 permission profiles 和本地
324
324
 
325
325
  新建房间面板始终显示“智能调度”,可选供应商(TypeSafe Jev 的 `POST /v1/systemone`,或 OpenAI Decisions 的 `POST /v1/decisions`)、模型(预设 `jev-latest` / `gpt-6-luna`)并填写密钥。密钥经 `api-key` 保存到服务目录,绑定供应商官方根地址;密钥旁的闪电按钮调用 `scheduler-check`(`codiscuss launch scheduler-check`),向决策模型发一道是非题核对供应商、模型与密钥。启动请求只带 `scheduler: {provider, model, api_key_ref}`,`validate_config` 核对密钥存在后写入私有房间配置。只在本次由本机服务启动时可开启(自由房间至少有一位本机唤醒成员,讨论须勾选“创建后立即启动”),否则选项禁用并说明原因。目前只能在建房时开启,已建房间不能中途开启或关闭。
326
326
 
327
- `runner/scheduler.mjs` 用中立的题目(`choice` 带选项,`predicate` 为是非题)组装两家的请求,并把答案统一为 `{choice, confidence, probabilities}` 或 `{probability}`。上下文是房间标题、目标与用户原文、最近 10 条消息(每条截断到 600 字)和待处理的那条用户消息。把握程度取所选项的概率(缺失时才用 `confidence`;Jev 的两项选择 `confidence` 明显低于该概率,实测概率 0.82 时只有 0.64),阈值 0.8。请求 30 秒超时,错误只报 HTTP 状态,不含响应正文或密钥。
327
+ `runner/scheduler.mjs` 用中立的题目(`choice` 带选项,`predicate` 为是非题)组装两家的请求,并把答案统一为 `{choice, confidence, probabilities}` 或 `{probability}`。上下文是房间标题、目标与用户原文、最近 10 条消息(每条截断到 600 字)和待处理的那条用户消息。把握程度取所选项的概率(缺失时才用 `confidence`;Jev 的两项选择 `confidence` 明显低于该概率,实测概率 0.82 时只有 0.64),阈值 0.7。讨论中的判断(留言、下一位发言人、下一轮)另带当前焦点、主持简报(前 1200 字)、主持人最近的安排,下一位发言人还带本轮已发言/未发言名单,并多问一道是非题“是否有明确依据要求候选人发言”(简报分工或流程轮到、或被点名追问;仅“本轮还没发言”不算),低于 0.7 即交回主持人,因为主持人预排的安排用完可能是有意结束;选项描述写明各选项的适用条件,“交给主持人”只在偏离目标、卡住或候选都不合适时选。每次调用计时,毫秒数写入决策记录 detail 的 `duration_ms`,决策卡片显示“耗时”;主持人安排卡片的耗时为上一条回答到安排保存的间隔,两种卡片样式一致。请求 30 秒超时,错误只报 HTTP 状态,不含响应正文或密钥。
328
328
 
329
329
  - **自由房间(推荐,用户决定)**:未点名的消息照常交给主持人,判断在后台进行,不阻塞本次检查和任何投递。每条 `route` 请求只判断一次,选项为各位本机唤醒的非主持成员(描述为软件与项目读写权限)和“交回主持人”;只有选中成员且 `confidence ≥ 0.7` 时,才用 `POST recommendations` 记录推荐。网页在消息旁显示“智能调度建议让 B 回答”与 60 秒倒计时,用户可选“打断主持人,改由它回答”或“主持人答完后再回答”(主持人已答完时直接请 B 回答),过期后提示消失。判断失败、超时或置信度不足时不显示推荐,行为与未开启相同。
330
330
  - **讨论房间(有把握即执行)**:投递主持事件前先只读判断,置信度 ≥ 0.7 才执行,否则照常唤醒主持人:
package/docs/workflow.md CHANGED
@@ -61,7 +61,7 @@
61
61
 
62
62
  网页新建默认 3 轮、不限时长、单次执行不限时、到限暂停;收束预留默认 5 分钟。新建表单分别显示轮次和时长上限,点击数值才展开滚轮;鼠标滚轮逐格切换,停止滚动即停住,鼠标或手指在屏幕上拖动则保留松手惯性。选择即时生效,点击外部即可收起,无需确认。时长收起时显示小时及剩余分钟,展开后分为小时、分钟两列,按 1 小时、1 分钟选择;轻点各列中间数字可直接输入,启用时长上限后范围为 1–1440 分钟,24 小时时分钟固定为 0。拖动松手后先按阻尼减速,再按预计停止位置吸附;系统“减少动画”下仍保留拖动惯性,只去掉数字的三维翻转。两项各自提供“不限”,关闭“不限”恢复此前数值,首次启用时长上限为 1 小时,互不改动;下方实时说明哪个上限先到便触发所选动作。未传参数的 CLI 建房默认轮次、总时长与单次执行不限;旧房间保留已有设置。全自动支持不限总时长:按轮次上限或手动停止结束,重启不自动添加时长上限;主动设置的单次 AI 执行超时、租约和收束预留仍有效。CLI 可额外指定未来 24 小时内的运行截止,但不能延长话题已设的截止。
63
63
 
64
- “主持”及“接入”显示配置、已用/剩余时间、已完成轮次和到限状态。新专家安排及观点由服务端拒绝越界,原提交的幂等重试和用户留言保留。自动程序定期调用到限处理;手动主持每次原生检查时执行 `room limits ROOM --check --as 本人`,也可由用户点击界面中的检查按钮。仅打开页面不触发写入或启动 AI。
64
+ “主持”及“接入”显示配置、已用/剩余时间、已完成轮次和到限状态。新专家安排及观点由服务端拒绝越界,原提交的幂等重试和用户留言保留。自动程序定期调用到限处理;手动主持每次原生检查时执行 `room limits ROOM --check --as 本人`,也可由用户点击界面中的检查按钮。仅打开页面不触发写入或启动 AI。到限且方式为暂停时,服务端拒绝主持人直接把阶段设为 `concluded`,须先 `--check` 进入 `paused`;用户本人收束不受限。
65
65
 
66
66
  选择总结时,在轮次到限或进入预留时间后取消未完成安排、撤销旧自动执行并交回主持人;主持人只使用已有材料收束。提前达到轮次上限时,收束最多使用预留分钟数,且不越过总截止。总截止到达而总结未完成则暂停;页面到限提示不代表外部手动软件已停止。无总时长配置时,轮次到限后的总结仍受预留时长约束。
67
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.2.42",
3
+ "version": "0.2.43",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -363,7 +363,10 @@ export class DiscussionRunner {
363
363
  const experts = Object.keys(room.experts || {});
364
364
  // Only experts the runner can wake are offered to the decision model; manual ones wait for the host.
365
365
  const automatic = experts.filter(name => this.config.participants[name]);
366
- const context = { title: room.title, goal: room.goal, user_request: room.user_request, context: page.messages, host: room.host };
366
+ const last_plan = room.host_arrangements?.at(-1);
367
+ const context = { title: room.title, goal: room.goal, user_request: room.user_request, context: page.messages, host: room.host,
368
+ brief: room.host_brief?.text, focus: room.focus,
369
+ arrangement: last_plan?.turns.map(turn => `${turn.expert}:${String(turn.question).slice(0, 120)}`).join(';') };
367
370
  if (room.held_since) {
368
371
  const pending = page.messages.filter(item => item.id >= room.held_since && item.author === '用户');
369
372
  // Topic edits and several messages at once need the host's judgement.
@@ -392,7 +395,7 @@ export class DiscussionRunner {
392
395
  // An expert the user just skipped is not picked again unless nobody else is left.
393
396
  const skipped = room.current_turn?.status === 'cancelled' ? room.current_turn.expert : null;
394
397
  const candidates = waiting.filter(name => name !== skipped);
395
- return this.next_speaker_plan(room, context, candidates.length ? candidates : waiting, anchor);
398
+ return this.next_speaker_plan(room, { ...context, spoken: [...spoken] }, candidates.length ? candidates : waiting, anchor);
396
399
  }
397
400
  if (room.named_followup) return skip('round', '点名追问已开启,由主持人安排下一轮');
398
401
  const decision = await decide_round(this.config.api_home, this.config.scheduler,
@@ -13,6 +13,7 @@ const HOST_CHOICE = '__host__';
13
13
  const NOTE_CHOICE = '__note__';
14
14
  const CONTEXT_MESSAGES = 10;
15
15
  const MAX_MESSAGE_CHARS = 600;
16
+ const MAX_BRIEF_CHARS = 1200;
16
17
 
17
18
  /** Normalize a launch's scheduler choice and prove its key exists for that provider's root. */
18
19
  export function validate_scheduler(home, scheduler) {
@@ -50,13 +51,17 @@ export async function decide(home, scheduler, state, questions, fetch_impl = fet
50
51
  const key = read_saved_api_key(home, scheduler.base_url, scheduler.api_key_ref);
51
52
  const request = scheduler_request(scheduler, state, questions);
52
53
  let response;
54
+ const started = Date.now();
53
55
  try {
54
56
  response = await fetch_impl(`${scheduler.base_url}${request.path}`, { method: 'POST', redirect: 'error',
55
57
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
56
58
  body: JSON.stringify(request.body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
57
59
  } catch { throw new Error('智能调度请求超时或连接失败'); }
58
60
  if (!response.ok) throw new Error(api_status_message(response.status));
59
- return scheduler_answers(scheduler, await response.json());
61
+ const answers = scheduler_answers(scheduler, await response.json());
62
+ // Timing rides along without being an answer, so callers iterating answers by question name never see it.
63
+ Object.defineProperty(answers, 'elapsed_ms', { value: Date.now() - started });
64
+ return answers;
60
65
  }
61
66
 
62
67
  /** Send one tiny yes/no question so provider, model and key are checked together before a room relies on them. */
@@ -67,10 +72,19 @@ export async function scheduler_check(home, input, fetch_impl = fetch) {
67
72
  return { ok: true };
68
73
  }
69
74
 
70
- /** What was asked (the instruction and options only, never the room excerpts) and what the provider chose, for the folded record. */
75
+ /** What was asked (the instruction and options only, never the room excerpts), what the provider chose and how long the call took. */
71
76
  function detail_of(questions, answers) {
72
- return questions.map(question => ({ question: question.instructions, choices: question.choices.map(choice => choice.value),
73
- choice: answers[question.name]?.choice ?? null, probabilities: answers[question.name]?.probabilities ?? {} }));
77
+ return questions.map(question => {
78
+ const answer = answers[question.name];
79
+ // A yes/no question is recorded as a two-way choice so the card reads it like any other.
80
+ if (question.type === 'predicate') {
81
+ const yes = typeof answer?.probability === 'number' ? answer.probability : null;
82
+ return { question: question.instructions, choices: ['是', '否'], choice: yes === null ? null : yes >= 0.5 ? '是' : '否',
83
+ probabilities: yes === null ? {} : { 是: yes, 否: Number((1 - yes).toFixed(4)) }, duration_ms: answers.elapsed_ms ?? null };
84
+ }
85
+ return { question: question.instructions, choices: question.choices.map(choice => choice.value),
86
+ choice: answer?.choice ?? null, probabilities: answer?.probabilities ?? {}, duration_ms: answers.elapsed_ms ?? null };
87
+ });
74
88
  }
75
89
 
76
90
  /** Say which option the provider leaned to when it was not sure enough, so the transcript shows how close it was. */
@@ -85,14 +99,16 @@ function excerpt(message) {
85
99
  }
86
100
 
87
101
  /** The room's purpose in the user's words, so a decision can tell drifting from progress. */
88
- function topic_lines(title, goal, user_request) {
89
- return [`房间:${title || ''}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`].filter(Boolean).join('\n');
102
+ function topic_lines(title, goal, user_request, { brief, focus, arrangement, progress } = {}) {
103
+ return [`房间:${title || ''}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
104
+ focus && `当前焦点:${focus}`, brief && `主持简报(含分工与规则):${String(brief).slice(0, MAX_BRIEF_CHARS)}`,
105
+ arrangement && `主持人最近的安排:${arrangement}`, progress].filter(Boolean).join('\n');
90
106
  }
91
107
 
92
108
  /** State shared by both uses: the room, a few earlier messages, and the message to place. */
93
- export function scheduler_state(title, context, message, goal = '', user_request = '') {
109
+ export function scheduler_state(title, context, message, goal = '', user_request = '', extra = {}) {
94
110
  const earlier = context.filter(item => item.id < message.id).slice(-CONTEXT_MESSAGES).map(excerpt);
95
- return [topic_lines(title, goal, user_request), earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
111
+ return [topic_lines(title, goal, user_request, extra), earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
96
112
  }
97
113
 
98
114
  /**
@@ -123,12 +139,12 @@ export async function route_free_message(home, scheduler, { title, goal, context
123
139
  * Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
124
140
  * Anything but a confident note or ask is returned as host.
125
141
  */
126
- export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts }, fetch_impl) {
127
- const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言` })),
128
- { value: HOST_CHOICE, description: `由主持人 ${host} 处理:调整目标、方向、安排,或需要主持人决定` },
129
- { value: NOTE_CHOICE, description: '仅补充信息:不需要专人回应,按原计划继续' }];
130
- const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。结合讨论目标判断它最适合怎样处理。', choices }];
131
- const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request), questions, fetch_impl);
142
+ export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, ...extra }, fetch_impl) {
143
+ const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言:留言是向该专家(或其负责的内容)提问、质疑或补充要求` })),
144
+ { value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,或同时涉及多位专家` },
145
+ { value: NOTE_CHOICE, description: '仅补充信息:留言只是旁注、信息或鼓励,不要求任何人回应,按当前安排继续' }];
146
+ const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。', choices }];
147
+ const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request, extra), questions, fetch_impl);
132
148
  const detail = detail_of(questions, answers);
133
149
  const choice = answers.handling?.choice;
134
150
  const confidence = certainty(answers.handling);
@@ -143,20 +159,28 @@ export async function decide_interjection(home, scheduler, { title, goal, user_r
143
159
  * Mid-round with no queued turn left: pick the next speaker among the experts who can be woken and have not spoken
144
160
  * this round, or hand back to the host when unsure or when the discussion needs steering.
145
161
  */
146
- export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates }, fetch_impl) {
162
+ export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates, spoken = [], ...extra }, fetch_impl) {
147
163
  const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
148
- const state = [topic_lines(title, goal, user_request), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
149
- const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言` })),
150
- { value: HOST_CHOICE, description: `交给主持人 ${host}:讨论偏离目标、卡住,或需要主持人调整安排` }];
164
+ const progress = `本轮已发言:${spoken.join('、') || '无'};本轮尚未发言:${candidates.join('、')}。`;
165
+ const state = [topic_lines(title, goal, user_request, { ...extra, progress }), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
166
+ const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言:简报的分工或接龙顺序轮到 ${name},或刚才的发言点名追问了 ${name}` })),
167
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:没有明确该谁发言,或需要主持人重新安排` }];
168
+ // Having not spoken yet is no reason to speak: the host's plan may have ended on purpose, so the scheduler acts only on an explicit obligation.
151
169
  const questions = [{ type: 'choice', name: 'speaker',
152
- instructions: '本轮还有专家没发言,安排被取消或用完。结合已有发言判断下一位应请谁发言:优先选被点名追问、最能回应当前焦点的人。', choices }];
170
+ instructions: '主持人预排的安排已用完或被取消。对照主持简报的分工、当前焦点和主持人最近的安排,判断下一位应请谁发言。', choices },
171
+ { type: 'predicate', name: 'obligation',
172
+ instructions: `是否存在明确依据要求 ${candidates.join('、')} 之一接着发言:简报分工或流程规定轮到其人,或最近发言点名追问了其人?仅仅是“本轮还没发言”不算依据。` }];
153
173
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
154
174
  const detail = detail_of(questions, answers);
155
175
  const choice = answers.speaker?.choice;
156
176
  const confidence = certainty(answers.speaker);
157
- if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE) {
177
+ const obligation = answers.obligation?.probability;
178
+ if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE && obligation >= ROUTE_CONFIDENCE) {
158
179
  return { action: 'next', expert: choice, confidence, detail, label: `本轮继续,请 ${choice} 接着发言` };
159
180
  }
181
+ if (typeof obligation === 'number' && obligation < ROUTE_CONFIDENCE) {
182
+ return { action: 'host', confidence, detail, label: '没有明确该谁接着发言的依据,交给主持人安排' };
183
+ }
160
184
  return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && confidence >= ROUTE_CONFIDENCE ? '建议由主持人调整安排,交给主持人' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
161
185
  }
162
186
 
@@ -164,14 +188,14 @@ export async function decide_next_speaker(home, scheduler, { title, goal, user_r
164
188
  * At the end of a round without named follow-ups: continue with another peer round, or wake the host
165
189
  * (to conclude, or because the discussion drifted, repeats itself or is stuck). The first speaker is never the last one.
166
190
  */
167
- export async function decide_round(home, scheduler, { title, goal, user_request, context, host, experts, last_speaker }, fetch_impl) {
191
+ export async function decide_round(home, scheduler, { title, goal, user_request, context, host, experts, last_speaker, ...extra }, fetch_impl) {
168
192
  const candidates = experts.filter(name => name !== last_speaker);
169
193
  const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
170
- const state = [topic_lines(title, goal, user_request), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
171
- const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。结合讨论目标判断:是否值得让大家继续下一轮,互相回应、追问或补充?', choices: [
172
- { value: 'continue', description: '继续下一轮:还有可以互相回应、追问、补充或展开的内容' },
173
- { value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或讨论偏离目标、反复重复、卡住,需要主持人引导` }] }];
174
- if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?优先选被其他专家点名追问、最需要回应的人。',
194
+ const state = [topic_lines(title, goal, user_request, extra), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
195
+ const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。对照讨论目标、主持简报与当前焦点判断:下一轮是否还有明确的内容可以让大家互相回应、追问或补充?', choices: [
196
+ { value: 'continue', description: '继续下一轮:最近发言里有未回应的分歧、追问或可展开的点,或简报规定的流程还没走完' },
197
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或最近发言在重复、偏离目标、卡住,需要主持人引导` }] }];
198
+ if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?按简报分工或接龙顺序判断;没有规定时选被其他专家点名追问、最需要回应的人。',
175
199
  choices: candidates.map(name => ({ value: name, description: `先请 ${name}` })) });
176
200
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
177
201
  const detail = detail_of(questions, answers);