feat(v2): T4 Workspace 交流文本协议(schema/校验/渲染/rollup)

This commit is contained in:
tzt
2026-08-30 21:03:52 +08:00
parent 78a410b773
commit 3ed37a1d30
4 changed files with 861 additions and 75 deletions
+67 -3
View File
@@ -9,10 +9,18 @@ system:
version: 0.1.0 version: 0.1.0
router: router:
low_confidence_threshold: 0.60 # 分类置信度低于此值 -> 直接走大模型 low_confidence_threshold: 0.60 # 分类置信度低于此值 -> 直接走最后处理者
judge_fallback_threshold: 0.70 # Judge 质量分低于此值 -> 升级大模型 judge_fallback_threshold: 0.70 # Judge 质量分低于此值 -> 升级最后处理者
default_temperature: 0.2 default_temperature: 0.2
# 专家系统内核执行配置(L0 默认零参数)
execution:
mode: rule # rule(默认,L0 零参数)| hybrid
planner: rule # rule(规则拆解)| hf(可选小模型拆解)
expert_backend: rule # rule(规则执行器)| hf | api(本地小模型按需加载)
model_level: L0 # L0 纯规则 | L1 分类/Planner增强 | L2 领域生成
max_plan_depth: 3 # 任务拆解深度上限
classifier: classifier:
type: rule # rule(零依赖)| hftransformers type: rule # rule(零依赖)| hftransformers
model: Qwen/Qwen3-0.6B model: Qwen/Qwen3-0.6B
@@ -23,17 +31,31 @@ domains:
- math - math
- legal - legal
- medical - medical
- finance
- life
- education
- general - general
# 两级路由:大领域分组(用户接口指定 group → 组内路由模型 → 组内专业小模型)
# 组内路由模型只识别本组领域,体积约为统一路由模型的 1/4
domain_groups:
tech: [code, math]
professional: [legal, medical, finance]
lifestyle: [life, education]
general: [general]
experts: experts:
code: { type: mock, model: Qwen/Qwen2.5-Coder-7B-Instruct } code: { type: mock, model: Qwen/Qwen2.5-Coder-7B-Instruct }
math: { type: mock, model: Qwen/Qwen3-4B-Instruct } math: { type: mock, model: Qwen/Qwen3-4B-Instruct }
legal: { type: mock, model: Qwen/Qwen3-4B-Instruct } legal: { type: mock, model: Qwen/Qwen3-4B-Instruct }
medical: { type: mock, model: Qwen/Qwen3-4B-Instruct } medical: { type: mock, model: Qwen/Qwen3-4B-Instruct }
finance: { type: mock, model: Qwen/Qwen3-4B-Instruct }
life: { type: mock, model: Qwen/Qwen3-1.7B-Instruct }
education: { type: mock, model: Qwen/Qwen3-1.7B-Instruct }
general: { type: mock, model: Qwen/Qwen3-1.7B-Instruct } general: { type: mock, model: Qwen/Qwen3-1.7B-Instruct }
fallback: fallback:
type: mock # mock | apiOpenAI 兼容,如 DeepSeek type: mock # none(降级模板)| mock | local(本地≤8B 按需加载)| api
model: deepseek-chat model: deepseek-chat
base_url: https://api.deepseek.com/v1 base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY api_key_env: DEEPSEEK_API_KEY
@@ -47,3 +69,45 @@ cache:
semantic_enabled: true # 语义缓存(字符 n-gram 相似度,零依赖) semantic_enabled: true # 语义缓存(字符 n-gram 相似度,零依赖)
similarity_threshold: 0.88 similarity_threshold: 0.88
promote_frequency: 5 # 命中 N 次后提升为精确缓存 promote_frequency: 5 # 命中 N 次后提升为精确缓存
# ============================================================
# v2:端云协同 LLM 协作系统(《实现方案_v2》)配置段
# v1 段(上方)保留,供 legacy 路由(POST /chat/legacy)使用。
# ============================================================
runtime:
llama_server:
binary: bin/llama-server.exe # 捆绑上游 release,不改源码(D1)
model: models/qwen3.5-4b-q4_k_m.gguf
port: 8901
hw_profile: auto # auto | gpu12 | gpu8 | cpu
extra_args: ["-fa", "-ctk", "q8_0", "-ctv", "q8_0", "--cache-reuse", "256"]
tiers: # 三档硬件模板(保守默认,可手动覆盖)
gpu12: {ngl: 99, ctx: 32768}
gpu8: {ngl: 14, ctx: 16384}
cpu: {ngl: 0, ctx: 8192}
architect: # 大模型(API
model: deepseek-chat
base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY
temperature: 0.2
timeout_s: 60
worker: # 小模型(本地)
backend: llama_server
temperature: 0.3
max_fix_attempts: 2
per_step_timeout_s: 300
pipeline:
fast_path: true
rounds_cap: 6
api_token_cap: 8000
breach_policy: architect_do # architect_do | local_only
review:
queue_db: data/review.sqlite3
sample_rate: 0.10 # 随机抽样送审
force_tags: [safety] # brief.tags 命中即强制送审
+516
View File
@@ -0,0 +1,516 @@
"""交流文本(Workspace)—— 端云协同 LLM 协作系统的核心协议(零依赖)。
大模型(Architect)与小模型(Worker)互不共享内部状态,只通过这份
schema 约束的结构化 JSON 共享工作区交接(类比前后端通过 API 契约协作)。
本模块实现(对齐《实现方案_v2》第 4 节):
- WORKSPACE_SCHEMAdraft-07 风格 schema 常量(文档/校验依据)
- validate():结构 + 字段长度校验(写入前必过,D2/D9)
- 锚点寻址:a://<file>#L<start>-<end>(引用工件片段,替代全文复制)
- 双渲染函数:render_for_architect(≤1200 token)、render_for_worker(≤8K token
- rollup():已完成步骤折叠为 archive 摘要行;超限压缩(只减不删,4.5)
- 状态机:draft -> in_progress -> reviewing -> done / escalated / failed
"""
from __future__ import annotations
import copy
import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
VERSION = "1.0"
# ---------------------------------------------------------------------------
# 字段长度上限(同时是 rollup 依据,4.2)
# ---------------------------------------------------------------------------
LIMITS = {
"goal": 500, # brief.goal 字数
"task": 300, # brief.plan[].task 字数
"summary": 200, # progress[].summary 字数
"issue_text": 300, # issues[].observed/expected/tried/ask 字数
"reply": 600, # decisions[].reply 字数
"archive": 160, # archive[] 每条字数
"constraints": 8, # brief.constraints 上限条数
"plan_steps": 5, # brief.plan 上限步数
"acceptance": 20, # brief.acceptance 上限条数
"query_truncate": 200, # render_for_architect 中 query 截断
}
# 允许的领域标签(4.2 brief.tags;仅用于安全标记与验证接地,不做路由 D3)
ALLOWED_TAGS = {"code", "math", "legal", "medical", "finance",
"life", "education", "general", "safety"}
STATUS_FLOW = {
"draft": {"in_progress"},
"in_progress": {"reviewing", "escalated", "failed", "in_progress"},
"reviewing": {"done", "in_progress", "failed"},
"escalated": {"reviewing", "done", "failed"},
"done": set(),
"failed": set(),
}
# ---------------------------------------------------------------------------
# JSON Schemadraft-07 风格,draft-07 依赖内嵌;供校验与文档参考)
# ---------------------------------------------------------------------------
WORKSPACE_SCHEMA: Dict[str, Any] = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Communication Workspace",
"type": "object",
"required": ["version", "request_id", "query", "meta"],
"additionalProperties": False,
"properties": {
"version": {"const": VERSION},
"request_id": {"type": "string", "minLength": 1},
"query": {"type": "string", "minLength": 1},
"meta": {
"type": "object",
"required": ["status", "round", "budget"],
"properties": {
"status": {"enum": ["draft", "in_progress", "reviewing", "escalated", "done", "failed"]},
"round": {"type": "integer", "minimum": 0},
"budget": {
"type": "object",
"required": ["api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"],
"properties": {
"api_input_tokens": {"type": "integer", "minimum": 0},
"api_output_tokens": {"type": "integer", "minimum": 0},
"api_token_cap": {"type": "integer", "minimum": 1},
"rounds_cap": {"type": "integer", "minimum": 1},
},
},
},
},
"brief": {
"type": "object",
"required": ["goal", "constraints", "tags", "acceptance", "plan"],
"properties": {
"goal": {"type": "string"},
"constraints": {"type": "array", "items": {"type": "string"}},
"tags": {"type": "array", "items": {"type": "string"}},
"acceptance": {"type": "array", "items": {"type": "object"}},
"plan": {"type": "array", "items": {"type": "object"}},
},
},
"progress": {"type": "array", "items": {"type": "object"}},
"issues": {"type": "array", "items": {"type": "object"}},
"decisions": {"type": "array", "items": {"type": "object"}},
"archive": {"type": "array", "items": {"type": "string"}},
},
}
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
_TOKEN_PER_CHAR_ZH = 1 / 1.6 # 中文约 1.6 字/token
_TOKEN_PER_CHAR_EN = 1 / 4.0 # 英文约 4 字/token
def estimate_tokens(text: str) -> int:
"""粗略 token 估算(中英混合,用于渲染预算校验)。"""
if not text:
return 0
zh = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
en = len(text) - zh
return max(1, int(zh * _TOKEN_PER_CHAR_ZH + en * _TOKEN_PER_CHAR_EN))
def build_anchor(filename: str, start: int = 1, end: Optional[int] = None) -> str:
"""构造锚点:a://<file>#L<start>-<end>end 缺省仅 L<start>。"""
if end is None:
return f"a://{filename}#L{start}"
return f"a://{filename}#L{start}-{end}"
_ANCHOR_RE = re.compile(r"^a://(?P<file>[^#]+?)(?:#L(?P<start>\d+)(?:-(?P<end>\d+))?)?$")
def parse_anchor(anchor: str) -> Optional[Dict[str, Any]]:
"""解析锚点为 {file, start, end};非法返回 None。"""
m = _ANCHOR_RE.match(anchor)
if not m:
return None
start = int(m.group("start")) if m.group("start") else 1
end = int(m.group("end")) if m.group("end") else start
return {"file": m.group("file"), "start": start, "end": end}
def _clip(text: str, limit: int) -> str:
"""按字数截断(中文按字符)。"""
if len(text) <= limit:
return text
return text[:limit] + ""
# ---------------------------------------------------------------------------
# 校验
# ---------------------------------------------------------------------------
def validate(ws: Dict[str, Any]) -> List[str]:
"""校验 workspace 结构 + 字段长度。返回错误列表(空 = 合法)。"""
errors: List[str] = []
if not isinstance(ws, dict):
return ["workspace 必须是 object"]
if ws.get("version") != VERSION:
errors.append(f"version 必须是 {VERSION}")
if not isinstance(ws.get("request_id"), str) or not ws["request_id"]:
errors.append("request_id 必须是非空字符串")
if not isinstance(ws.get("query"), str) or not ws["query"]:
errors.append("query 必须是非空字符串")
meta = ws.get("meta")
if not isinstance(meta, dict):
errors.append("meta 必须是 object")
else:
if meta.get("status") not in STATUS_FLOW:
errors.append(f"meta.status 非法: {meta.get('status')}")
budget = meta.get("budget")
if not isinstance(budget, dict):
errors.append("meta.budget 必须是 object")
else:
for k in ("api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"):
if not isinstance(budget.get(k), int) or budget.get(k) < 0:
errors.append(f"meta.budget.{k} 必须是非负整数")
brief = ws.get("brief")
if brief is not None:
if not isinstance(brief, dict):
errors.append("brief 必须是 object")
else:
if not isinstance(brief.get("goal"), str):
errors.append("brief.goal 必须是字符串")
elif len(brief["goal"]) > LIMITS["goal"]:
errors.append(f"brief.goal 超长(>{LIMITS['goal']}字)")
if not isinstance(brief.get("constraints"), list):
errors.append("brief.constraints 必须是数组")
elif len(brief["constraints"]) > LIMITS["constraints"]:
errors.append(f"brief.constraints 超过 {LIMITS['constraints']}")
if not isinstance(brief.get("tags"), list):
errors.append("brief.tags 必须是数组")
for t in brief.get("tags", []) or []:
if t not in ALLOWED_TAGS:
errors.append(f"brief.tags 含非法标签: {t}")
acc = brief.get("acceptance")
if not isinstance(acc, list) or len(acc) > LIMITS["acceptance"]:
errors.append(f"brief.acceptance 需为 ≤{LIMITS['acceptance']} 的数组")
plan = brief.get("plan")
if not isinstance(plan, list) or len(plan) > LIMITS["plan_steps"]:
errors.append(f"brief.plan 需为 ≤{LIMITS['plan_steps']} 步的数组")
else:
ids = [p.get("id") for p in plan if isinstance(p, dict)]
if len(set(ids)) != len(ids):
errors.append("brief.plan 存在重复 step id")
for p in plan:
if not isinstance(p, dict):
errors.append("brief.plan 元素必须是 object")
continue
if not isinstance(p.get("task"), str):
errors.append(f"brief.plan[{p.get('id')}].task 必须是字符串")
elif len(p["task"]) > LIMITS["task"]:
errors.append(f"brief.plan[{p.get('id')}].task 超长(>{LIMITS['task']}字)")
for i, entry in enumerate(ws.get("progress", []) or []):
if not isinstance(entry, dict):
errors.append(f"progress[{i}] 必须是 object"); continue
if entry.get("status") not in ("done", "failed", "blocked"):
errors.append(f"progress[{i}].status 非法")
if not isinstance(entry.get("summary"), str) or len(entry["summary"]) > LIMITS["summary"]:
errors.append(f"progress[{i}].summary 非法或超长")
for i, entry in enumerate(ws.get("issues", []) or []):
if not isinstance(entry, dict):
errors.append(f"issues[{i}] 必须是 object"); continue
for k in ("observed", "expected", "tried", "ask"):
if isinstance(entry.get(k), str) and len(entry[k]) > LIMITS["issue_text"]:
errors.append(f"issues[{i}].{k} 超长(>{LIMITS['issue_text']}字)")
for i, entry in enumerate(ws.get("decisions", []) or []):
if not isinstance(entry, dict):
errors.append(f"decisions[{i}] 必须是 object"); continue
if isinstance(entry.get("reply"), str) and len(entry["reply"]) > LIMITS["reply"]:
errors.append(f"decisions[{i}].reply 超长(>{LIMITS['reply']}字)")
for i, line in enumerate(ws.get("archive", []) or []):
if not isinstance(line, str) or len(line) > LIMITS["archive"]:
errors.append(f"archive[{i}] 非法或超长")
return errors
# ---------------------------------------------------------------------------
# Workspace
# ---------------------------------------------------------------------------
class Workspace:
"""交流文本对象:持有状态、执行写入前校验、渲染、rollup、持久化。"""
def __init__(self, data: Dict[str, Any]):
errors = validate(data)
if errors:
raise ValueError("workspace 校验失败: " + "; ".join(errors[:5]))
self._data = data
self._brief_locked = False
# ---------- 构造 ----------
@classmethod
def new(cls, request_id: str, query: str,
api_token_cap: int = 8000, rounds_cap: int = 6) -> "Workspace":
data = {
"version": VERSION,
"request_id": request_id,
"query": query,
"meta": {
"status": "draft",
"round": 0,
"budget": {
"api_input_tokens": 0,
"api_output_tokens": 0,
"api_token_cap": api_token_cap,
"rounds_cap": rounds_cap,
},
},
"brief": None,
"progress": [],
"issues": [],
"decisions": [],
"archive": [],
}
return cls(data)
# ---------- 访问 ----------
@property
def request_id(self) -> str:
return self._data["request_id"]
@property
def status(self) -> str:
return self._data["meta"]["status"]
@property
def data(self) -> Dict[str, Any]:
return copy.deepcopy(self._data)
def get(self, key: str, default: Any = None) -> Any:
return self._data.get(key, default)
def __getitem__(self, key: str) -> Any:
return self._data[key]
def meta(self) -> Dict[str, Any]:
return self._data["meta"]
def budget(self) -> Dict[str, int]:
return self._data["meta"]["budget"]
# ---------- 写入(均先校验) ----------
def _commit(self, data: Dict[str, Any]) -> None:
errors = validate(data)
if errors:
raise ValueError("写入校验失败: " + "; ".join(errors[:5]))
self._data = data
def transition(self, new_status: str) -> None:
cur = self.status
if new_status == cur:
return
if new_status not in STATUS_FLOW.get(cur, set()):
raise ValueError(f"非法状态迁移: {cur} -> {new_status}")
self._data["meta"]["status"] = new_status
def apply_brief(self, brief: Dict[str, Any]) -> None:
"""写入 brief(写一次后锁定,D2:brief 恒定位于文档前部,prefix cache 友好)。"""
if self._data.get("brief") is not None or self._brief_locked:
raise ValueError("brief 已写入,不可重复")
new = copy.deepcopy(self._data)
new["brief"] = brief
new["meta"]["status"] = "in_progress"
self._commit(new)
self._brief_locked = True
def add_progress(self, step: str, status: str, summary: str,
artifact: Optional[str] = None) -> None:
new = copy.deepcopy(self._data)
entry: Dict[str, Any] = {"step": step, "status": status, "summary": summary}
if artifact:
entry["artifact"] = artifact
new["progress"].append(entry)
self._commit(new)
def add_issue(self, step: str, anchor: str, observed: str, expected: str,
tried: str, ask: str) -> str:
new = copy.deepcopy(self._data)
iid = f"i{len(new['issues']) + 1}"
entry = {
"id": iid, "step": step, "anchor": anchor,
"observed": observed, "expected": expected,
"tried": tried, "ask": ask,
}
new["issues"].append(entry)
self._commit(new)
return iid
def add_decision(self, ref: str, reply: str,
patch_plan: Optional[List[Dict[str, str]]] = None) -> None:
new = copy.deepcopy(self._data)
new["decisions"].append({
"ref": ref, "reply": reply,
"patch_plan": patch_plan or [],
})
self._commit(new)
def mark_round(self) -> None:
self._data["meta"]["round"] += 1
def add_budget(self, input_tokens: int = 0, output_tokens: int = 0) -> None:
b = self._data["meta"]["budget"]
b["api_input_tokens"] += int(input_tokens)
b["api_output_tokens"] += int(output_tokens)
self._commit(self._data)
def exhausted(self) -> bool:
"""预算熔断判定:API token 或回合任一触顶(D6)。"""
b = self._data["meta"]["budget"]
used = b["api_input_tokens"] + b["api_output_tokens"]
if b["api_token_cap"] and used >= b["api_token_cap"]:
return True
if b["rounds_cap"] and self._data["meta"]["round"] >= b["rounds_cap"]:
return True
return False
# ---------- rollup4.5 ----------
def rollup(self) -> int:
"""把 done 的 progress 折叠为 archive 摘要行,并清理解析完成的问题。
只减不删 archive 历史;progress 中 done 的条目折叠后移除(保留 failed/blocked)。
返回本次折叠的条目数。
"""
folded = 0
new_progress: List[Dict[str, Any]] = []
for entry in self._data.get("progress", []):
if entry.get("status") == "done" and entry.get("step"):
line = _clip(f"{entry['step']}: {entry.get('summary', '')}", LIMITS["archive"])
if line not in self._data["archive"]:
self._data["archive"].append(line)
folded += 1
else:
new_progress.append(entry)
resolved = {d.get("ref") for d in self._data.get("decisions", [])}
kept_issues: List[Dict[str, Any]] = []
for iss in self._data.get("issues", []):
if iss.get("id") in resolved:
line = _clip(f"{iss['id']}: {iss.get('expected', '')[:60]}", LIMITS["archive"])
if line not in self._data["archive"]:
self._data["archive"].append(line)
else:
kept_issues.append(iss)
self._data["issues"] = kept_issues
self._data["progress"] = new_progress
return folded
# ---------- 渲染 ----------
def render_for_architect(self) -> str:
"""渲染 Architect 输入(D7):meta+query(截断)+全部 issues+最近3条 decisions
+最近回合 progress 摘要。目标 ≤1200 token。"""
d = self._data
parts: List[str] = []
m = d["meta"]
parts.append("== meta ==")
parts.append(f"status={m['status']} round={m['round']} "
f"budget={json.dumps(m['budget'], ensure_ascii=False)}")
parts.append("== query ==")
parts.append(_clip(d["query"], LIMITS["query_truncate"]))
if d.get("brief"):
b = d["brief"]
parts.append("== brief(锁定) ==")
parts.append(f"goal: {_clip(b['goal'], 120)}")
parts.append(f"plan: {[p['id'] for p in b.get('plan', [])]}")
parts.append(f"acceptance: {[a.get('id') for a in b.get('acceptance', [])]}")
parts.append("== issues ==")
for iss in d.get("issues", []):
parts.append(f"{iss['id']} step={iss.get('step')} anchor={iss.get('anchor')} "
f"ask={_clip(iss.get('ask', ''), 80)}")
parts.append("== 最近 3 条 decisions ==")
for dec in d.get("decisions", [])[-3:]:
parts.append(f"ref={dec.get('ref')} reply={_clip(dec.get('reply', ''), 80)}")
parts.append("== progress 摘要 ==")
for p in d.get("progress", [])[-5:]:
parts.append(f"{p.get('step')} [{p.get('status')}] {_clip(p.get('summary', ''), 40)}")
parts.append("== archive ==")
for line in d.get("archive", [])[-8:]:
parts.append(line)
# token 预算:超限先截断最旧 archive(已只保留 3 条 decisions
out = "\n".join(parts)
while estimate_tokens(out) > 1200 and len(d.get("archive", [])) > 4:
d = copy.deepcopy(d)
d["archive"] = d["archive"][4:]
out = "\n".join(_rerender(self, d))
return out
def render_for_worker(self, step_id: str,
artifact_text: Optional[str] = None) -> str:
"""渲染 Worker 输入(4.4):brief 全文+该 step 定义+依赖 step 的 archive 摘要行
+该 step 现有工件全文+验收标准。目标 ≤8K token。"""
d = self._data
b = d.get("brief")
parts: List[str] = []
if b:
parts.append("== 任务目标 (goal) ==")
parts.append(b["goal"])
parts.append("== 约束 (constraints) ==")
parts.extend(f"- {c}" for c in b.get("constraints", []))
parts.append("== 全部步骤 (plan) ==")
for p in b.get("plan", []):
mark = " <-- 当前步" if p.get("id") == step_id else ""
parts.append(f"{p['id']}: {p.get('task', '')}{mark}")
parts.append(f" done_criteria: {p.get('done_criteria', '')}")
parts.append("== 依赖步摘要 (archive) ==")
for line in d.get("archive", [])[-6:]:
parts.append(line)
if artifact_text:
parts.append(f"== 当前步已有工件({step_id} ==")
parts.append(artifact_text)
parts.append("== 验收标准 ==")
if b:
for a in b.get("acceptance", []):
parts.append(f"- {a.get('id')}: {a.get('check', '')} "
f"(machine_checkable={a.get('machine_checkable', False)})")
parts.append("== 要求 ==")
parts.append("请实现当前步,并用可执行验证/事实对照/结构检查自验证;"
"通过则写 progress(done),失败自修 ≤2 次,仍失败则写 issue。")
return "\n".join(parts)
# ---------- 持久化 ----------
def save(self, path: Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False, indent=2)
@classmethod
def load(cls, path: Path) -> "Workspace":
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return cls(data)
def to_dict(self) -> Dict[str, Any]:
return self.data
def _rerender(ws: "Workspace", d: Dict[str, Any]) -> List[str]:
"""用裁剪后的数据重建 Architect 渲染(供超限压缩内部用)。"""
parts: List[str] = []
m = d["meta"]
parts.append("== meta ==")
parts.append(f"status={m['status']} round={m['round']}")
parts.append("== query ==")
parts.append(_clip(d["query"], LIMITS["query_truncate"]))
parts.append("== issues ==")
for iss in d.get("issues", []):
parts.append(f"{iss['id']} step={iss.get('step')} ask={_clip(iss.get('ask', ''), 80)}")
parts.append("== decisions(最近3) ==")
for dec in d.get("decisions", [])[-3:]:
parts.append(f"ref={dec.get('ref')} reply={_clip(dec.get('reply', ''), 80)}")
parts.append("== progress ==")
for p in d.get("progress", [])[-5:]:
parts.append(f"{p.get('step')} [{p.get('status')}] {_clip(p.get('summary', ''), 40)}")
parts.append("== archive ==")
for line in d.get("archive", [])[-6:]:
parts.append(line)
return parts
+231
View File
@@ -0,0 +1,231 @@
"""T4 交流文本 Workspace 单测(封闭,纯逻辑)。"""
import json
import pytest
from router_system.workspace import (
LIMITS,
STATUS_FLOW,
Workspace,
build_anchor,
estimate_tokens,
parse_anchor,
validate,
)
ALLOWED_BRIEF = {
"goal": "用 Python 实现快速排序并解释复杂度",
"constraints": ["必须用标准库", "时间复杂度 O(n log n)"],
"tags": ["code"],
"acceptance": [{"id": "a1", "check": "排序结果正确", "machine_checkable": True}],
"plan": [
{"id": "s1", "task": "实现快排主函数", "deps": [], "done_criteria": "可运行"},
{"id": "s2", "task": "补充单元测试", "deps": ["s1"], "done_criteria": "3/3 通过"},
],
}
def _ws(**kw):
return Workspace.new(request_id=kw.get("request_id", "abc123def456"),
query=kw.get("query", "写个快排"),
api_token_cap=kw.get("api_token_cap", 8000),
rounds_cap=kw.get("rounds_cap", 6))
# ---------- 结构/校验 ----------
def test_new_has_valid_defaults():
ws = _ws()
assert ws.status == "draft"
assert validate(ws.data) == []
b = ws.budget()
assert b["api_token_cap"] == 8000 and b["rounds_cap"] == 6
def test_brief_write_once():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
assert ws.status == "in_progress"
with pytest.raises(ValueError):
ws.apply_brief(ALLOWED_BRIEF) # 二次写入被拒
def test_apply_brief_missing_field_rejected():
ws = _ws()
bad = dict(ALLOWED_BRIEF)
del bad["plan"]
with pytest.raises(ValueError):
ws.apply_brief(bad)
def test_apply_brief_invalid_tag_rejected():
ws = _ws()
bad = dict(ALLOWED_BRIEF, tags=["hacker"])
with pytest.raises(ValueError):
ws.apply_brief(bad)
def test_apply_brief_overlong_goal_rejected():
ws = _ws()
bad = dict(ALLOWED_BRIEF, goal="" * (LIMITS["goal"] + 1))
with pytest.raises(ValueError):
ws.apply_brief(bad)
def test_apply_brief_too_many_plan_steps_rejected():
ws = _ws()
plan = [{"id": f"s{i}", "task": "t", "deps": [], "done_criteria": "c"}
for i in range(LIMITS["plan_steps"] + 1)]
bad = dict(ALLOWED_BRIEF, plan=plan)
with pytest.raises(ValueError):
ws.apply_brief(bad)
# ---------- 状态机 ----------
def test_status_transitions():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.transition("reviewing")
ws.transition("done")
assert ws.status == "done"
with pytest.raises(ValueError):
ws.transition("in_progress") # done 之后不允许再回
def test_illegal_transition():
ws = _ws()
with pytest.raises(ValueError):
ws.transition("done") # draft 不能直接到 done
# ---------- 写入 + 预算 ----------
def test_progress_and_issues_and_decisions():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "实现完成", artifact="a://s1_main.py")
iid = ws.add_issue("s2", "a://s2_tests.py#L12-18",
"测试失败", "3/3 通过", "修了两次", "请裁决")
assert iid == "i1"
ws.add_decision(iid, "改用排序后断言", [{"id": "s2", "task": "修测试"}])
assert len(ws["progress"]) == 1
assert len(ws["issues"]) == 1
assert len(ws["decisions"]) == 1
def test_budget_accumulation_and_exhaustion():
ws = _ws(api_token_cap=10)
ws.apply_brief(ALLOWED_BRIEF)
ws.add_budget(input_tokens=6, output_tokens=2)
assert ws.exhausted() is False
ws.add_budget(input_tokens=3) # 累计 11 > 10
assert ws.exhausted() is True
def test_round_exhaustion():
ws = _ws(rounds_cap=2)
ws.apply_brief(ALLOWED_BRIEF)
ws.mark_round()
ws.mark_round()
assert ws.exhausted() is True
def test_issue_text_overlong_rejected():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
with pytest.raises(ValueError):
ws.add_issue("s1", "a://f.py", "" * (LIMITS["issue_text"] + 1), "e", "t", "q")
# ---------- rollup ----------
def test_rollup_folds_done_progress_and_resolved_issues():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "快排实现完成", "a://s1_main.py")
ws.add_progress("s2", "failed", "测试未过")
iid = ws.add_issue("s2", "a://s2_tests.py#L1", "失败", "通过", "试过", "")
ws.add_decision(iid, "已修复")
n = ws.rollup()
assert n == 1 # 折叠 1 条 done progress
assert "s1" in "".join(ws["archive"])
# s2 failed 保留;已 resolve 的 issue 从 issues 移除
steps = [p["step"] for p in ws["progress"]]
assert steps == ["s2"]
assert len(ws["issues"]) == 0
def test_rollup_keeps_unresolved_issue():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "ok", "a://s1.py")
ws.add_issue("s2", "a://s2.py#L1", "obs", "exp", "try", "ask") # 无 decision
ws.rollup()
assert len(ws["issues"]) == 1
# ---------- 渲染 ----------
def test_render_for_architect_within_budget():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
for i in range(6):
ws.add_progress(f"s{i}", "done", f"步骤{i}完成" * 3)
ws.add_issue(f"s{i}", f"a://f{i}.py#L1", "obs", "exp", "try", "ask" * 10)
ws.add_decision(f"i{i+1}", "裁决" * 20)
out = ws.render_for_architect()
assert estimate_tokens(out) <= 1200
assert "== meta ==" in out
def test_render_for_worker_has_step_and_criteria():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
out = ws.render_for_worker("s2", artifact_text="def test(): pass")
assert "当前步" in out
assert "s2" in out
assert "def test(): pass" in out
assert "验收标准" in out and "a1" in out
def test_render_for_worker_marks_current_step():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
out = ws.render_for_worker("s1")
assert "<-- 当前步" in out
# ---------- 锚点 ----------
def test_anchor_build_and_parse():
a = build_anchor("s1_main.py", 12, 18)
assert a == "a://s1_main.py#L12-18"
p = parse_anchor(a)
assert p == {"file": "s1_main.py", "start": 12, "end": 18}
# 无行号
assert parse_anchor("a://f.py") == {"file": "f.py", "start": 1, "end": 1}
assert parse_anchor("not-an-anchor") is None
# ---------- 持久化 ----------
def test_save_load_roundtrip(tmp_path):
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "ok")
path = tmp_path / "ws.json"
ws.save(path)
loaded = Workspace.load(path)
assert loaded.to_dict() == ws.to_dict()
assert loaded.request_id == ws.request_id
def test_json_serializable(tmp_path):
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_issue("s1", "a://f.py#L1", "obs", "exp", "try", "ask")
# 应能被 json 直接序列化(中文 ensure_ascii=False
s = json.dumps(ws.to_dict(), ensure_ascii=False)
assert json.loads(s)["request_id"] == ws.request_id
def test_validate_rejects_non_object():
assert validate("nope") != []
def test_status_flow_enumeration():
assert set(STATUS_FLOW.keys()) == {"draft", "in_progress", "reviewing", "escalated", "done", "failed"}
+1 -26
View File
@@ -70,7 +70,7 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T1 | 环境与基线确认(126 测试全绿;README 环境备忘) | ✅ 完成 | (并入 T2 commit | | T1 | 环境与基线确认(126 测试全绿;README 环境备忘) | ✅ 完成 | (并入 T2 commit |
| T2 | 运维层:hw_profile + llama_server 进程管理 | ✅ 完成 | T2 | | T2 | 运维层:hw_profile + llama_server 进程管理 | ✅ 完成 | T2 |
| T3 | ArchitectClientDeepSeek APIJSON 约束) | ⬜ | | | T3 | ArchitectClientDeepSeek APIJSON 约束) | ⬜ | |
| T4 | Workspace(交流文本 schema/校验/渲染/rollup | ⬜ | | | T4 | Workspace(交流文本 schema/校验/渲染/rollup | ✅ 完成 | T4 |
| T5 | WorkerLoop + 接地验证 | ⬜ | | | T5 | WorkerLoop + 接地验证 | ⬜ | |
| T6 | CollaborativePipeline 编排 | ⬜ | | | T6 | CollaborativePipeline 编排 | ⬜ | |
| T7 | 网关扩展(/chat 切 v2/chat/legacy | ⬜ | | | T7 | 网关扩展(/chat 切 v2/chat/legacy | ⬜ | |
@@ -81,28 +81,3 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T12 | 实验脚本 bench_tokens.py + 数据集 | ⬜ | | | T12 | 实验脚本 bench_tokens.py + 数据集 | ⬜ | |
| T13 | E1E5 跑数到 research/v2_experiments/ | ⬜ | | | T13 | E1E5 跑数到 research/v2_experiments/ | ⬜ | |
| T14 | 文档收口(README v2 改写) | ⬜ | | | T14 | 文档收口(README v2 改写) | ⬜ | |
---
## 五、v2 任务登记(端云协同 LLM 协作系统,见《实现方案_v2_端云协同LLM协作系统.md》)
> 每个任务一个 commit`feat(v2): Tn 描述`),交付含封闭单测;v1 的 126 项测试保持全绿。
| T | 内容 | 状态 | commit |
|---|------|------|--------|
| T1 | 环境与基线确认(126 测试全绿;README 环境备忘) | ✅ 完成 | (并入 T2 commit |
| T2 | 运维层:hw_profile + llama_server 进程管理 | ✅ 完成 | T2 |
| T3 | ArchitectClientDeepSeek APIJSON 约束) | ⬜ | |
| T4 | Workspace(交流文本 schema/校验/渲染/rollup | ⬜ | |
| T5 | WorkerLoop + 接地验证 | ⬜ | |
| T6 | CollaborativePipeline 编排 | ⬜ | |
| T7 | 网关扩展(/chat 切 v2/chat/legacy | ⬜ | |
| T8 | 人工检验队列 ReviewQueue | ⬜ | |
| T9 | token 计量与账单 | ⬜ | |
| T10 | rollup + prefix cache 调优 | ⬜ | |
| T11 | 打包分发 setup_runtime.py | ⬜ | |
| T12 | 实验脚本 bench_tokens.py + 数据集 | ⬜ | |
| T13 | E1E5 跑数到 research/v2_experiments/ | ⬜ | |
| T14 | 文档收口(README v2 改写) | ⬜ | |