codebee 0.1.28 → 0.1.30

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.
@@ -11,6 +11,7 @@ CAPABILITY = {
11
11
  }
12
12
 
13
13
  MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
14
+ AUTO_CRITIC_LIMIT = 2 # 自动内容评审只取综合分前两名;手工模式仍尊重完整名单
14
15
 
15
16
 
16
17
  def _task_type(ttype):
@@ -62,7 +63,9 @@ def _online_bonus(agent, role, ttype):
62
63
  rate = float(metrics.get("success_rate") or 0.0)
63
64
  success_score = max(-8.0, min(8.0, (rate - 0.75) * 24.0))
64
65
  p95 = max(0.0, float(metrics.get("p95_duration_s") or 0.0))
65
- latency_score = -min(6.0, max(0.0, (p95 - 45.0) / 10.0))
66
+ # 旧上限 -6 抵不过 CLI 静态能力 10 分差,P95 两三分钟的模型仍会
67
+ # 压过十几秒的健康模型。加大惩罚,让真实延迟足以改变自动选择。
68
+ latency_score = -min(18.0, max(0.0, (p95 - 30.0) / 7.0))
66
69
  cost = max(0.0, float(metrics.get("avg_cost_usd") or 0.0))
67
70
  cost_score = -min(4.0, max(0.0, (cost - 0.01) / 0.01))
68
71
  total = round(success_score + latency_score + cost_score, 1)
@@ -199,14 +202,18 @@ def pick_reviewer(agents, impl, ttype, stats=None):
199
202
  return impl, "(自评:仅有实现者一个智能体可用)"
200
203
 
201
204
 
202
- def pick_critics(agents, ttype, stats=None, impl=None):
203
- """小说评审组:全部真实智能体(排除作者本人——自评是最典型的同族盲区);
204
- 面板与作者同厂商时在路由依据里明示,供用户决定是否补充其他厂商。
205
- 没有任何真实智能体时用内置 mock。"""
206
- stats = stats or history.agent_stats()
207
- real = [a for a in agents if a.get("mode") == "real" and (not impl or a["id"] != impl["id"])]
208
- if real:
209
- note = "全部真实智能体参与(按历史表现自动选择)"
205
+ def pick_critics(agents, ttype, stats=None, impl=None):
206
+ """自动内容评审组选综合分前两名(排除作者本人)。
207
+
208
+ 旧逻辑让全部已启用 CLI 串行参与每章和全局评审;一条慢/失效链就会把
209
+ 任务拖长数分钟。两名保留交叉判断并封顶默认调用数;手工模式不走这里。
210
+ """
211
+ stats = stats or history.agent_stats()
212
+ real = [a for a in agents if a.get("mode") == "real" and (not impl or a["id"] != impl["id"])]
213
+ if real:
214
+ ranked = sorted(real, key=lambda a: score(a, "review", ttype, stats)[0], reverse=True)
215
+ real = ranked[:AUTO_CRITIC_LIMIT]
216
+ note = "按成功率与延迟选择前 %d 名评审(共 %d 名候选)" % (len(real), len(ranked))
210
217
  if impl and impl.get("mode") == "real" \
211
218
  and all(a.get("kind") == impl.get("kind") for a in real):
212
219
  note += ";评审组与作者同为 %s,建议启用其他厂商 CLI 交叉评审" % impl.get("kind")
@@ -58,13 +58,52 @@ def find_git_bash():
58
58
  return None
59
59
 
60
60
 
61
- def resolve_command(command):
61
+ def resolve_command(command):
62
62
  """命令名 → 可直接 spawn 的 argv 前缀(.cmd/.bat 垫片必须经 cmd /c)。"""
63
63
  path = shutil.which(command) or command
64
64
  low = path.lower()
65
65
  if low.endswith(".cmd") or low.endswith(".bat"):
66
66
  return ["cmd", "/c", path]
67
- return [path]
67
+ return [path]
68
+
69
+
70
+ def _git_repo_issue(workdir):
71
+ """返回工作目录 Git 仓库的可读性问题;无 Git 仓库时返回空串。
72
+
73
+ Aider 会在启动后自行读取索引。索引包缺失时它通常先写入 .gitignore,
74
+ 再在深层调用中才报错,用户会误以为任务卡住;提前做一次廉价预检能把
75
+ 错误变成可操作的任务失败信息。
76
+ """
77
+ if not workdir or not os.path.isdir(workdir):
78
+ return ""
79
+ git_dir = os.path.join(workdir, ".git")
80
+ if not os.path.exists(git_dir):
81
+ return ""
82
+ try:
83
+ probe = subprocess.run(
84
+ ["git", "-C", workdir, "rev-parse", "--git-dir"],
85
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
86
+ text=True, timeout=5, check=False,
87
+ )
88
+ except (OSError, subprocess.SubprocessError):
89
+ return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
90
+ if probe.returncode != 0:
91
+ detail = (probe.stderr or probe.stdout or "仓库元数据不可读").strip()
92
+ return "Git 仓库不可读:%s;请先执行 git fsck/恢复 .git 对象后重试" % detail[-300:]
93
+ try:
94
+ probe = subprocess.run(
95
+ ["git", "-C", workdir, "status", "--porcelain", "--untracked-files=no"],
96
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
97
+ text=True, timeout=8, check=False,
98
+ )
99
+ except subprocess.TimeoutExpired:
100
+ return "Git 仓库检查超时;请先执行 git status/git fsck 修复后重试"
101
+ except OSError:
102
+ return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
103
+ if probe.returncode != 0:
104
+ detail = (probe.stderr or probe.stdout or "仓库对象或索引不可读").strip()
105
+ return "Git 仓库损坏或对象缺失:%s;请先执行 git fsck 并恢复仓库后重试" % detail[-300:]
106
+ return ""
68
107
 
69
108
 
70
109
  def _npm_shim_bypass(argv):
@@ -844,8 +883,9 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
844
883
  stdin_text = None
845
884
  tmp_files = []
846
885
  imgs = [str(p) for p in (images or []) if p]
847
- if kind == "codex":
848
- cp = agent.get("codex_provider")
886
+ if kind == "codex":
887
+ cp = agent.get("codex_provider")
888
+ reasoning = str(agent.get("reasoning_effort") or "").strip().lower()
849
889
  if sid:
850
890
  # resume 子命令不支持 -s 也不支持 --full-auto(0.154 实测:
851
891
  # "unexpected argument '--full-auto'")——读/写模式都用 -c sandbox_mode
@@ -855,16 +895,18 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
855
895
  if model:
856
896
  argv += ["-m", model]
857
897
  argv += ["-c", 'sandbox_mode="%s"' % _codex_sandbox(readonly)]
858
- if cp:
859
- argv += _codex_provider_args(cp)
898
+ if cp:
899
+ argv += _codex_provider_args(cp)
860
900
  else:
861
901
  argv = resolve_command(agent["command"]) + [
862
902
  "exec", "--skip-git-repo-check", "--json",
863
903
  "-s", _codex_sandbox(readonly)]
864
904
  if model:
865
905
  argv += ["-m", model]
866
- if cp:
867
- argv += _codex_provider_args(cp)
906
+ if cp:
907
+ argv += _codex_provider_args(cp)
908
+ if reasoning in ("low", "medium", "high", "xhigh"):
909
+ argv += ["-c", 'model_reasoning_effort="%s"' % reasoning]
868
910
  # codex exec 与 exec resume 都支持 -i:图片直接附到 prompt(exec resume 的
869
911
  # -i 附在恢复后发送的首条消息上,即本次 stdin prompt)
870
912
  for p in imgs:
@@ -912,10 +954,11 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
912
954
  if model:
913
955
  argv += ["-m", model]
914
956
  stdin_text = prompt
915
- elif kind == "aider":
916
- argv = resolve_command(agent["command"]) + [
917
- "--yes-always", "--no-auto-commits", "--no-check-update",
918
- # 网关自定义模型名(glm-5.1 等)litellm 全都不认识,警告页+建议列表
957
+ elif kind == "aider":
958
+ argv = resolve_command(agent["command"]) + [
959
+ "--yes-always", "--no-auto-commits", "--no-check-update",
960
+ "--no-gitignore", "--no-pretty", "--no-stream",
961
+ # 网关自定义模型名(glm-5.1 等)litellm 全都不认识,警告页+建议列表
919
962
  # 纯属刷屏(2026-09-20 实测占满步骤日志头部)
920
963
  "--no-show-model-warnings", "--message", prompt]
921
964
  if model:
@@ -987,7 +1030,7 @@ def _stall_timeout(env_name, default):
987
1030
  return default
988
1031
 
989
1032
 
990
- def run_agent(agent, prompt, workdir=None, readonly=True,
1033
+ def run_agent(agent, prompt, workdir=None, readonly=True,
991
1034
  timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
992
1035
  images=None, require_tools=False):
993
1036
  """执行一次智能体调用,返回统一结构
@@ -1010,7 +1053,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1010
1053
  orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
1011
1054
  if orch_timeout_ms:
1012
1055
  timeout = float(orch_timeout_ms) / 1000.0
1013
- kind = agent.get("kind", "generic")
1056
+ kind = agent.get("kind", "generic")
1057
+ if kind == "aider":
1058
+ repo_issue = _git_repo_issue(workdir)
1059
+ if repo_issue:
1060
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
1061
+ "tokens": 0, "usage": None, "error": repo_issue,
1062
+ "error_code": ErrorCode.VENDOR_ERROR, "raw": None,
1063
+ "kind": kind, "model": agent.get("model")}
1014
1064
  if kind == "codex":
1015
1065
  stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
1016
1066
  elif kind == "claude":
@@ -176,10 +176,17 @@ def apply_upgrade(port=None):
176
176
  _PENDING_PORT = int(port) if port else None
177
177
  except (TypeError, ValueError):
178
178
  _PENDING_PORT = None
179
- if install_mode() != "npm":
180
- return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
181
- from . import store, jobs
182
- try:
179
+ if install_mode() != "npm":
180
+ return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
181
+ from . import store, jobs
182
+ capacity = jobs.capacity_status()
183
+ if not capacity["accepting"]:
184
+ if capacity["restarting"]:
185
+ return {"error": "服务正在完成升级重启,请稍后再试"}
186
+ return {"error": "当前有 %d 个任务运行,已达并发保护上限(%d);"
187
+ "升级未启动且不会排队,请在任务结束后重试"
188
+ % (capacity["active"], capacity["limit"])}
189
+ try:
183
190
  run = store.create_run(
184
191
  "mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
185
192
  entry_id="__self__", op="selfupgrade")
@@ -188,16 +195,23 @@ def apply_upgrade(port=None):
188
195
  return {"error": "升级任务创建失败,请稍后重试"}
189
196
  try:
190
197
  jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
191
- except Exception:
192
- # run 已持久化;启动失败时显式收口,版本页不能停在误导性的待启动状态。
193
- log.exception("selfupdate: 升级任务启动失败 run=%s", run["id"])
194
- try:
195
- store.update_run(run["id"], status="failed",
196
- error="升级任务启动失败,本次未排队,请稍后重试",
197
- ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
198
+ except Exception as exc:
199
+ # run 已持久化;启动失败时显式收口,版本页不能停在误导性的待启动状态。
200
+ if isinstance(exc, jobs.JobsBusyError):
201
+ message = "升级未启动:%s" % str(exc)
202
+ log.warning("selfupdate: 升级任务因并发满载未启动 run=%s: %s",
203
+ run["id"], exc)
204
+ else:
205
+ log.exception("selfupdate: 升级任务启动失败 run=%s", run["id"])
206
+ # 仅并发保护类错误可直接展示;其他内部异常继续隐藏实现细节。
207
+ message = "升级任务启动失败,本次未排队,请稍后重试"
208
+ try:
209
+ store.update_run(run["id"], status="failed",
210
+ error=message,
211
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
198
212
  except Exception:
199
213
  log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
200
- return {"error": "升级任务启动失败,本次未排队,请稍后重试", "run_id": run["id"]}
214
+ return {"error": message, "run_id": run["id"]}
201
215
  return {"run_id": run["id"]}
202
216
 
203
217
 
package/app/core/store.py CHANGED
@@ -104,12 +104,12 @@ def create_task(payload):
104
104
  if not wd.is_dir():
105
105
  raise ValueError("工作目录不存在: %s" % workdir)
106
106
  mode = payload.get("mode")
107
- if mode not in ("auto", "manual"):
108
- mode = "manual" if payload.get("implementer") else "auto"
107
+ if mode not in ("auto", "fast", "expert", "manual"):
108
+ mode = "manual" if payload.get("implementer") else "auto"
109
109
  difficulty = payload.get("difficulty")
110
110
  if difficulty not in ("auto", "easy", "hard", "default"):
111
111
  difficulty = "auto"
112
- task = {
112
+ task = {
113
113
  "id": _new_id("t"), "type": flow["id"], "engine": flow["engine"],
114
114
  "title": title, "goal": goal,
115
115
  "context": _text(payload.get("context"), "context"),
@@ -119,8 +119,16 @@ def create_task(payload):
119
119
  "implementer": payload.get("implementer") or "",
120
120
  "attachments": [],
121
121
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
122
- "status": "created",
123
- }
122
+ "status": "created",
123
+ }
124
+ thinking = str(payload.get("thinking") or "standard").strip().lower()
125
+ task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
126
+ if flow["engine"] == "direct":
127
+ task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
128
+ "direct_provider_id")[:80]
129
+ task["direct_model"] = _text(payload.get("direct_model"), "direct_model")[:160]
130
+ if task["direct_model"] and not task["direct_provider_id"]:
131
+ raise ValueError("指定对话模型时必须同时指定厂商")
124
132
  # 代码版本:仅当引用合法才固化(流水线执行前据此检出任务分支)
125
133
  from . import gitmod
126
134
  git_rev = _text(payload.get("git_rev"), "git_rev")
@@ -133,8 +141,8 @@ def create_task(payload):
133
141
  task["git_rev"] = git_rev
134
142
  if flow["engine"] == "code":
135
143
  task["verify_command"] = _text(payload.get("verify_command"), "verify_command")
136
- elif flow["engine"] == "direct":
137
- pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
144
+ elif flow["engine"] == "direct":
145
+ pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
138
146
  else:
139
147
  ms = _text(payload.get("manuscript") or flow.get("manuscript") or "manuscript.md",
140
148
  "manuscript")
@@ -478,7 +486,7 @@ def load_all():
478
486
 
479
487
  # ---------------------------------------------------------------- 运行(含管理操作)
480
488
 
481
- def create_run(kind, title, task_id=None, entry_id=None, op=None):
489
+ def create_run(kind, title, task_id=None, entry_id=None, op=None):
482
490
  run = {
483
491
  "id": _new_id("r" if kind == "orchestration" else "m"),
484
492
  "kind": kind, # orchestration | mgmt
@@ -488,8 +496,21 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
488
496
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
489
497
  "started_at": None, "ended_at": None,
490
498
  "cost_usd": 0.0, "tokens": 0, "error": "",
491
- "verdict": None, "summary": "",
492
- }
499
+ "verdict": None, "summary": "",
500
+ }
501
+ if kind == "orchestration" and task_id:
502
+ task = get_task(task_id) or {}
503
+ try:
504
+ from . import usage
505
+ eta = usage.estimate(
506
+ task_type=task.get("type") or "", mode=task.get("mode") or "auto",
507
+ thinking=task.get("thinking") or "standard",
508
+ rounds=task.get("rounds"))
509
+ run["estimated_duration_s"] = eta.get("estimated_duration_s")
510
+ run["estimated_p90_s"] = eta.get("p90_duration_s")
511
+ run["estimate_source"] = eta.get("duration_source")
512
+ except Exception:
513
+ pass
493
514
  rdir = paths.RUNS_DIR / run["id"]
494
515
  (rdir / "steps").mkdir(parents=True, exist_ok=True)
495
516
  with LOCK:
@@ -814,12 +835,36 @@ def run_workdir(run_id):
814
835
  _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}
815
836
 
816
837
  # 构建产物/依赖缓存目录:里面的文件是工具链再生成的,不是任务成品
817
- _BUILD_DIRS = {
838
+ _BUILD_DIRS = {
818
839
  "target", "build", "dist", "out", "bin", "obj", # 通用构建输出
819
840
  "surefire", "failsafe-reports", "test-output", "reports", # 测试/报告输出
820
841
  ".next", ".nuxt", ".output", ".gradle", ".gradle-home", # 前端/Gradle
821
842
  "__MACOSX",
822
- }
843
+ }
844
+
845
+ # CLI 自身的历史/评审中间件不属于用户成果。它们通常落在工作目录根部,
846
+ # 只按隐藏目录过滤会漏掉 Aider 的 .aider.* 与流程生成的 *_review.json。
847
+ _PROCESS_ARTIFACT_NAMES = {
848
+ ".aider.chat.history.md", ".aider.input.history", ".aider.input.history.md",
849
+ ".aider.tags.cache.v4", ".aider.tags.cache.v3", ".aider.conf.yml",
850
+ }
851
+ _PROCESS_ARTIFACT_SUFFIXES = ("_review.json", ".review.json")
852
+ _PROCESS_ARTIFACT_PREFIXES = ("tutti_prompt_", "_tutti_prompt_")
853
+
854
+
855
+ def _is_process_artifact(rel_name):
856
+ """判断工作目录文件是否为 CLI/评审过程产物,而非可交付成果。"""
857
+ name = str(rel_name or "").replace("\\", "/")
858
+ base = name.rsplit("/", 1)[-1].lower()
859
+ if base in _PROCESS_ARTIFACT_NAMES:
860
+ return True
861
+ if base.startswith(".") and base.startswith(".aider"):
862
+ return True
863
+ if base.endswith(_PROCESS_ARTIFACT_SUFFIXES):
864
+ return True
865
+ if base.startswith(_PROCESS_ARTIFACT_PREFIXES):
866
+ return True
867
+ return False
823
868
 
824
869
 
825
870
  def task_first_start(task_id, fallback=""):
@@ -880,7 +925,10 @@ def run_artifacts(run_id, limit=200):
880
925
  continue
881
926
  if st.st_mtime < t0:
882
927
  continue
883
- files.append({"name": str(p.relative_to(root)).replace("\\", "/"),
928
+ rel_name = str(p.relative_to(root)).replace("\\", "/")
929
+ if _is_process_artifact(rel_name):
930
+ continue
931
+ files.append({"name": rel_name,
884
932
  "size": st.st_size, "mtime": int(st.st_mtime)})
885
933
  if len(files) >= 800: # 防超大目录拖垮接口;截断后再排序取最新
886
934
  break
@@ -69,6 +69,9 @@ def compile_task(task):
69
69
  "dimension": dimension,
70
70
  "difficulty": difficulty,
71
71
  "difficulty_reason": difficulty_reason,
72
+ "thinking": (str(raw.get("thinking") or "standard").lower()
73
+ if str(raw.get("thinking") or "standard").lower()
74
+ in ("auto", "low", "standard", "high") else "auto"),
72
75
  "capabilities": _capabilities(raw, dimension, engine),
73
76
  "deliverable": deliverable,
74
77
  "quality_dimensions": rubric,
@@ -85,3 +88,111 @@ def summary(spec):
85
88
  spec.get("type") or "direct", spec.get("engine") or "direct",
86
89
  spec.get("difficulty") or "default",
87
90
  "、".join(spec.get("capabilities") or []) or "通用")
91
+
92
+
93
+ def code_workflow(task, difficulty, plan=None, mode="auto", planned=True):
94
+ """把难度、验收能力和规划建议收敛成代码任务的实际步骤策略。
95
+
96
+ 自动模式允许低风险任务走短链;手动模式与高风险任务保留完整质量门禁。
97
+ 规划器只能在安全边界内减少步骤,不能让无确定性验证的任务跳过评审。
98
+ """
99
+ task = task if isinstance(task, Mapping) else {}
100
+ rec = (plan or {}).get("workflow") if isinstance(plan, Mapping) else None
101
+ rec = rec if isinstance(rec, Mapping) else {}
102
+ has_verify = bool(str(task.get("verify_command") or "").strip())
103
+ risk_text = " ".join(str(task.get(k) or "") for k in ("title", "goal", "context")).lower()
104
+ high_risk = bool(any(k in risk_text for k in (
105
+ "安全", "权限", "鉴权", "认证", "加密", "密钥", "注入", "迁移", "数据库",
106
+ "并发", "竞态", "支付", "public api", "公共 api", "breaking change",
107
+ "security", "permission", "auth", "migration", "concurrency")))
108
+ fast_mode = mode == "fast"
109
+ expert_mode = mode == "expert"
110
+ easy_auto = (mode == "auto" and difficulty == "easy") or fast_mode
111
+
112
+ review_required = True
113
+ max_repairs = 2
114
+ allow_switch = True
115
+ reason = "复杂或手动任务使用完整质量门禁"
116
+ if easy_auto:
117
+ review_required = high_risk or not has_verify
118
+ max_repairs = 1
119
+ allow_switch = False
120
+ reason = ("低风险任务以确定性验证代替模型评审"
121
+ if has_verify else "低风险任务无验证命令,保留一次模型评审")
122
+ if isinstance(rec.get("review_required"), bool):
123
+ review_required = high_risk or rec["review_required"] or not has_verify
124
+ try:
125
+ if "max_repair_rounds" in rec:
126
+ max_repairs = max(1 if not has_verify else 0,
127
+ min(1, int(rec["max_repair_rounds"])))
128
+ except (TypeError, ValueError):
129
+ pass
130
+ elif expert_mode or (mode == "auto" and difficulty == "hard"):
131
+ review_required = True
132
+ max_repairs = 2
133
+ allow_switch = True
134
+ try:
135
+ if "max_repair_rounds" in rec:
136
+ max_repairs = max(1, min(2, int(rec["max_repair_rounds"])))
137
+ except (TypeError, ValueError):
138
+ pass
139
+ if isinstance(rec.get("allow_switch"), bool):
140
+ allow_switch = rec["allow_switch"]
141
+ reason = "复杂任务保留规划、验证与模型评审"
142
+
143
+ return {
144
+ "planning": "llm" if planned else "inline",
145
+ "implementation_steps": len((plan or {}).get("steps") or []) or 1,
146
+ "verification": has_verify,
147
+ "review_required": review_required,
148
+ "reviewers": 1 if review_required else 0,
149
+ "max_repair_rounds": max_repairs,
150
+ "allow_switch": allow_switch,
151
+ "reason": reason,
152
+ }
153
+
154
+
155
+ def content_workflow(task, difficulty, plan=None, mode="auto"):
156
+ """为非连载内容任务生成动态大纲/评审/修订策略。"""
157
+ task = task if isinstance(task, Mapping) else {}
158
+ ttype = str(task.get("type") or "doc")
159
+ requested_rounds = max(1, min(5, int(task.get("rounds") or 2)))
160
+ rec = (plan or {}).get("workflow") if isinstance(plan, Mapping) else None
161
+ rec = rec if isinstance(rec, Mapping) else {}
162
+ light_types = {"email", "weekly_report", "translation"}
163
+ deep_types = {"novel", "research", "tech_proposal"}
164
+
165
+ if mode == "fast":
166
+ return {"outline": False, "reviewers": 1, "review_rounds": 1,
167
+ "reason": "快速模式省略独立大纲,仅保留一次快速评审"}
168
+ if mode == "expert":
169
+ return {"outline": True, "reviewers": 2,
170
+ "review_rounds": min(requested_rounds, 3),
171
+ "reason": "专家模式启用完整大纲、多评审与修订门禁"}
172
+ if mode != "auto":
173
+ return {"outline": True, "reviewers": 0,
174
+ "review_rounds": requested_rounds,
175
+ "reason": "手动模式尊重用户指定的评审组与轮数"}
176
+
177
+ if ttype in light_types and difficulty != "hard":
178
+ return {"outline": False, "reviewers": 1, "review_rounds": 1,
179
+ "reason": "轻量内容省略独立大纲,只做一次快速评审"}
180
+
181
+ outline = ttype in deep_types or difficulty == "hard"
182
+ reviewers = 2 if outline else 1
183
+ review_rounds = min(requested_rounds, 2 if outline else 1)
184
+ try:
185
+ if "reviewers" in rec:
186
+ reviewers = max(1, min(2, int(rec["reviewers"])))
187
+ if "review_rounds" in rec:
188
+ review_rounds = max(1, min(requested_rounds, 3,
189
+ int(rec["review_rounds"])))
190
+ except (TypeError, ValueError):
191
+ pass
192
+ # 深度类型至少保留一轮评审,但允许规划器把双评审降成一名;高门槛强制双评审。
193
+ if float(task.get("threshold") or 7.0) >= 8.5:
194
+ reviewers = 2
195
+ return {"outline": outline, "reviewers": reviewers,
196
+ "review_rounds": review_rounds,
197
+ "reason": ("深度内容保留大纲和多轮质量门禁" if outline
198
+ else "标准内容使用单评审短链")}
package/app/core/usage.py CHANGED
@@ -655,11 +655,19 @@ def summary(days=30, recent_limit=30):
655
655
  }
656
656
 
657
657
 
658
- def estimate(task_type="", days=90):
659
- """同类任务开跑前成本预估(借鉴 omnigent 的 pre-run estimate;数据源就是本台账)。
660
-
661
- 同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
662
- task_type 为空=全类型。无样本时 samples=0,前端不展示。"""
658
+ _DURATION_BASELINES = {
659
+ "direct": 90, "rank_scan": 240, "email": 180, "weekly_report": 240,
660
+ "translation": 240, "speech": 360, "video_script": 360, "article": 480,
661
+ "doc": 480, "resume": 420, "code": 600, "tech_proposal": 720,
662
+ "research": 900, "novel": 720, "serial_novel": 2400,
663
+ }
664
+
665
+
666
+ def estimate(task_type="", days=90, mode="auto", thinking="standard", rounds=None):
667
+ """同类任务开跑前成本与耗时预估(数据源为本地用量台账)。
668
+
669
+ 同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
670
+ 无历史样本时使用流程级保守基线;模式与思考程度只做透明倍率修正。"""
663
671
  span = max(1, min(3650, int(days or 90)))
664
672
  with LOCK:
665
673
  records = _iter_records(span)
@@ -671,24 +679,53 @@ def estimate(task_type="", days=90):
671
679
  rid = str(r.get("run_id") or "")
672
680
  if not rid:
673
681
  continue
674
- g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0, "ok": False})
675
- g["tokens"] += _num(r, "total")
676
- g["cost"] += _parse_float(r.get("cost_usd"))
682
+ g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0,
683
+ "duration": 0.0, "ok": False})
684
+ g["tokens"] += _num(r, "total")
685
+ g["cost"] += _parse_float(r.get("cost_usd"))
686
+ g["duration"] += max(0.0, _parse_float(r.get("duration_s")))
677
687
  if r.get("ok"):
678
688
  g["ok"] = True
679
689
  ok_runs = [g for g in runs.values() if g["ok"]]
680
690
  basis = ok_runs or list(runs.values())
681
- if not basis:
682
- return {"task_type": task_type or "", "days": span, "samples": 0}
691
+ mode_factor = {"fast": 0.60, "auto": 1.0, "expert": 1.65,
692
+ "manual": 1.0}.get(str(mode or "auto"), 1.0)
693
+ thinking_factor = {"low": 0.78, "standard": 1.0, "high": 1.45,
694
+ "auto": 1.0}.get(str(thinking or "standard"), 1.0)
695
+ factor = mode_factor * thinking_factor
696
+ try:
697
+ if rounds is not None and int(rounds) > 2:
698
+ factor *= 1.0 + min(3, int(rounds) - 2) * 0.18
699
+ except (TypeError, ValueError):
700
+ pass
701
+ if not basis:
702
+ baseline = _DURATION_BASELINES.get(task_type, 480)
703
+ median_s = max(30, int(baseline * factor))
704
+ return {"task_type": task_type or "", "days": span, "samples": 0,
705
+ "estimated_duration_s": median_s,
706
+ "p90_duration_s": max(median_s + 30, int(median_s * 1.7)),
707
+ "duration_source": "baseline"}
683
708
  toks = sorted(g["tokens"] for g in basis)
684
- costs = sorted(g["cost"] for g in basis)
709
+ costs = sorted(g["cost"] for g in basis)
710
+ durations = sorted(g["duration"] for g in basis if g["duration"] > 0)
685
711
  n = len(toks)
686
712
  mid = n // 2
687
713
  med = toks[mid] if n % 2 else (toks[mid - 1] + toks[mid]) / 2.0
688
714
  med_c = costs[mid] if n % 2 else (costs[mid - 1] + costs[mid]) / 2.0
689
715
  import datetime
690
- import math
691
- return {
716
+ import math
717
+ if durations:
718
+ dn = len(durations)
719
+ dmid = dn // 2
720
+ dmed = (durations[dmid] if dn % 2
721
+ else (durations[dmid - 1] + durations[dmid]) / 2.0)
722
+ dp90 = durations[max(0, min(dn - 1, math.ceil(0.9 * dn) - 1))]
723
+ duration_source = "history"
724
+ else:
725
+ dmed = _DURATION_BASELINES.get(task_type, 480)
726
+ dp90 = dmed * 1.7
727
+ duration_source = "baseline"
728
+ return {
692
729
  "task_type": task_type or "",
693
730
  "days": span,
694
731
  "samples": n,
@@ -697,6 +734,10 @@ def estimate(task_type="", days=90):
697
734
  "median_tokens": int(med),
698
735
  "p90_tokens": toks[max(0, min(n - 1, math.ceil(0.9 * n) - 1))],
699
736
  "avg_cost_usd": round(sum(costs) / n, 4),
700
- "median_cost_usd": round(med_c, 4),
737
+ "median_cost_usd": round(med_c, 4),
738
+ "estimated_duration_s": max(30, int(dmed * factor)),
739
+ "p90_duration_s": max(60, int(dp90 * factor)),
740
+ "duration_samples": len(durations),
741
+ "duration_source": duration_source,
701
742
  "since": (datetime.date.today() - datetime.timedelta(days=span - 1)).isoformat(),
702
743
  }