Compare commits

...

25 Commits

Author SHA1 Message Date
wuyang6 1e7570e72d fix: bound idle evaluation model streams 2026-07-24 20:08:38 +08:00
wuyang6 459bee47a7 fix: recover interrupted evaluation cases 2026-07-24 20:04:09 +08:00
wuyang6 96809fa5c8 fix: back off evaluation rate limits 2026-07-24 17:51:10 +08:00
wuyang6 174d1d3d01 feat: add evaluation label mapping 2026-07-24 17:34:32 +08:00
wuyang6 e2bf012f60 feat(skill): improve multi-turn fast-slow routing 2026-07-24 17:12:25 +08:00
wuyang6 a505261bde fix: make skill editor responsive 2026-07-24 10:17:05 +08:00
wuyang6 6630e82069 feat: add editable skill evaluation versions 2026-07-24 10:11:51 +08:00
wuyang6 ecc02c06b7 refactor: layer fast slow routing knowledge 2026-07-23 21:35:18 +08:00
wuyang6 6116d10e8f feat: persist single evaluation history 2026-07-23 19:46:15 +08:00
wuyang6 5be9434ba3 feat: add skill evaluation workbench 2026-07-23 19:38:25 +08:00
zhukai8 9067914ecc feat: 新增label快慢分流skill全套配置与校验脚本 2026-07-23 18:09:01 +08:00
wuyang6 017743a415 update 2026-07-23 16:50:47 +08:00
wuyang6 0e4f5041f1 docs: add skill evaluation workbench design 2026-07-23 16:49:53 +08:00
wuyang6 97e1e1dd48 Add versioned annotation submissions 2026-07-20 17:28:47 +08:00
wuyang6 7cd6e5e464 Add annotation page gallery 2026-07-20 10:56:59 +08:00
wuyang6 b7798e9985 Update screen operation label indexes 2026-07-14 15:00:29 +08:00
wuyang6 ef213c4125 Update screen operation label knowledge 2026-07-14 14:57:55 +08:00
wuyang6 359b84ba55 Fix auth session race 2026-07-13 20:01:21 +08:00
wuyang6 f5f98688dd Fix compact summary regex replacement 2026-07-07 21:21:19 +08:00
wuyang6 da8b63443b Add shared Jupyter defaults for model training lite 2026-07-07 20:00:27 +08:00
wuyang6 2a6a7c2780 Enhance model training lite workflow 2026-07-07 19:58:00 +08:00
wuyang6 8eadc25b19 fix feishu reauth for online docs 2026-07-02 14:02:01 +08:00
wuyang6 0007d99763 fix session display replay consistency 2026-06-30 14:40:20 +08:00
wuyang6 84ff22fc65 Handle absolute workspace globs 2026-06-24 15:06:12 +08:00
wuyang6 3637e2c73a Fix compacted tool history for resumed sessions 2026-06-24 14:18:15 +08:00
67 changed files with 11887 additions and 73 deletions
+5
View File
@@ -26,6 +26,7 @@ router_session_parquet/
# Environment files # Environment files
.env .env
.env.* .env.*
skills/*/.local/
# Local benchmark outputs # Local benchmark outputs
benchmark_artifacts/ benchmark_artifacts/
@@ -42,4 +43,8 @@ benchmarks/data/manifest.json
claude-code-sourcemap-main claude-code-sourcemap-main
router_session_parquet router_session_parquet
# Locally managed annotation pages served by /annotations
annotation-pages/
标签定义 标签定义
.codex-tmp
+500 -20
View File
@@ -47,6 +47,12 @@ from src.agent_types import (
) )
from src.bundled_skills import ALWAYS_ENABLED_HIDDEN_SKILL_NAMES, get_bundled_skills from src.bundled_skills import ALWAYS_ENABLED_HIDDEN_SKILL_NAMES, get_bundled_skills
from src.data_agent_inputs import DataAgentInputError, load_input_sources from src.data_agent_inputs import DataAgentInputError, load_input_sources
from src.evaluation_runtime import (
DEFAULT_CONCURRENCY,
MAX_CONCURRENCY,
EvaluationError,
EvaluationRuntime,
)
from src.jupyter_runtime import ( from src.jupyter_runtime import (
DEFAULT_JUPYTER_WORKSPACE_ROOT, DEFAULT_JUPYTER_WORKSPACE_ROOT,
JupyterRuntimeError, JupyterRuntimeError,
@@ -586,6 +592,12 @@ class AgentState:
self.model_config_for, self.model_config_for,
) )
self.memory_manager.start() self.memory_manager.start()
self.evaluation_runtime = EvaluationRuntime(
root=self.session_directory.parent / 'evaluations',
cwd_for_account=lambda account_id: self.config_for(account_id).cwd,
model_config_for=self.model_config_for,
account_paths_for=self.account_paths,
)
@property @property
def cwd(self) -> Path: def cwd(self) -> Path:
@@ -1125,6 +1137,78 @@ class SkillSyncRequest(BaseModel):
account_id: str | None = None account_id: str | None = None
class EvaluationDatasetCreateRequest(BaseModel):
account_id: str = Field(min_length=1)
name: str = ''
filename: str = Field(min_length=1)
content_base64: str | None = None
rows: list[dict[str, Any]] | None = None
class EvaluationDatasetMappingRequest(BaseModel):
account_id: str = Field(min_length=1)
mapping: dict[str, Any]
class EvaluationLabelMappingSuggestRequest(BaseModel):
account_id: str = Field(min_length=1)
mapping: dict[str, Any]
skill_name: str = 'label-master'
snapshot_id: str | None = None
model: str | None = None
class EvaluationAnalyzeRequest(BaseModel):
account_id: str = Field(min_length=1)
query: str = Field(min_length=1, max_length=20_000)
skill_name: str = 'label-master'
snapshot_id: str | None = None
model: str | None = None
history: list[dict[str, Any]] = Field(default_factory=list)
context: dict[str, Any] = Field(default_factory=dict)
domain: str = ''
request_id: str = ''
metadata: dict[str, Any] = Field(default_factory=dict)
class EvaluationExperimentCreateRequest(BaseModel):
account_id: str = Field(min_length=1)
dataset_id: str = Field(min_length=1)
name: str = ''
skill_name: str = 'label-master'
snapshot_id: str | None = None
model: str | None = None
concurrency: int = Field(
default=DEFAULT_CONCURRENCY,
ge=1,
le=MAX_CONCURRENCY,
)
class EvaluationSkillVersionSaveRequest(BaseModel):
account_id: str = Field(min_length=1)
base_snapshot_id: str = Field(min_length=1)
version_name: str = Field(min_length=1, max_length=80)
note: str = Field(default='', max_length=1000)
files: dict[str, str]
class EvaluationExperimentActionRequest(BaseModel):
account_id: str = Field(min_length=1)
class EvaluationRetryRequest(BaseModel):
account_id: str = Field(min_length=1)
case_ids: list[str] | None = None
disagreements_only: bool = False
class EvaluationReviewRequest(BaseModel):
account_id: str = Field(min_length=1)
review_status: str = Field(max_length=80)
review_note: str = Field(default='', max_length=4000)
class SessionUpdate(BaseModel): class SessionUpdate(BaseModel):
title: str | None = Field(default=None, max_length=80) title: str | None = Field(default=None, max_length=80)
is_training: bool | None = None is_training: bool | None = None
@@ -1132,6 +1216,7 @@ class SessionUpdate(BaseModel):
class FeishuAccountRequest(BaseModel): class FeishuAccountRequest(BaseModel):
account_id: str | None = None account_id: str | None = None
force: bool = False
class FeishuOnlineDocRequest(BaseModel): class FeishuOnlineDocRequest(BaseModel):
@@ -1177,6 +1262,7 @@ def create_app(state: AgentState) -> FastAPI:
scanner_task.cancel() scanner_task.cancel()
_watcher_manager.cancel_all() _watcher_manager.cancel_all()
_bash_bg_manager.cancel_all() _bash_bg_manager.cancel_all()
state.evaluation_runtime.shutdown()
state.event_loop = None state.event_loop = None
app = FastAPI(title='Claw Code GUI', version='1.0', lifespan=lifespan) app = FastAPI(title='Claw Code GUI', version='1.0', lifespan=lifespan)
@@ -1467,6 +1553,331 @@ def create_app(state: AgentState) -> FastAPI:
) )
return result return result
# ------------- Skill evaluations ---------------------------------------
@app.get('/api/evaluations/metadata')
async def evaluation_metadata(account_id: str) -> dict[str, Any]:
try:
return state.evaluation_runtime.metadata(_safe_account_id(account_id))
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/evaluations/skills/{skill_name}/versions')
async def list_evaluation_skill_versions(
skill_name: str,
account_id: str,
) -> dict[str, Any]:
try:
return await asyncio.to_thread(
state.evaluation_runtime.list_skill_versions,
_safe_account_id(account_id),
skill_name,
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get(
'/api/evaluations/skills/{skill_name}/versions/{snapshot_id}'
)
async def get_evaluation_skill_version(
skill_name: str,
snapshot_id: str,
account_id: str,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.get_skill_version_manifest(
_safe_account_id(account_id),
skill_name,
snapshot_id,
)
except EvaluationError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get(
'/api/evaluations/skills/{skill_name}/versions/{snapshot_id}/file'
)
async def get_evaluation_skill_version_file(
skill_name: str,
snapshot_id: str,
account_id: str,
path: str,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.read_skill_version_file(
_safe_account_id(account_id),
skill_name,
snapshot_id,
path,
)
except EvaluationError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post('/api/evaluations/skills/{skill_name}/versions')
async def save_evaluation_skill_version(
skill_name: str,
payload: EvaluationSkillVersionSaveRequest,
) -> dict[str, Any]:
try:
return await asyncio.to_thread(
state.evaluation_runtime.save_skill_version,
_safe_account_id(payload.account_id),
skill_name,
base_snapshot_id=payload.base_snapshot_id,
version_name=payload.version_name,
note=payload.note,
files=payload.files,
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post('/api/evaluations/analyze')
async def analyze_evaluation_case(
payload: EvaluationAnalyzeRequest,
) -> dict[str, Any]:
try:
return await asyncio.to_thread(
state.evaluation_runtime.analyze_case,
account_id=_safe_account_id(payload.account_id),
query=payload.query,
skill_name=payload.skill_name,
snapshot_id=payload.snapshot_id,
model=payload.model,
history=payload.history,
context=payload.context,
domain=payload.domain,
request_id=payload.request_id,
metadata=payload.metadata,
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/evaluations/analyses')
async def list_evaluation_analyses(
account_id: str,
) -> list[dict[str, Any]]:
return state.evaluation_runtime.list_single_analyses(
_safe_account_id(account_id)
)
@app.get('/api/evaluations/analyses/{analysis_id}')
async def get_evaluation_analysis(
analysis_id: str,
account_id: str,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.get_single_analysis(
analysis_id,
_safe_account_id(account_id),
)
except EvaluationError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get('/api/evaluations/datasets')
async def list_evaluation_datasets(account_id: str) -> list[dict[str, Any]]:
return state.evaluation_runtime.list_datasets(_safe_account_id(account_id))
@app.post('/api/evaluations/datasets')
async def create_evaluation_dataset(
payload: EvaluationDatasetCreateRequest,
) -> dict[str, Any]:
try:
return await asyncio.to_thread(
state.evaluation_runtime.create_dataset,
account_id=_safe_account_id(payload.account_id),
name=payload.name,
filename=payload.filename,
content_base64=payload.content_base64,
rows=payload.rows,
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/evaluations/datasets/{dataset_id}')
async def get_evaluation_dataset(
dataset_id: str,
account_id: str,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.get_dataset(
dataset_id,
_safe_account_id(account_id),
include_rows=False,
)
except EvaluationError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.patch('/api/evaluations/datasets/{dataset_id}/mapping')
async def update_evaluation_dataset_mapping(
dataset_id: str,
payload: EvaluationDatasetMappingRequest,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.update_dataset_mapping(
dataset_id,
_safe_account_id(payload.account_id),
payload.mapping,
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post('/api/evaluations/datasets/{dataset_id}/label-mapping/suggest')
async def suggest_evaluation_label_mapping(
dataset_id: str,
payload: EvaluationLabelMappingSuggestRequest,
) -> dict[str, Any]:
try:
return await asyncio.to_thread(
state.evaluation_runtime.suggest_label_mapping,
dataset_id,
_safe_account_id(payload.account_id),
mapping=payload.mapping,
skill_name=payload.skill_name,
snapshot_id=payload.snapshot_id,
model=payload.model,
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/evaluations/experiments')
async def list_evaluation_experiments(
account_id: str,
) -> list[dict[str, Any]]:
return state.evaluation_runtime.list_experiments(
_safe_account_id(account_id)
)
@app.post('/api/evaluations/experiments')
async def create_evaluation_experiment(
payload: EvaluationExperimentCreateRequest,
) -> dict[str, Any]:
try:
return await asyncio.to_thread(
state.evaluation_runtime.create_experiment,
account_id=_safe_account_id(payload.account_id),
dataset_id=payload.dataset_id,
name=payload.name,
skill_name=payload.skill_name,
snapshot_id=payload.snapshot_id,
model=payload.model,
concurrency=payload.concurrency,
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/evaluations/experiments/{experiment_id}')
async def get_evaluation_experiment(
experiment_id: str,
account_id: str,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.get_experiment(
experiment_id,
_safe_account_id(account_id),
)
except EvaluationError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post('/api/evaluations/experiments/{experiment_id}/start')
async def start_evaluation_experiment(
experiment_id: str,
payload: EvaluationExperimentActionRequest,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.start(
experiment_id,
_safe_account_id(payload.account_id),
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post('/api/evaluations/experiments/{experiment_id}/pause')
async def pause_evaluation_experiment(
experiment_id: str,
payload: EvaluationExperimentActionRequest,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.pause(
experiment_id,
_safe_account_id(payload.account_id),
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post('/api/evaluations/experiments/{experiment_id}/resume')
async def resume_evaluation_experiment(
experiment_id: str,
payload: EvaluationExperimentActionRequest,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.resume(
experiment_id,
_safe_account_id(payload.account_id),
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post('/api/evaluations/experiments/{experiment_id}/cancel')
async def cancel_evaluation_experiment(
experiment_id: str,
payload: EvaluationExperimentActionRequest,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.cancel(
experiment_id,
_safe_account_id(payload.account_id),
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post('/api/evaluations/experiments/{experiment_id}/retry')
async def retry_evaluation_experiment(
experiment_id: str,
payload: EvaluationRetryRequest,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.retry(
experiment_id,
_safe_account_id(payload.account_id),
case_ids=payload.case_ids,
disagreements_only=payload.disagreements_only,
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.patch('/api/evaluations/cases/{case_id}/review')
async def review_evaluation_case(
case_id: str,
payload: EvaluationReviewRequest,
) -> dict[str, Any]:
try:
return state.evaluation_runtime.update_review(
case_id,
_safe_account_id(payload.account_id),
review_status=payload.review_status,
review_note=payload.review_note,
)
except EvaluationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/evaluations/experiments/{experiment_id}/export')
async def export_evaluation_experiment(
experiment_id: str,
account_id: str,
) -> Response:
try:
filename, content = state.evaluation_runtime.export_csv(
experiment_id,
_safe_account_id(account_id),
)
except EvaluationError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return Response(
content,
media_type='text/csv; charset=utf-8',
headers={
'Content-Disposition': f"attachment; filename*=UTF-8''{quote(filename)}"
},
)
# ------------- memory ---------------------------------------------------- # ------------- memory ----------------------------------------------------
@app.get('/api/memory/user') @app.get('/api/memory/user')
async def get_user_memory(account_id: str) -> dict[str, Any]: async def get_user_memory(account_id: str) -> dict[str, Any]:
@@ -1616,7 +2027,7 @@ def create_app(state: AgentState) -> FastAPI:
@app.post('/api/integrations/feishu/login') @app.post('/api/integrations/feishu/login')
async def feishu_login(payload: FeishuAccountRequest) -> dict[str, Any]: async def feishu_login(payload: FeishuAccountRequest) -> dict[str, Any]:
return _start_feishu_login(state, payload.account_id) return _start_feishu_login(state, payload.account_id, force=payload.force)
@app.post('/api/integrations/feishu/logout') @app.post('/api/integrations/feishu/logout')
async def feishu_logout(payload: FeishuAccountRequest) -> dict[str, Any]: async def feishu_logout(payload: FeishuAccountRequest) -> dict[str, Any]:
@@ -1684,6 +2095,20 @@ def create_app(state: AgentState) -> FastAPI:
except OSError as exc: except OSError as exc:
raise HTTPException(status_code=400, detail=f'读取文件失败: {exc}') raise HTTPException(status_code=400, detail=f'读取文件失败: {exc}')
except Exception as exc: except Exception as exc:
if _is_feishu_auth_error(str(exc)):
_stop_feishu_login(payload.account_id)
logout_result = _run_feishu_cli(paths, ['logout'], timeout_seconds=30)
status = _feishu_status_payload(state, payload.account_id)
status['logout_output'] = logout_result.get('output')
raise HTTPException(
status_code=409,
detail={
'code': 'feishu_auth_expired',
'message': '飞书登录已过期或未登录,请重新授权后再转换。',
'force_login': True,
'status': status,
},
)
target_name = '飞书表格' if kind == 'sheet' else '飞书文档' target_name = '飞书表格' if kind == 'sheet' else '飞书文档'
raise HTTPException(status_code=502, detail=f'{target_name}创建失败: {exc}') raise HTTPException(status_code=502, detail=f'{target_name}创建失败: {exc}')
url = _extract_first_url(rendered) url = _extract_first_url(rendered)
@@ -3169,8 +3594,42 @@ def _stored_display_messages(
stored: StoredAgentSession, stored: StoredAgentSession,
) -> tuple[dict[str, Any], ...]: ) -> tuple[dict[str, Any], ...]:
if stored.display_messages: if stored.display_messages:
return tuple(dict(message) for message in stored.display_messages) messages = tuple(dict(message) for message in stored.display_messages)
return tuple(dict(message) for message in stored.messages) else:
messages = tuple(dict(message) for message in stored.messages)
return tuple(message for message in messages if _is_visible_display_message(message))
_INTERNAL_DISPLAY_KINDS = {
'compact_boundary',
'compact_summary',
'continuation_request',
'file_history_replay',
'plugin_tool_runtime',
'runtime_context',
'snipped_message',
'system_context',
}
def _is_visible_display_message(message: dict[str, Any]) -> bool:
if message.get('role') == 'system':
return False
metadata = message.get('metadata')
if isinstance(metadata, dict):
kind = metadata.get('kind')
if isinstance(kind, str) and kind in _INTERNAL_DISPLAY_KINDS:
return False
content = message.get('content')
if isinstance(content, str):
stripped = content.strip()
if stripped.startswith('<system-reminder>'):
return False
if message.get('role') == 'user' and stripped.startswith(
'This session is being continued from a previous conversation'
):
return False
return True
def _stored_session_has_incomplete_tail(stored: StoredAgentSession) -> bool: def _stored_session_has_incomplete_tail(stored: StoredAgentSession) -> bool:
@@ -3572,10 +4031,7 @@ def _save_in_progress_session(
stored = load_agent_session(safe_id, directory=directory) stored = load_agent_session(safe_id, directory=directory)
except (FileNotFoundError, OSError, json.JSONDecodeError): except (FileNotFoundError, OSError, json.JSONDecodeError):
return None return None
messages = list(stored.messages)
display_messages = list(_stored_display_messages(stored)) display_messages = list(_stored_display_messages(stored))
if messages and _is_pending_user_message(dict(messages[-1])):
messages.pop()
if display_messages and _is_pending_user_message(dict(display_messages[-1])): if display_messages and _is_pending_user_message(dict(display_messages[-1])):
display_messages.pop() display_messages.pop()
pending_message = { pending_message = {
@@ -3585,9 +4041,6 @@ def _save_in_progress_session(
'metadata': pending_metadata, 'metadata': pending_metadata,
'message_id': f'user_pending_{int(time.time() * 1000)}', 'message_id': f'user_pending_{int(time.time() * 1000)}',
} }
messages.append(
dict(pending_message)
)
display_messages.append(dict(pending_message)) display_messages.append(dict(pending_message))
budget_state = ( budget_state = (
dict(stored.budget_state) dict(stored.budget_state)
@@ -3599,7 +4052,6 @@ def _save_in_progress_session(
save_agent_session( save_agent_session(
replace( replace(
stored, stored,
messages=tuple(messages),
display_messages=tuple(display_messages), display_messages=tuple(display_messages),
budget_state=budget_state, budget_state=budget_state,
), ),
@@ -4352,16 +4804,36 @@ def _run_feishu_cli(
} }
def _is_feishu_auth_error(text: str) -> bool:
lowered = text.lower()
markers = (
'not logged in',
'no credentials',
'unauthorized',
'unauthenticated',
'login expired',
'token expired',
'invalid token',
'access token expired',
'refresh token expired',
'登录已过期',
'授权已过期',
'未登录',
'未授权',
'凭证已过期',
'认证失败',
'请重新登录',
'重新授权',
)
return any(marker in lowered for marker in markers)
def _feishu_status_payload(state: AgentState, account_id: str | None) -> dict[str, Any]: def _feishu_status_payload(state: AgentState, account_id: str | None) -> dict[str, Any]:
paths = _feishu_paths(state, account_id) paths = _feishu_paths(state, account_id)
_reap_feishu_login(account_id) _reap_feishu_login(account_id)
result = _run_feishu_cli(paths, ['status'], timeout_seconds=30) result = _run_feishu_cli(paths, ['status'], timeout_seconds=30)
output = str(result.get('output') or '') output = str(result.get('output') or '')
lowered = output.lower() logged_in = result.get('returncode') == 0 and not _is_feishu_auth_error(output)
logged_in = result.get('returncode') == 0 and not any(
marker in lowered
for marker in ('not logged in', '未登录', 'no credentials')
)
pending = _feishu_login_snapshot(account_id) pending = _feishu_login_snapshot(account_id)
payload: dict[str, Any] = { payload: dict[str, Any] = {
'logged_in': logged_in, 'logged_in': logged_in,
@@ -4378,15 +4850,21 @@ def _feishu_status_payload(state: AgentState, account_id: str | None) -> dict[st
return payload return payload
def _start_feishu_login(state: AgentState, account_id: str | None) -> dict[str, Any]: def _start_feishu_login(
status = _feishu_status_payload(state, account_id) state: AgentState,
if status.get('logged_in'): account_id: str | None,
return status *,
force: bool = False,
) -> dict[str, Any]:
if not force:
status = _feishu_status_payload(state, account_id)
if status.get('logged_in'):
return status
key = _feishu_login_key(account_id) key = _feishu_login_key(account_id)
with _FEISHU_LOGIN_LOCK: with _FEISHU_LOGIN_LOCK:
existing = _FEISHU_LOGIN_PROCESSES.get(key) existing = _FEISHU_LOGIN_PROCESSES.get(key)
if existing is not None and existing.process.poll() is None: if not force and existing is not None and existing.process.poll() is None:
return { return {
'logged_in': False, 'logged_in': False,
'status': 'login_pending', 'status': 'login_pending',
@@ -4395,6 +4873,8 @@ def _start_feishu_login(state: AgentState, account_id: str | None) -> dict[str,
_FEISHU_LOGIN_PROCESSES.pop(key, None) _FEISHU_LOGIN_PROCESSES.pop(key, None)
paths = _feishu_paths(state, account_id) paths = _feishu_paths(state, account_id)
if force:
_run_feishu_cli(paths, ['logout'], timeout_seconds=30)
env = os.environ.copy() env = os.environ.copy()
env.update(_feishu_env(paths)) env.update(_feishu_env(paths))
try: try:
@@ -0,0 +1,148 @@
<svg width="1600" height="900" viewBox="0 0 1600 900" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M2,2 L10,6 L2,10 Z" fill="#64748b"/>
</marker>
<marker id="arrowBlue" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M2,2 L10,6 L2,10 Z" fill="#2563eb"/>
</marker>
<filter id="softShadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#0f172a" flood-opacity="0.10"/>
</filter>
<style>
.title { font: 700 38px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #0f172a; }
.subtitle { font: 400 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #475569; }
.sectionTitle { font: 700 22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #0f172a; }
.stepNo { font: 700 17px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #64748b; }
.stepTitle { font: 700 19px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #0f172a; }
.stepText { font: 400 14px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #475569; }
.badge { font: 700 13px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #9a3412; }
.baseTitle { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #0f172a; }
.baseMain { font: 700 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #1e293b; }
.baseSub { font: 400 18px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #475569; }
.chip { font: 600 15px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #334155; }
.loopLabel { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #2563eb; }
</style>
</defs>
<rect width="1600" height="900" fill="#f8fafc"/>
<text x="800" y="72" text-anchor="middle" class="title">planning 模型数据驱动迭代链路</text>
<text x="800" y="108" text-anchor="middle" class="subtitle">从问题发现到模型改进:数据开发 + AutoResearch + Agent 平台底座的全流程提效</text>
<!-- Main entry: problem discovery -->
<g filter="url(#softShadow)">
<rect x="68" y="220" width="190" height="130" rx="18" fill="#ffffff" stroke="#dbeafe" stroke-width="2"/>
<text x="95" y="253" class="stepNo">1</text>
<text x="163" y="283" text-anchor="middle" class="stepTitle">问题发现</text>
<text x="163" y="314" text-anchor="middle" class="stepText">线上 badcase</text>
<text x="163" y="337" text-anchor="middle" class="stepText">评测错误 / 指标异常</text>
</g>
<!-- Data development module -->
<g filter="url(#softShadow)">
<rect x="300" y="170" width="690" height="230" rx="24" fill="#eef6ff" stroke="#bfdbfe" stroke-width="2"/>
<text x="645" y="205" text-anchor="middle" class="sectionTitle">数据开发提效</text>
<text x="645" y="232" text-anchor="middle" class="stepText">把问题转成可训练、可评测的数据资产</text>
</g>
<!-- AutoResearch module -->
<g filter="url(#softShadow)">
<rect x="1040" y="170" width="455" height="230" rx="24" fill="#f5f3ff" stroke="#ddd6fe" stroke-width="2"/>
<text x="1267" y="205" text-anchor="middle" class="sectionTitle">AutoResearch</text>
<text x="1267" y="232" text-anchor="middle" class="stepText">planning 模型迭代与结构分析</text>
</g>
<!-- Step boxes: data development -->
<g>
<rect x="330" y="265" width="140" height="95" rx="16" fill="#ffffff" stroke="#bfdbfe" stroke-width="1.5"/>
<text x="350" y="292" class="stepNo">2</text>
<text x="400" y="316" text-anchor="middle" class="stepTitle">线上数据挖掘</text>
<text x="400" y="342" text-anchor="middle" class="stepText">rid / session</text>
<text x="400" y="362" text-anchor="middle" class="stepText">prompt / output</text>
</g>
<g>
<rect x="500" y="265" width="150" height="95" rx="16" fill="#fff7ed" stroke="#fed7aa" stroke-width="1.8"/>
<text x="520" y="292" class="stepNo">3</text>
<text x="575" y="316" text-anchor="middle" class="stepTitle">问题分析</text>
<text x="575" y="342" text-anchor="middle" class="stepText">归因 / 标签边界</text>
<rect x="536" y="352" width="78" height="24" rx="12" fill="#ffedd5" stroke="#fdba74"/>
<text x="575" y="369" text-anchor="middle" class="badge">人工 Review</text>
</g>
<g>
<rect x="680" y="265" width="140" height="95" rx="16" fill="#ffffff" stroke="#bfdbfe" stroke-width="1.5"/>
<text x="700" y="292" class="stepNo">4</text>
<text x="750" y="316" text-anchor="middle" class="stepTitle">数据构造</text>
<text x="750" y="342" text-anchor="middle" class="stepText">训练样本</text>
<text x="750" y="362" text-anchor="middle" class="stepText">评测样本 / 边界样例</text>
</g>
<g>
<rect x="850" y="265" width="115" height="95" rx="16" fill="#ffffff" stroke="#bfdbfe" stroke-width="1.5"/>
<text x="868" y="292" class="stepNo">5</text>
<text x="907" y="316" text-anchor="middle" class="stepTitle">格式转换</text>
<text x="907" y="342" text-anchor="middle" class="stepText">CSV / JSONL</text>
<text x="907" y="362" text-anchor="middle" class="stepText">eval set</text>
</g>
<!-- Step boxes: autoresearch -->
<g>
<rect x="1072" y="265" width="155" height="95" rx="16" fill="#ffffff" stroke="#ddd6fe" stroke-width="1.5"/>
<text x="1092" y="292" class="stepNo">6</text>
<text x="1149" y="316" text-anchor="middle" class="stepTitle">planning 模型迭代</text>
<text x="1149" y="342" text-anchor="middle" class="stepText">训练 / 评测</text>
<text x="1149" y="362" text-anchor="middle" class="stepText">配置实验</text>
</g>
<g>
<rect x="1262" y="265" width="200" height="95" rx="16" fill="#fff7ed" stroke="#fed7aa" stroke-width="1.8"/>
<text x="1282" y="292" class="stepNo">7</text>
<text x="1362" y="316" text-anchor="middle" class="stepTitle">结构分析 / 结果分析</text>
<text x="1362" y="342" text-anchor="middle" class="stepText">指标拆解 / 回归验证</text>
<rect x="1323" y="352" width="78" height="24" rx="12" fill="#ffedd5" stroke="#fdba74"/>
<text x="1362" y="369" text-anchor="middle" class="badge">人工 Review</text>
</g>
<!-- Straight arrows -->
<path d="M258 285 L318 285" stroke="#64748b" stroke-width="3" fill="none" marker-end="url(#arrow)"/>
<path d="M470 312 L492 312" stroke="#64748b" stroke-width="3" fill="none" marker-end="url(#arrow)"/>
<path d="M650 312 L672 312" stroke="#64748b" stroke-width="3" fill="none" marker-end="url(#arrow)"/>
<path d="M820 312 L842 312" stroke="#64748b" stroke-width="3" fill="none" marker-end="url(#arrow)"/>
<path d="M965 312 L1064 312" stroke="#64748b" stroke-width="3" fill="none" marker-end="url(#arrow)"/>
<path d="M1227 312 L1254 312" stroke="#64748b" stroke-width="3" fill="none" marker-end="url(#arrow)"/>
<!-- Feedback loop -->
<path d="M1362 402 C1362 500 1070 505 820 505 C465 505 163 485 163 365" stroke="#2563eb" stroke-width="3" stroke-dasharray="8 8" fill="none" marker-end="url(#arrowBlue)"/>
<text x="760" y="486" text-anchor="middle" class="loopLabel">新 badcase / 新边界 / 新样本 / 新策略回流</text>
<!-- Platform base -->
<g filter="url(#softShadow)">
<rect x="90" y="570" width="1410" height="210" rx="28" fill="#ffffff" stroke="#cbd5e1" stroke-width="2"/>
<text x="795" y="615" text-anchor="middle" class="baseTitle">ZK Data Agent 平台底座</text>
<text x="795" y="663" text-anchor="middle" class="baseMain">Agent Loop + Skill + 工具链 + 记忆 + Jupyter 工作区</text>
<text x="795" y="699" text-anchor="middle" class="baseSub">通过 Agent Loop 调度 Skill 和工具,连接线上数据、执行环境与产物沉淀</text>
<g>
<rect x="205" y="725" width="140" height="34" rx="17" fill="#f1f5f9"/>
<text x="275" y="748" text-anchor="middle" class="chip">线上日志工具</text>
<rect x="375" y="725" width="120" height="34" rx="17" fill="#f1f5f9"/>
<text x="435" y="748" text-anchor="middle" class="chip">标签知识</text>
<rect x="525" y="725" width="150" height="34" rx="17" fill="#f1f5f9"/>
<text x="600" y="748" text-anchor="middle" class="chip">数据格式转换</text>
<rect x="705" y="725" width="120" height="34" rx="17" fill="#f1f5f9"/>
<text x="765" y="748" text-anchor="middle" class="chip">会话沉淀</text>
<rect x="855" y="725" width="130" height="34" rx="17" fill="#f1f5f9"/>
<text x="920" y="748" text-anchor="middle" class="chip">运行态观测</text>
<rect x="1015" y="725" width="155" height="34" rx="17" fill="#f1f5f9"/>
<text x="1092" y="748" text-anchor="middle" class="chip">账号 / 工作区隔离</text>
<rect x="1200" y="725" width="160" height="34" rx="17" fill="#f1f5f9"/>
<text x="1280" y="748" text-anchor="middle" class="chip">训练评测工具链</text>
</g>
</g>
<!-- Thin connector from business flow to base -->
<path d="M795 526 L795 565" stroke="#94a3b8" stroke-width="2" stroke-dasharray="5 6"/>
<text x="825" y="548" class="stepText">平台能力支撑全链路</text>
</svg>

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

@@ -0,0 +1,133 @@
<svg width="1600" height="900" viewBox="0 0 1600 900" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M2,2 L10,6 L2,10 Z" fill="#64748b"/>
</marker>
<marker id="arrowBlue" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M2,2 L10,6 L2,10 Z" fill="#2563eb"/>
</marker>
<marker id="arrowPurple" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M2,2 L10,6 L2,10 Z" fill="#7c3aed"/>
</marker>
<filter id="softShadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#0f172a" flood-opacity="0.10"/>
</filter>
<style>
.title { font: 700 38px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #0f172a; }
.subtitle { font: 400 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #475569; }
.sectionTitle { font: 700 22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #0f172a; }
.sectionSub { font: 400 14px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #64748b; }
.stepNo { font: 700 17px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #64748b; }
.stepTitle { font: 700 19px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #0f172a; }
.stepText { font: 400 14px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #475569; }
.badge { font: 700 13px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #9a3412; }
.loopLabel { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #2563eb; }
.iterLabel { font: 700 16px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #6d28d9; }
.base { font: 600 18px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; fill: #475569; }
</style>
</defs>
<rect width="1600" height="900" fill="#f8fafc"/>
<text x="800" y="76" text-anchor="middle" class="title">planning 模型数据驱动迭代链路</text>
<text x="800" y="112" text-anchor="middle" class="subtitle">从问题发现到模型改进:数据开发 + AutoResearch 的全流程提效</text>
<!-- Step 1 -->
<g filter="url(#softShadow)">
<rect x="70" y="265" width="185" height="120" rx="18" fill="#ffffff" stroke="#dbeafe" stroke-width="2"/>
<text x="94" y="297" class="stepNo">1</text>
<text x="162" y="323" text-anchor="middle" class="stepTitle">问题发现</text>
<text x="162" y="351" text-anchor="middle" class="stepText">线上 badcase</text>
<text x="162" y="373" text-anchor="middle" class="stepText">评测错误 / 指标异常</text>
</g>
<!-- Data development module -->
<g filter="url(#softShadow)">
<rect x="300" y="190" width="640" height="250" rx="24" fill="#eef6ff" stroke="#bfdbfe" stroke-width="2"/>
<text x="620" y="226" text-anchor="middle" class="sectionTitle">数据开发提效</text>
<text x="620" y="252" text-anchor="middle" class="sectionSub">把问题转成可训练、可评测的数据资产</text>
</g>
<!-- Data development steps -->
<g>
<rect x="335" y="300" width="150" height="105" rx="16" fill="#ffffff" stroke="#bfdbfe" stroke-width="1.5"/>
<text x="358" y="329" class="stepNo">2</text>
<text x="410" y="352" text-anchor="middle" class="stepTitle">线上数据挖掘</text>
<text x="410" y="379" text-anchor="middle" class="stepText">rid / session</text>
<text x="410" y="399" text-anchor="middle" class="stepText">prompt / output</text>
</g>
<g>
<rect x="535" y="300" width="155" height="105" rx="16" fill="#fff7ed" stroke="#fed7aa" stroke-width="1.8"/>
<text x="558" y="329" class="stepNo">3</text>
<text x="612" y="352" text-anchor="middle" class="stepTitle">问题分析</text>
<text x="612" y="379" text-anchor="middle" class="stepText">归因 / 标签边界</text>
<rect x="573" y="389" width="78" height="24" rx="12" fill="#ffedd5" stroke="#fdba74"/>
<text x="612" y="406" text-anchor="middle" class="badge">人工 Review</text>
</g>
<g>
<rect x="740" y="300" width="165" height="105" rx="16" fill="#ffffff" stroke="#bfdbfe" stroke-width="1.5"/>
<text x="763" y="329" class="stepNo">4</text>
<text x="822" y="352" text-anchor="middle" class="stepTitle">数据构造</text>
<text x="822" y="379" text-anchor="middle" class="stepText">样本生成 / 格式转换</text>
<text x="822" y="399" text-anchor="middle" class="stepText">训练集 / 评测集</text>
</g>
<!-- AutoResearch module -->
<g filter="url(#softShadow)">
<rect x="990" y="190" width="450" height="250" rx="24" fill="#f5f3ff" stroke="#ddd6fe" stroke-width="2"/>
<text x="1215" y="226" text-anchor="middle" class="sectionTitle">AutoResearch</text>
<text x="1215" y="252" text-anchor="middle" class="sectionSub">planning 模型迭代与结构分析</text>
</g>
<!-- AutoResearch steps -->
<g>
<rect x="1028" y="300" width="165" height="105" rx="16" fill="#ffffff" stroke="#ddd6fe" stroke-width="1.5"/>
<text x="1051" y="329" class="stepNo">5</text>
<text x="1110" y="352" text-anchor="middle" class="stepTitle">planning 模型迭代</text>
<text x="1110" y="379" text-anchor="middle" class="stepText">训练 / 评测</text>
<text x="1110" y="399" text-anchor="middle" class="stepText">配置实验</text>
</g>
<g>
<rect x="1248" y="300" width="160" height="105" rx="16" fill="#fff7ed" stroke="#fed7aa" stroke-width="1.8"/>
<text x="1271" y="329" class="stepNo">6</text>
<text x="1328" y="352" text-anchor="middle" class="stepTitle">结构分析 / 结果分析</text>
<text x="1328" y="379" text-anchor="middle" class="stepText">指标拆解 / 回归验证</text>
<rect x="1289" y="389" width="78" height="24" rx="12" fill="#ffedd5" stroke="#fdba74"/>
<text x="1328" y="406" text-anchor="middle" class="badge">人工 Review</text>
</g>
<!-- Step 7 online -->
<g filter="url(#softShadow)">
<rect x="1128" y="520" width="175" height="100" rx="18" fill="#ffffff" stroke="#cbd5e1" stroke-width="2"/>
<text x="1152" y="550" class="stepNo">7</text>
<text x="1215" y="576" text-anchor="middle" class="stepTitle">模型上线</text>
<text x="1215" y="604" text-anchor="middle" class="stepText">发布 / 观察 / 回流</text>
</g>
<!-- Main arrows -->
<path d="M255 325 L326 325" stroke="#64748b" stroke-width="3" fill="none" marker-end="url(#arrow)"/>
<path d="M485 352 L526 352" stroke="#64748b" stroke-width="3" fill="none" marker-end="url(#arrow)"/>
<path d="M690 352 L731 352" stroke="#64748b" stroke-width="3" fill="none" marker-end="url(#arrow)"/>
<path d="M905 352 L1019 352" stroke="#64748b" stroke-width="3" fill="none" marker-end="url(#arrow)"/>
<!-- Iteration loop between 5 and 6 -->
<path d="M1195 325 C1222 292 1250 292 1275 319" stroke="#7c3aed" stroke-width="3" fill="none" marker-end="url(#arrowPurple)"/>
<path d="M1276 385 C1248 424 1218 424 1194 391" stroke="#7c3aed" stroke-width="3" fill="none" marker-end="url(#arrowPurple)"/>
<text x="1218" y="286" text-anchor="middle" class="iterLabel">循环迭代</text>
<!-- From 6 to 7 -->
<path d="M1328 405 C1328 480 1260 498 1225 514" stroke="#64748b" stroke-width="3" fill="none" marker-end="url(#arrow)"/>
<!-- Feedback loop from 7 to 1 -->
<path d="M1128 570 C920 690 520 690 270 585 C175 545 142 470 154 395" stroke="#2563eb" stroke-width="3" stroke-dasharray="8 8" fill="none" marker-end="url(#arrowBlue)"/>
<text x="650" y="660" text-anchor="middle" class="loopLabel">新 badcase / 新边界 / 新样本 / 新策略回流</text>
<!-- Light platform support bar -->
<g>
<rect x="140" y="752" width="1320" height="58" rx="18" fill="#f1f5f9" stroke="#e2e8f0" stroke-width="1"/>
<text x="800" y="788" text-anchor="middle" class="base">ZK Data Agent 支撑:通过 Agent Loop 调度 Skill 和工具,连接线上数据、执行环境与产物沉淀</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

+145
View File
@@ -0,0 +1,145 @@
# 从个人 Agent 到团队 Agent:沉淀可复用的 AI 提效探索
## 平台地址
- 平台入口:http://10.189.47.6/
- 平台文档:http://10.189.47.6/doc
## Git 地址
- 仓库:https://git.n.xiaomi.com/wuyang6/zk-data-agent.git
现有 Skill 数量 12 个,涵盖线上数据挖掘、数据生成、日志分析、标签知识、模型打标、训练发起 / 评测等业务功能,提升组内工作效率。
## 1. 为什么要做这个
个人 AI 提效已经发生了,下一步要解决的是团队复用和流程沉淀。
[图片]
最初的目标,是提升中控 planning 模型迭代中的数据开发效率。
- 日志挖掘、问题分析、数据构造、数据格式化....
做的过程中发现,我们发现低效并不只是某个步骤慢,而是三类能力分散在个人手里:
- 业务知识分散:哪些日志表可查、哪些字段有用、标签边界怎么判断、问题应该怎么归因。
- 执行工具分散:脚本、参数、路径、格式转换逻辑和产物规范各自维护。
- AI 经验分散:有效的 AI 流程、会话历史、修正经验、个人偏好难以共享和继承。
类似情况也存在于评测、训练、结果分析、问题排查、线上配置修改等工作中,用于数据开发提效的逻辑也同样适用...
所以,这个项目逐渐从一个数据开发提效实践,演化成一个中控数据 Agent:把已经验证有效的业务知识、执行工具和 AI 经验,整理成可以共享、组合和继续迭代的 Skill 与工具链。
## 2. 目前做了什么
中控数据 Agent 以 Agent Loop 作为执行底座,以业务 Skill 作为核心组织方式,再通过工作区、工具链和记忆机制,把个人经验沉淀成团队可以继续使用和改造的能力。
系统的架构:http://10.189.47.6/doc
[图片]
### 2.1 Skill 热更新与同步
平台支持在页面中更新 Skill:当有人新增或修改 Skill 后,可以通过 Skill 面板同步到当前服务,让新的能力进入模型可见列表。这样业务能力可以跟随实际需求快速迭代,不需要每次都改核心 Agent 代码。
这一点适合团队协作:
- 个人可以先在自己的分支里开发 Skill;
- 稳定后同步到平台;
- 其他人可以直接启用和复用;
- Skill 可以继续被修改、补充和演进。
[图片]
### 2.2 记忆机制
平台目前把记忆分成两类:用户记忆和 Skill 记忆。
- 用户记忆:记录个人偏好、常用环境、输出习惯等。
- Skill 记忆:记录某个 Skill 使用过程中积累的经验、边界和常见坑。
这类记忆可以在页面中查看和编辑。使用次数越多,某个 Skill 相关的经验越容易被沉淀下来,后续类似任务可以少重复解释。
和常见的“全局用户偏好记忆”相比,这里的重点是把记忆绑定到业务能力上。
例如线上挖掘中,哪些字段更可靠、session 应该怎么抽、哪些输出格式容易出错,这些经验更适合沉淀到对应 Skill,而不是混在一个全局记忆里。
[图片]
典型记忆方法的对比:
| 记忆方案 | 主要解决什么 | 典型做法 | 对我们的启发 | ZK Data Agent 的取舍 |
|---|---|---|---|---|
| ChatGPT Memory | 个人助手的长期个性化 | 从用户对话、历史聊天、文件、记忆摘要等来源形成个性化上下文;用户可以查看 Memory Sources、纠正、删除或关闭记忆。OpenAI 文档也强调,删除某条记忆并不等于删除所有来源,完整删除需要处理过去聊天、文件、连接应用等源头。(OpenAI Help Center) | 记忆必须用户可管理、可纠正、可删除、最好能追踪来源。 | 保留“用户记忆”,记录个人偏好、输出习惯、常用约定 |
| Claude Code / CLAUDE.md + Auto Memory | 项目规则、工程约定、代码库经验沉淀 | CLAUDE.md 保存显式项目规则;Auto Memory 使用 MEMORY.md 作为索引,并把详细内容拆到 debugging.md、api-conventions.md 等主题文件。Claude Code 会在启动时加载 MEMORY.md 前 200 行或 25KB,主题文件按需读取;这些 memory 文件是 Markdown,可编辑、可删除。(Claude Platform Docs) | 记忆应该透明、文件化、可编辑、可版本化;同时要用“索引 + 主题文件”避免全量注入。 | 记忆正文落到 Markdown,用户可在页面编辑 |
| LangGraph / LangMem | Agent 的会话状态、长期记忆、异步整理 | LangGraph 区分 thread-scoped short-term memory 和跨 session 的 long-term memory;长期记忆通过 namespace 隔离。LangMem 则提供从对话中抽取、合并、更新长期记忆的工具,并支持 hot path 写入和 background memory manager。(LangChain 文档) (LangChain AI) | 记忆需要分层:会话状态、用户长期记忆、Skill 规则、历史经验不是一类东西;写记忆也应区分同步和异步。 | 第一阶段不先上复杂检索框架,但借鉴其结构:主链路只记录候选记忆事件,后台异步整理;注入时按 user_id + skill_id + task_type 精准选择。 |
| Mem0 | 通用 AI 应用 / Agent 的持久记忆层 | Mem0 把记忆分为 conversation、session、user、organizational 等层;通过 user_id、run_id、metadata 做作用域隔离,并在查询时合并召回不同层级的记忆。(Mem0) | 记忆的 scope 和生命周期应该是数据模型的一等字段,而不是临时写进 prompt。 | 当前先按用户和 Skill 精准注入,不先上复杂检索层 |
| Zep | 企业级 Agent Memory、时序知识图谱、动态事实更新 | Zep 从聊天、业务数据、文档、JSON 等来源构建 temporal Context Graph;图中包含实体、关系和事实,事实变更后会 invalidating outdated facts,同时保留历史。它还会生成 token-efficient Context Block 给 Agent 使用。(Zep) | 对业务 Agent 很重要的一点是:记忆不能只追加,还要能处理事实过期、口径变化、历史保留、当前有效状态。 | 采用后台异步整理,避免影响主任务执行 |
| ZK Data Agent | 团队业务 Skill 的持续复用 | 用户记忆 + Skill 核心记忆 + Skill 归档记忆 + 异步整理队列 + Markdown 可编辑。主任务执行时,只注入当前用户和当前 Skill 相关的高优先级记忆。 | 核心目标不是“什么都记住”,而是把反复纠正、反复复用、业务相关的经验沉淀到对应 Skill 上。 | 第一阶段采用轻量、可控方案:Markdown 可编辑、按 Skill 精准注入、后台异步整理、作用域隔离。 |
### 2.3 Jupyter 工作区入口
很多实际工作最终还是要落到远端环境里执行,例如:
- 修改线上配置
- 发起模型训练-cloudml / 分析训练结果
- 读取 juiceFS 数据和产物
平台支持把 Jupyter / 远端工作区绑定到当前用户或会话。Agent 在执行任务时,可以围绕当前工作区读写文件、运行脚本、整理结果。
这个能力的价值在于:AI 不只停留在本地聊天和文本生成,而是可以进入真实工作环境,衔接已有的数据、脚本和训练流程。
[图片]
## 3. 我们在怎么用
### 3.1 数据挖掘的例子
会话链接:http://10.189.47.6/session/__LOCALID_0lj28j5i
[图片]
[图片]
### 3.2 标签大师的例子
会话链接:
- http://10.189.47.6/session/__LOCALID_HP8pkRE
- http://10.189.47.6/session/__LOCALID_bextxgj0
[图片]
### 3.3 线上正则干预的例子
- http://10.189.47.6/session/__LOCALID_dgjr9shm
## 4. 实际效果
| 场景 | 原流程 | 当前流程 | 粗略提效 | 产出 |
|---|---:|---:|---:|---|
| 线上问题分析 / 评测集构建 | 1~2 天 | 30 分钟内 | 约 16~32 倍 | 目前已构造 5 个评测集,2000+ 数据 |
| 数据构造与格式转换 | 1~2 小时 | 10 分钟内 | 约 612 倍 | |
| 线上配置修改(熟练工) | 20 分钟 | 2 分钟 | 约 10 倍 | |
[图片]
## 5. 总结
这次实践的起点很具体:提升中控 planning 模型迭代中的数据开发效率。
做下来之后,我们验证了一个更通用的方向:很多 AI 提效能力,真正有复用价值的部分,往往不是某一次对话结果,而是背后的业务知识、执行脚本、流程经验和修正记录。
中控数据 Agent 目前做的事情,就是把这些分散能力整理成团队可以复用的 Skill 和工具链:
- 个人经验可以沉淀成 Skill。
- Skill 可以被同步、启用和组合。
- 使用过程中的修正经验可以进入记忆。
- Jupyter 工作区让 Agent 能连接真实执行环境。
后续继续迭代的重点可以放在三件事:
- 扩展更多高频业务 Skill。
- 提升 Skill 记忆和团队经验沉淀质量。
- 把数据开发、评测、训练、线上问题分析这些流程打通得更稳定。
@@ -0,0 +1,451 @@
# 13. Skill 评测实验台设计
## 1. 背景
当前快慢分流优化需要完成一条完整链路:
```text
已有评测集
-> 使用现成 Skill 判断快慢
-> 支持单条和批量运行
-> 对比人工标签和模型判断
-> 查看分歧及判定依据
-> 更新 Skill 或切换模型
-> 重新评测并比较变化
```
这里讨论的是离线优化和评估场景。评测过程允许 Agent 使用完整 Agent Loop,重点是判断质量、依据可追溯和标准持续收敛。线上快慢路由的耗时、成本和部署形态属于后续独立问题。
本设计假设快慢分流 Skill 已经存在。实验台负责稳定调用和评估 Skill,不负责定义 Skill 内部的分类标准。
## 2. 目标
实验台需要支持:
- 单条输入调用指定 Skill,返回判断、理由和执行记录。
- 上传 CSV、XLSX 或 JSONL,配置字段映射后批量运行。
- 选择模型和不可变的 Skill 版本。
- 后台并发执行,支持暂停、继续、取消和失败重试。
- 自动计算总体、分类别和分垂域指标。
- 集中查看人工与模型分歧,并进行人工复核。
- 查看模型声明的 Skill 判定依据和实际访问记录。
- 对比不同 Skill 版本或模型版本的修正与退化。
- 导出原始结果、复核结果和版本对比结果。
实验台不承担:
- 在线请求的实时路由。
- 快系统能力或分类原则的编写。
- 将每个普通 Skill 自动转成页面应用。
- 修改现有聊天 Agent 的会话和运行流程。
## 3. 总体架构
```mermaid
flowchart LR
UI["快慢分流评测页面"] --> EXP["Evaluation Service"]
API["Skill Run API"] --> EXP
EXP --> MAP["Dataset Mapping"]
EXP --> SNAP["Skill Snapshot"]
EXP --> SCHED["Batch Scheduler"]
SCHED --> RUNNER["Headless Skill Runner"]
RUNNER --> AGENT["LocalCodingAgent"]
RUNNER --> MODEL["指定模型"]
RUNNER --> SKILL["固定 Skill 快照"]
EXP --> STORE["Evaluation Store"]
STORE --> RESULT["指标 / 分歧 / 复核 / 版本对比"]
```
新增能力由五个部分组成:
1. `Evaluation Service`:管理数据集、实验配置、运行和结果。
2. `Dataset Mapping`:把不同格式的数据转换为统一 case。
3. `Skill Snapshot`:固定每次实验使用的 Skill 内容。
4. `Headless Skill Runner`:使用现有 Agent Core 在后台执行指定 Skill。
5. `Batch Scheduler`:拆分、并发和恢复批量任务。
## 4. 与现有 Agent 的隔离
现有聊天链路保持不变:
```text
/api/chat
-> AgentState
-> account/session-scoped LocalCodingAgent
-> 聊天 session 和活动流
```
评测链路新增独立入口:
```text
/api/evaluations
-> EvaluationRuntime
-> evaluation-scoped LocalCodingAgent
-> 评测任务、case 结果和内部执行记录
```
两条链路可以复用:
- `LocalCodingAgent`
- `ModelConfig`
- Skill loader
- Tool handler
- `OutputSchemaConfig`
- 模型兼容层
评测链路必须独立管理:
- Agent 实例。
- Session 命名空间。
- 数据库记录。
- 运行队列。
- 并发和资源配额。
- Skill 快照。
评测产生的内部 Agent session 不进入左侧聊天会话列表,也不读取聊天历史、用户记忆或当前聊天 session 状态。
代码隔离只能保证功能不互相污染。批量任务仍可能争用模型服务资源,因此评测任务需要独立 worker pool、并发上限和低于聊天请求的调度优先级。有条件时可以为评测配置独立模型地址。
## 5. 数据集字段映射
评测文件格式由数据来源决定,不应写进 Skill,也不应要求用户临时组织成提示词。
上传文件后,系统先读取表头和样本,用户把源字段映射到统一字段:
| 统一字段 | 说明 | 必填 |
|---|---|---|
| `case_id` | case 唯一标识 | 否,可自动生成 |
| `query` | 当前用户 query | 是 |
| `gold_route` | 人工快慢标签 | 评测时必填 |
| `history` | 对话历史 | 否 |
| `context` | 设备、位置等上下文 | 否 |
| `domain` | 垂域 | 否 |
| `request_id` | 线上 RID | 否 |
| `metadata` | 其他保留字段 | 否 |
| `source_row` | 原始行 | 自动保留 |
页面需要支持:
- 列名自动推荐。
- 手动选择源字段。
- JSON path,例如 `data.query`
- 标签值转换,例如 `快/慢``0/1``fast/slow`
- 必要的字段组合和简单转换。
- 转换后样本预览。
- 缺失字段、非法标签和重复 ID 校验。
确认后的映射保存为可复用的 `Dataset Mapping Profile`。Skill Runner 只接收统一 case,不感知原始文件的列名和格式。
统一 case 示例:
```json
{
"case_id": "001",
"query": "到目的地电量够不够",
"gold_route": "fast",
"history": [],
"context": {},
"domain": "地图导航",
"request_id": "",
"metadata": {},
"source_row": {}
}
```
## 6. Skill 版本与快照
实验不能只记录 Skill 名称。Skill 内容会持续更新,同一个名称在不同时间可能代表不同标准。
每次创建实验时生成不可变快照,并记录:
```json
{
"skill_name": "fast-slow-routing",
"repository": "zk-data-agent",
"git_commit": "abc1234",
"content_hash": "sha256:...",
"snapshot_id": "skill_snapshot_xxx",
"status": "committed"
}
```
版本来源可以是:
- 当前部署版本。
- 指定 Git commit。
- 指定 Git tag。
- 尚未提交的草稿快照。
快照建议存放在:
```text
.port_sessions/evaluations/skill_snapshots/
<skill-name>/
<commit-or-content-hash>/
```
运行旧版本时不能切换当前项目分支,也不能覆盖线上 Skill。对于 Git 中的历史版本,可以通过 `git archive` 提取单个 Skill 目录到快照缓存。
草稿版本允许参与实验,但页面必须明确标记“未提交”,并使用内容哈希保证同一次实验可复现。
评测 Agent 始终从快照加载 Skill;聊天 Agent 继续从当前部署目录加载 Skill,两者互不影响。
## 7. Headless Skill Runner
`Headless` 表示没有聊天页面,不表示减少 Agent 能力。
Runner 接收:
```json
{
"skill_snapshot_id": "skill_snapshot_xxx",
"model_id": "model_xxx",
"case": {
"case_id": "001",
"query": "到目的地电量够不够"
}
}
```
Runner 的执行过程:
1. 从快照读取指定 Skill。
2. 创建独立的评测 Agent 实例和 session。
3. 直接把指定 Skill 注入当前任务,不等待模型自行召回 Skill。
4. 把统一 case 作为任务输入。
5. 按 Skill 约定执行完整 Agent Loop。
6. 保存最终结果、执行事件、工具调用和用量。
7. 将最终输出归一化为评测结果。
统一评测结果至少包含:
```json
{
"case_id": "001",
"prediction": "fast",
"reason": "当前快系统已有对应能力",
"confidence": 0.95,
"evidence_refs": [
"knowledge/地图能力.md#到达电量预估"
],
"status": "completed",
"raw_output": "",
"agent_session_id": ""
}
```
单条判断创建一个 Agent Run。批量判断把每条 case 拆成独立 Agent Run,避免样本间上下文污染。
## 8. 批量调度
批量调度器负责:
- 把数据集拆成独立 case 任务。
- 使用受控并发执行。
- 保存每条 case 的状态和重试次数。
- 单条失败不终止整个实验。
- 支持暂停、继续和取消。
- 服务重启后从数据库恢复未完成任务。
- 只重跑失败项、分歧项或用户选中的 case。
任务状态:
```text
pending
running
completed
failed
cancelled
```
聊天请求和评测请求需要独立并发池。评测批量任务默认低优先级,避免影响当前在线使用者。
## 9. 判定依据与可追溯性
页面需要回答“模型依据 Skill 的哪部分作出判断”,但不能把完整模型思考当作可靠因果证据。
实验台展示两类事实:
1. **实际访问记录**Agent 读取了哪些 Skill 文件、知识文件和章节。
2. **模型声明的判定依据**:最终结果中的 `evidence_refs`
生成 Skill 快照时,系统为 Markdown 标题和知识文件建立稳定引用,例如:
```text
SKILL.md#判断流程
knowledge/地图能力.md#到达电量预估
knowledge/边界.md#开放式行程规划
```
评测 Runner 的统一任务约定要求最终结果返回引用。页面将引用解析为可点击的原文片段。
如果模型没有引用明确规则,页面展示“未引用具体依据”,并允许按此条件筛选。这类 case 本身就是标准缺失或模型未正确使用 Skill 的候选问题。
## 10. 页面信息架构
页面采用“实验配置、结果页签、单条详情”三层结构。
### 10.1 实验配置
顶部配置区包含:
- 数据集。
- 字段映射。
- Skill 和版本。
- 模型。
- case 数量。
- 并发配置。
- 开始、暂停、继续、取消、克隆实验。
运行开始后配置区折叠,避免占用结果空间。
### 10.2 结果页签
| 页签 | 主要内容 |
|---|---|
| 概览 | 运行进度、总体指标、混淆矩阵、快慢分布、分垂域指标 |
| 分歧 | 人工标签与模型判断不一致、低置信、无明确依据的 case |
| 全部 Case | 全量结果、状态、筛选、排序和导出 |
| 版本对比 | 不同 Skill 或模型实验之间的修正、退化和指标变化 |
### 10.3 单条详情抽屉
点击一条 case 后展示:
1. 原始数据和统一后的输入。
2. 人工标签、模型判断、置信度和理由。
3. Skill 判定依据及原文片段。
4. 实际读取文件和工具调用。
5. 默认折叠的完整 Agent 执行记录。
6. 人工复核结果和备注。
人工复核至少支持:
- 人工原标签正确。
- 模型判断正确。
- 分类标准存在歧义。
- 快系统能力信息缺失。
- 暂不确定。
## 11. 实验与版本对比
一个实验由以下对象共同确定:
```text
数据集版本
+ 字段映射版本
+ Skill 快照
+ 模型版本
+ 执行参数
```
同一数据集可以克隆实验并替换 Skill 或模型:
```text
实验 ASkill abc1234 + 模型 A
实验 BSkill def5678 + 模型 A
实验 CSkill def5678 + 模型 B
```
版本对比需要展示:
- 总体指标变化。
- 各垂域指标变化。
- 从错误变正确的 case。
- 从正确变错误的 case。
- 判断未变化但理由变化的 case。
- 新增或消失的无依据结果。
这样才能形成“评测、发现分歧、更新 Skill、重新评测”的优化闭环。
## 12. 数据存储
评测运行状态适合使用独立 SQLite 数据库,例如:
```text
.port_sessions/evaluations/evaluations.db
```
核心实体:
```text
evaluation_datasets
dataset_mapping_profiles
skill_snapshots
evaluation_experiments
evaluation_cases
evaluation_case_runs
evaluation_results
evaluation_reviews
```
数据库保存可查询状态、关系和人工复核结果。上传原文件、Skill 快照、导出文件和较大的 Agent transcript 保存在实验目录,数据库记录路径和摘要。
## 13. API 轮廓
```text
POST /api/evaluations/datasets
POST /api/evaluations/datasets/{id}/mapping
POST /api/evaluations/skill-snapshots
GET /api/evaluations/skill-snapshots
POST /api/evaluations
GET /api/evaluations/{id}
POST /api/evaluations/{id}/start
POST /api/evaluations/{id}/pause
POST /api/evaluations/{id}/resume
POST /api/evaluations/{id}/cancel
GET /api/evaluations/{id}/results
POST /api/evaluations/{id}/retry
POST /api/evaluations/{id}/reviews
GET /api/evaluations/{id}/export
POST /api/evaluations/compare
```
API 同时服务 Web 页面和外部批量调用。单条调用可以创建只包含一个 case 的轻量实验,复用相同运行与记录机制。
## 14. 实施顺序
### P1:后台单条运行
- Skill 快照。
- Headless Skill Runner。
- 单条输入和统一结果。
- 独立于聊天 session 的运行记录。
### P2:数据集与批量调度
- 文件上传和字段映射。
- 批量拆分、并发、暂停、恢复和重试。
- 基础指标和导出。
### P3:评测页面
- 实验配置。
- 概览、分歧、全部 Case。
- 单条详情和人工复核。
- Skill 依据引用。
### P4:版本对比
- 克隆实验。
- Skill 和模型版本矩阵。
- 修正、退化和理由变化分析。
## 15. 验收标准
- 开启评测任务后,现有聊天 Agent 可以正常创建、继续和停止会话。
- 600 条批量任务不会出现在聊天会话列表。
- 不同输入格式可以通过字段映射转换为统一 case。
- 每次实验可以确认具体 Skill commit 或内容哈希。
- Skill 更新后,历史实验仍能读取原快照并复现配置。
- 单条失败不会中断全量任务,服务重启后可以恢复。
- 页面可以定位人工与模型分歧,并查看简短理由和 Skill 引用。
- 可以比较两个实验的修正项和退化项。
- 批量评测的并发不会明显拖慢聊天 Agent。
+1
View File
@@ -16,6 +16,7 @@
9. [外部系统 Skill:ELK、SQL、模型迭代](09-external-skills.md) 9. [外部系统 Skill:ELK、SQL、模型迭代](09-external-skills.md)
10. [Agent 记忆机制调研与对比](10-memory-research.md) 10. [Agent 记忆机制调研与对比](10-memory-research.md)
11. [运行中输入队列与 Runtime Guidance 注入](12-runtime-guidance-queue.md) 11. [运行中输入队列与 Runtime Guidance 注入](12-runtime-guidance-queue.md)
12. [Skill 评测实验台设计](13-skill-evaluation-workbench.md)
## 一句话定位 ## 一句话定位
@@ -0,0 +1,69 @@
import { readFile } from "node:fs/promises";
import { resolveAnnotationPage } from "@/lib/annotation-pages";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ name: string }> },
) {
const { name } = await params;
const filePath = resolveAnnotationPage(name);
if (!filePath)
return new Response("Invalid annotation page", { status: 400 });
try {
const content = await readFile(filePath, "utf8");
return new Response(injectSubmissionBridge(content, name), {
headers: {
"cache-control": "no-store",
"content-type": "text/html; charset=utf-8",
"x-content-type-options": "nosniff",
},
});
} catch {
return new Response("Annotation page not found", { status: 404 });
}
}
function injectSubmissionBridge(content: string, pageName: string) {
if (!content.includes("</body>") || !content.includes("mergedRows")) {
return content;
}
const safePageName = JSON.stringify(pageName).replaceAll("<", "\\u003c");
const bridge = `
<script>
window.__annotationSubmissionBridge = {
pageName: ${safePageName},
read: () => mergedRows().map(row => ({
requestId: String(row.request_id || ""),
result: String(row.GSB || ""),
note: String(row["人工备注"] || "")
})),
apply: items => {
for (const row of rows || []) {
if (row?.request_id) delete saved[row.request_id];
}
for (const item of items || []) {
if (!item || !item.requestId) continue;
saved[item.requestId] = { GSB: item.result || "", note: item.note || "" };
}
persist();
render();
},
applyMissing: items => {
for (const item of items || []) {
if (!item || !item.requestId || saved[item.requestId]) continue;
saved[item.requestId] = { GSB: item.result || "", note: item.note || "" };
}
persist();
render();
},
notify: message => toast(message)
};
</script>
<script src="/annotation-submissions.js"></script>
`;
return content.replace(/<\/body>/i, `${bridge}</body>`);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import {
ArrowLeftIcon,
ExternalLinkIcon,
FileTextIcon,
RefreshCwIcon,
SearchIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type AnnotationPageInfo = {
name: string;
title: string;
size: number;
modifiedAt: string;
href: string;
};
export default function AnnotationsPage() {
const [items, setItems] = useState<AnnotationPageInfo[]>([]);
const [query, setQuery] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
setIsLoading(true);
setError("");
try {
const response = await fetch("/api/annotations", { cache: "no-store" });
const payload = (await response.json()) as {
items?: AnnotationPageInfo[];
error?: string;
};
if (!response.ok) throw new Error(payload.error ?? "加载失败");
setItems(payload.items ?? []);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "加载失败");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const filteredItems = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase("zh-CN");
if (!keyword) return items;
return items.filter((item) =>
`${item.title}\n${item.name}`
.toLocaleLowerCase("zh-CN")
.includes(keyword),
);
}, [items, query]);
return (
<main className="min-h-dvh bg-[#f6f8fb] text-slate-900">
<header className="border-slate-200 border-b bg-white">
<div className="mx-auto flex w-full max-w-6xl items-center gap-3 px-5 py-4 sm:px-8">
<Button variant="ghost" size="icon" asChild title="返回工作台">
<a href="/">
<ArrowLeftIcon />
</a>
</Button>
<div className="min-w-0 flex-1">
<h1 className="font-semibold text-xl"></h1>
<p className="mt-0.5 text-slate-500 text-sm">
{isLoading ? "正在读取" : `${items.length} 个页面`}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void refresh()}
disabled={isLoading}
>
<RefreshCwIcon className={isLoading ? "animate-spin" : ""} />
</Button>
</div>
</header>
<section className="mx-auto w-full max-w-6xl px-5 py-6 sm:px-8">
<div className="relative mb-4 max-w-md">
<SearchIcon className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 size-4 text-slate-400" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索页面"
className="bg-white pl-9"
/>
</div>
<div className="overflow-hidden border-slate-200 border-y bg-white sm:rounded-md sm:border">
<div className="hidden grid-cols-[minmax(0,1fr)_140px_100px_44px] gap-4 border-slate-200 border-b bg-slate-50 px-4 py-2 font-medium text-slate-500 text-xs sm:grid">
<span></span>
<span></span>
<span></span>
<span />
</div>
{error ? (
<div className="px-4 py-10 text-center text-red-600 text-sm">
{error}
</div>
) : !isLoading && filteredItems.length === 0 ? (
<div className="px-4 py-14 text-center text-slate-500 text-sm">
</div>
) : (
<ul className="divide-y divide-slate-100">
{filteredItems.map((item) => (
<li key={item.name}>
<a
href={item.href}
target="_blank"
rel="noreferrer"
className="group grid min-h-18 grid-cols-[36px_minmax(0,1fr)_32px] items-center gap-3 px-4 py-3 transition-colors hover:bg-blue-50/60 sm:grid-cols-[36px_minmax(0,1fr)_140px_100px_32px]"
>
<span className="grid size-9 place-items-center rounded-md bg-blue-50 text-blue-600">
<FileTextIcon className="size-4" />
</span>
<span className="min-w-0">
<strong className="block truncate font-medium text-sm">
{item.title}
</strong>
<span className="mt-1 block truncate text-slate-500 text-xs">
{item.name}
</span>
</span>
<span className="hidden text-slate-500 text-sm sm:block">
{formatDate(item.modifiedAt)}
</span>
<span className="hidden text-slate-500 text-sm sm:block">
{formatSize(item.size)}
</span>
<ExternalLinkIcon className="size-4 text-slate-400 transition-colors group-hover:text-blue-600" />
</a>
</li>
))}
</ul>
)}
</div>
</section>
</main>
);
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
@@ -0,0 +1,62 @@
import {
createAnnotationSubmission,
readAnnotationSubmission,
readAnnotationSubmissions,
} from "@/lib/annotation-pages";
import { getCurrentAccount } from "@/lib/claw-auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ name: string }> },
) {
try {
const { name } = await params;
const revisionId = new URL(request.url).searchParams.get("revision");
if (revisionId) {
const submission = await readAnnotationSubmission(name, revisionId);
if (!submission) return errorResponse("提交记录不存在", 404);
return jsonResponse({ submission });
}
return jsonResponse(await readAnnotationSubmissions(name));
} catch (error) {
return errorResponse(errorMessage(error), 404);
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ name: string }> },
) {
const account = await getCurrentAccount();
if (!account) return errorResponse("请先登录工作台再提交", 401);
try {
const { name } = await params;
const body = (await request.json()) as { annotations?: unknown };
const submission = await createAnnotationSubmission(
name,
account,
body.annotations,
);
return jsonResponse({ submission }, 201);
} catch (error) {
return errorResponse(errorMessage(error), 400);
}
}
function jsonResponse(value: unknown, status = 200) {
return Response.json(value, {
status,
headers: { "cache-control": "no-store" },
});
}
function errorResponse(error: string, status: number) {
return jsonResponse({ error }, status);
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "操作失败";
}
+21
View File
@@ -0,0 +1,21 @@
import { listAnnotationPages } from "@/lib/annotation-pages";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const items = await listAnnotationPages();
return Response.json(
{ items },
{ headers: { "cache-control": "no-store" } },
);
} catch (error) {
return Response.json(
{
error: error instanceof Error ? error.message : "无法读取标注页面目录",
},
{ status: 500, headers: { "cache-control": "no-store" } },
);
}
}
@@ -0,0 +1,78 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
type RouteContext = {
params: Promise<{ path: string[] }>;
};
export async function GET(request: Request, context: RouteContext) {
return proxyEvaluationRequest("GET", request, context);
}
export async function POST(request: Request, context: RouteContext) {
return proxyEvaluationRequest("POST", request, context);
}
export async function PATCH(request: Request, context: RouteContext) {
return proxyEvaluationRequest("PATCH", request, context);
}
async function proxyEvaluationRequest(
method: "GET" | "POST" | "PATCH",
request: Request,
context: RouteContext,
) {
const account = await getCurrentAccount();
if (!account)
return Response.json({ detail: "unauthorized" }, { status: 401 });
const { path } = await context.params;
const incomingUrl = new URL(request.url);
const targetUrl = new URL(
`${CLAW_API_URL}/api/evaluations/${path.map(encodeURIComponent).join("/")}`,
);
for (const [key, value] of incomingUrl.searchParams.entries()) {
targetUrl.searchParams.append(key, value);
}
const headers: Record<string, string> = {};
let body: string | undefined;
if (method === "GET") {
targetUrl.searchParams.set("account_id", account.id);
} else {
const payload = (await request.json()) as Record<string, unknown>;
headers["content-type"] = "application/json";
body = JSON.stringify({ ...payload, account_id: account.id });
}
try {
const response = await fetch(targetUrl, {
method,
headers,
body,
cache: "no-store",
});
const responseHeaders = new Headers();
responseHeaders.set(
"content-type",
response.headers.get("content-type") ?? "application/json",
);
const disposition = response.headers.get("content-disposition");
if (disposition) responseHeaders.set("content-disposition", disposition);
return new Response(await response.arrayBuffer(), {
status: response.status,
headers: responseHeaders,
});
} catch (error) {
return Response.json(
{
detail:
error instanceof Error
? `Unable to reach Claw backend: ${error.message}`
: "Unable to reach Claw backend",
},
{ status: 502 },
);
}
}
@@ -20,6 +20,7 @@ export async function POST(request: Request) {
const body = (await request.json().catch(() => ({}))) as { const body = (await request.json().catch(() => ({}))) as {
action?: unknown; action?: unknown;
force?: unknown;
}; };
const action = typeof body.action === "string" ? body.action : "login"; const action = typeof body.action === "string" ? body.action : "login";
const endpoint = const endpoint =
@@ -30,7 +31,10 @@ export async function POST(request: Request) {
const response = await fetch(`${CLAW_API_URL}${endpoint}`, { const response = await fetch(`${CLAW_API_URL}${endpoint}`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ account_id: account.id }), body: JSON.stringify({
account_id: account.id,
...(action !== "logout" && body.force === true ? { force: true } : {}),
}),
cache: "no-store", cache: "no-store",
}); });
return proxyResponse(response); return proxyResponse(response);
File diff suppressed because it is too large Load Diff
@@ -574,10 +574,20 @@ function SessionFileRow({ file }: { file: SessionFile }) {
unknown unknown
>; >;
if (response.status === 409) { if (response.status === 409) {
const detail = payload.detail;
const forceLogin =
Boolean(
detail &&
typeof detail === "object" &&
(detail as { force_login?: unknown }).force_login,
) ||
(detail &&
typeof detail === "object" &&
(detail as { code?: unknown }).code === "feishu_auth_expired");
const loginResponse = await fetch("/api/claw/integrations/feishu", { const loginResponse = await fetch("/api/claw/integrations/feishu", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "login" }), body: JSON.stringify({ action: "login", force: forceLogin }),
}); });
const loginPayload = (await loginResponse const loginPayload = (await loginResponse
.json() .json()
@@ -6,6 +6,7 @@ import {
import { ThreadListPrimitive } from "@assistant-ui/react"; import { ThreadListPrimitive } from "@assistant-ui/react";
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import { import {
FlaskConicalIcon,
Loader2Icon, Loader2Icon,
MoreHorizontalIcon, MoreHorizontalIcon,
PencilIcon, PencilIcon,
@@ -148,6 +149,7 @@ export const ThreadList: FC = () => {
return ( return (
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1"> <ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1">
<ThreadListNew /> <ThreadListNew />
<ThreadListEvaluation />
<JupyterWorkspaceList /> <JupyterWorkspaceList />
<ClawSessionList /> <ClawSessionList />
</ThreadListPrimitive.Root> </ThreadListPrimitive.Root>
@@ -174,6 +176,21 @@ const ThreadListNew: FC = () => {
); );
}; };
const ThreadListEvaluation: FC = () => {
return (
<Button
variant="ghost"
className="h-9 w-full justify-start gap-2 rounded-lg px-3 text-sm"
asChild
>
<a href="/evaluations">
<FlaskConicalIcon className="size-4" />
Skill
</a>
</Button>
);
};
type JupyterWorkspaceEntry = { type JupyterWorkspaceEntry = {
id: string; id: string;
label?: string; label?: string;
@@ -1220,7 +1237,7 @@ function resolvePendingPrompt(
: ""; : "";
if (!pendingPrompt) return null; if (!pendingPrompt) return null;
for (const message of messages) { for (const message of messages) {
if (!message || message.role !== "user") continue; if (message?.role !== "user") continue;
const content = cleanStoredContent(message.content ?? "").trim(); const content = cleanStoredContent(message.content ?? "").trim();
if (!content || content.trimStart().startsWith("<system-reminder>")) if (!content || content.trimStart().startsWith("<system-reminder>"))
continue; continue;
@@ -5,6 +5,7 @@ import {
BrainIcon, BrainIcon,
ChevronDownIcon, ChevronDownIcon,
Clock3Icon, Clock3Icon,
FlaskConicalIcon,
LogOutIcon, LogOutIcon,
PanelLeftOpenIcon, PanelLeftOpenIcon,
SaveIcon, SaveIcon,
@@ -215,6 +216,12 @@ function CollapsedSidebarRail({
> >
<SquarePenIcon className="size-4" /> <SquarePenIcon className="size-4" />
</CollapsedIconButton> </CollapsedIconButton>
<CollapsedIconButton
label="Skill 评测"
onClick={() => window.location.assign("/evaluations")}
>
<FlaskConicalIcon className="size-4" />
</CollapsedIconButton>
<CollapsedSessionSearch /> <CollapsedSessionSearch />
<CollapsedRecentSessions /> <CollapsedRecentSessions />
</div> </div>
+297
View File
@@ -0,0 +1,297 @@
import { createHash, randomUUID } from "node:crypto";
import {
mkdir,
open,
readdir,
readFile,
rename,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
export type AnnotationPageInfo = {
name: string;
title: string;
size: number;
modifiedAt: string;
href: string;
};
export type AnnotationValue = {
requestId: string;
result: "G" | "S" | "B" | "";
note: string;
};
export type AnnotationSubmission = {
id: string;
version: number;
pageName: string;
submittedAt: string;
submittedBy: { id: string; username: string };
summary: {
total: number;
completed: number;
G: number;
S: number;
B: number;
};
annotations: AnnotationValue[];
};
export type AnnotationSubmissionSummary = Omit<
AnnotationSubmission,
"annotations"
>;
const annotationPagesRoot = path.resolve(
/* turbopackIgnore: true */
process.env.ANNOTATION_PAGES_DIR?.trim() || "/home/mi/annotation-pages",
);
const annotationSubmissionsRoot = path.resolve(
/* turbopackIgnore: true */
process.env.ANNOTATION_SUBMISSIONS_DIR?.trim() ||
path.join(annotationPagesRoot, ".submissions"),
);
const submissionWriteQueues = new Map<string, Promise<unknown>>();
export async function listAnnotationPages(): Promise<AnnotationPageInfo[]> {
await mkdir(annotationPagesRoot, { recursive: true });
const entries = await readdir(annotationPagesRoot, { withFileTypes: true });
const pages = await Promise.all(
entries
.filter((entry) => entry.isFile() && isSafeHtmlName(entry.name))
.map(async (entry) => {
const filePath = path.join(annotationPagesRoot, entry.name);
const [fileStat, title] = await Promise.all([
stat(filePath),
readHtmlTitle(filePath, entry.name),
]);
return {
name: entry.name,
title,
size: fileStat.size,
modifiedAt: fileStat.mtime.toISOString(),
href: `/annotations/${encodeURIComponent(entry.name)}`,
};
}),
);
return pages.sort(
(left, right) =>
Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt) ||
left.name.localeCompare(right.name, "zh-CN"),
);
}
export function resolveAnnotationPage(name: string): string | null {
if (!isSafeHtmlName(name)) return null;
const resolved = path.resolve(annotationPagesRoot, name);
if (path.dirname(resolved) !== annotationPagesRoot) return null;
return resolved;
}
export async function readAnnotationSubmissions(pageName: string) {
const pagePath = resolveAnnotationPage(pageName);
if (!pagePath) throw new Error("无效的标注页面");
await stat(pagePath);
const pageRoot = submissionPageRoot(pageName);
const latest = await readSubmissionFile(path.join(pageRoot, "latest.json"));
const revisionsRoot = path.join(pageRoot, "revisions");
let names: string[] = [];
try {
names = await readdir(revisionsRoot);
} catch {
return { latest, history: [] as AnnotationSubmissionSummary[] };
}
const history = (
await Promise.all(
names
.filter((name) => name.endsWith(".json"))
.map((name) => readSubmissionFile(path.join(revisionsRoot, name))),
)
)
.filter((item): item is AnnotationSubmission => item !== null)
.sort((left, right) => right.version - left.version)
.slice(0, 100)
.map(withoutAnnotations);
return { latest, history };
}
export async function readAnnotationSubmission(
pageName: string,
submissionId: string,
) {
if (!/^[a-zA-Z0-9_-]{8,120}$/.test(submissionId)) return null;
const pagePath = resolveAnnotationPage(pageName);
if (!pagePath) throw new Error("无效的标注页面");
await stat(pagePath);
return readSubmissionFile(
path.join(
submissionPageRoot(pageName),
"revisions",
`${submissionId}.json`,
),
);
}
export async function createAnnotationSubmission(
pageName: string,
account: { id: string; username: string },
input: unknown,
) {
const pagePath = resolveAnnotationPage(pageName);
if (!pagePath) throw new Error("无效的标注页面");
await stat(pagePath);
const annotations = normalizeAnnotations(input);
return enqueueSubmissionWrite(pageName, async () => {
const pageRoot = submissionPageRoot(pageName);
const revisionsRoot = path.join(pageRoot, "revisions");
await mkdir(revisionsRoot, { recursive: true });
const latest = await readSubmissionFile(path.join(pageRoot, "latest.json"));
const submittedAt = new Date().toISOString();
const id = `${submittedAt.replace(/\D/g, "").slice(0, 17)}-${randomUUID().slice(0, 8)}`;
const submission: AnnotationSubmission = {
id,
version: (latest?.version ?? 0) + 1,
pageName,
submittedAt,
submittedBy: account,
summary: summarizeAnnotations(annotations),
annotations,
};
await atomicWriteJson(path.join(revisionsRoot, `${id}.json`), submission);
await atomicWriteJson(path.join(pageRoot, "latest.json"), submission);
return submission;
});
}
function isSafeHtmlName(name: string): boolean {
return (
name === path.basename(name) &&
!name.startsWith(".") &&
/\.html?$/i.test(name)
);
}
function submissionPageRoot(pageName: string) {
const digest = createHash("sha256")
.update(pageName)
.digest("hex")
.slice(0, 24);
return path.join(annotationSubmissionsRoot, digest);
}
function normalizeAnnotations(input: unknown): AnnotationValue[] {
if (!Array.isArray(input)) throw new Error("annotations 必须是数组");
if (input.length > 20_000) throw new Error("单次提交最多包含 20000 条标注");
const seen = new Set<string>();
return input.map((raw, index) => {
if (!raw || typeof raw !== "object") {
throw new Error(`${index + 1} 条标注格式错误`);
}
const value = raw as Record<string, unknown>;
const requestId = String(value.requestId ?? value.request_id ?? "").trim();
const result = String(value.result ?? value.GSB ?? "").trim();
const note = String(value.note ?? value. ?? "").trim();
if (!requestId || requestId.length > 200) {
throw new Error(`${index + 1} 条缺少有效 request_id`);
}
if (seen.has(requestId)) throw new Error(`request_id 重复:${requestId}`);
if (!["", "G", "S", "B"].includes(result)) {
throw new Error(`${index + 1} 条 GSB 只能是 G、S、B 或空`);
}
if (note.length > 5000) throw new Error(`${index + 1} 条备注过长`);
seen.add(requestId);
return {
requestId,
result: result as AnnotationValue["result"],
note,
};
});
}
function summarizeAnnotations(annotations: AnnotationValue[]) {
const summary = {
total: annotations.length,
completed: 0,
G: 0,
S: 0,
B: 0,
};
for (const annotation of annotations) {
if (!annotation.result) continue;
summary.completed += 1;
summary[annotation.result] += 1;
}
return summary;
}
async function readSubmissionFile(filePath: string) {
try {
return JSON.parse(await readFile(filePath, "utf8")) as AnnotationSubmission;
} catch {
return null;
}
}
function withoutAnnotations(
submission: AnnotationSubmission,
): AnnotationSubmissionSummary {
const { annotations: _annotations, ...summary } = submission;
return summary;
}
async function atomicWriteJson(filePath: string, value: unknown) {
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
await rename(temporaryPath, filePath);
}
function enqueueSubmissionWrite<T>(pageName: string, task: () => Promise<T>) {
const previous = submissionWriteQueues.get(pageName) ?? Promise.resolve();
const current = previous.then(task, task);
submissionWriteQueues.set(pageName, current);
const cleanup = () => {
if (submissionWriteQueues.get(pageName) === current) {
submissionWriteQueues.delete(pageName);
}
};
void current.then(cleanup, cleanup);
return current;
}
async function readHtmlTitle(filePath: string, fileName: string) {
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(16 * 1024);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
const head = buffer.subarray(0, bytesRead).toString("utf8");
const match = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
const title = match?.[1]
?.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
return title ? decodeHtmlEntities(title) : stripHtmlExtension(fileName);
} finally {
await handle.close();
}
}
function stripHtmlExtension(fileName: string) {
return fileName.replace(/\.html?$/i, "");
}
function decodeHtmlEntities(value: string) {
return value
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'");
}
+37 -14
View File
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
@@ -14,6 +14,7 @@ const COOKIE_NAME = authCookieName();
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000; const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000;
const LINUX_ACCOUNT_WORKSPACE_NAME = "zk-agent"; const LINUX_ACCOUNT_WORKSPACE_NAME = "zk-agent";
let sessionsWriteQueue: Promise<unknown> = Promise.resolve();
type UserRecord = { type UserRecord = {
id: string; id: string;
@@ -131,9 +132,10 @@ export async function logoutAccount() {
const cookieStore = await cookies(); const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value; const token = cookieStore.get(COOKIE_NAME)?.value;
if (token) { if (token) {
const sessionsFile = await readSessions(); await mutateSessions((sessionsFile) => {
delete sessionsFile.sessions[token]; delete sessionsFile.sessions[token];
await writeSessions(sessionsFile); return sessionsFile;
});
} }
cookieStore.delete(COOKIE_NAME); cookieStore.delete(COOKIE_NAME);
} }
@@ -151,8 +153,12 @@ export async function getCurrentAccount(): Promise<AccountSession | null> {
const account = usersFile.users.find((user) => user.id === session.accountId); const account = usersFile.users.find((user) => user.id === session.accountId);
if (!account) return null; if (!account) return null;
if (shouldRefreshSession(session.updatedAt ?? session.createdAt)) { if (shouldRefreshSession(session.updatedAt ?? session.createdAt)) {
session.updatedAt = new Date().toISOString(); await mutateSessions((latestSessionsFile) => {
await writeSessions(sessionsFile); const latestSession = latestSessionsFile.sessions[token];
if (!latestSession) return latestSessionsFile;
latestSession.updatedAt = new Date().toISOString();
return latestSessionsFile;
});
await setSessionCookie(token); await setSessionCookie(token);
} }
return { id: account.id, username: account.username }; return { id: account.id, username: account.username };
@@ -199,13 +205,15 @@ export function accountSessionRoot(accountId: string, sessionId: string) {
async function createSession(account: UserRecord) { async function createSession(account: UserRecord) {
const token = randomBytes(32).toString("hex"); const token = randomBytes(32).toString("hex");
const sessionsFile = await readSessions(); const now = new Date().toISOString();
sessionsFile.sessions[token] = { await mutateSessions((sessionsFile) => {
accountId: account.id, sessionsFile.sessions[token] = {
createdAt: new Date().toISOString(), accountId: account.id,
updatedAt: new Date().toISOString(), createdAt: now,
}; updatedAt: now,
await writeSessions(sessionsFile); };
return sessionsFile;
});
await setSessionCookie(token); await setSessionCookie(token);
return { id: account.id, username: account.username }; return { id: account.id, username: account.username };
@@ -257,7 +265,22 @@ async function readSessions(): Promise<SessionsFile> {
async function writeSessions(payload: SessionsFile) { async function writeSessions(payload: SessionsFile) {
await mkdir(AUTH_STATE_ROOT, { recursive: true }); await mkdir(AUTH_STATE_ROOT, { recursive: true });
await writeFile(SESSIONS_PATH, JSON.stringify(payload, null, 2), "utf8"); const tempPath = `${SESSIONS_PATH}.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}.tmp`;
await writeFile(tempPath, JSON.stringify(payload, null, 2), "utf8");
await rename(tempPath, SESSIONS_PATH);
}
async function mutateSessions(
mutator: (payload: SessionsFile) => SessionsFile | Promise<SessionsFile>,
) {
const run = sessionsWriteQueue.then(async () => {
const current = await readSessions();
const next = await mutator(current);
await writeSessions(next);
return next;
});
sessionsWriteQueue = run.catch(() => undefined);
return run;
} }
function normalizeUsername(username: string) { function normalizeUsername(username: string) {
@@ -0,0 +1,234 @@
(() => {
const bridge = window.__annotationSubmissionBridge;
if (!bridge?.pageName || typeof bridge.read !== "function") return;
const endpoint = `/api/annotations/${encodeURIComponent(bridge.pageName)}/submissions`;
const toolbar = document.querySelector(".toolbar");
if (!toolbar) return;
const style = document.createElement("style");
style.textContent = `
.annotation-submit-status { color: #687385; font-size: 12px; white-space: nowrap; }
.annotation-history-dialog { width: min(680px, calc(100vw - 32px)); max-height: min(720px, calc(100vh - 32px)); padding: 0; border: 1px solid #d9dee7; border-radius: 8px; box-shadow: 0 24px 80px rgba(15, 23, 42, .2); }
.annotation-history-dialog::backdrop { background: rgba(15, 23, 42, .32); }
.annotation-history-head { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; border-bottom: 1px solid #e5e9ef; background: #fff; }
.annotation-history-head h2 { margin: 0; font-size: 17px; }
.annotation-history-close { width: 32px; height: 32px; border: 0; border-radius: 5px; background: #f1f4f8; font-size: 20px; color: #556274; }
.annotation-history-list { margin: 0; padding: 0; list-style: none; overflow: auto; }
.annotation-history-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 14px 18px; border-bottom: 1px solid #edf0f4; }
.annotation-history-item:last-child { border-bottom: 0; }
.annotation-history-title { font-weight: 650; font-size: 14px; }
.annotation-history-meta { margin-top: 5px; color: #687385; font-size: 12px; }
.annotation-history-summary { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 10px; color: #4b5666; font-size: 12px; }
.annotation-history-actions { display: flex; gap: 7px; }
.annotation-history-empty { padding: 40px 18px; text-align: center; color: #687385; }
@media (max-width: 820px) { .annotation-submit-status { display: none; } }
`;
document.head.appendChild(style);
const historyButton = button("提交记录", "secondary");
const submitButton = button("提交", "primary");
const status = document.createElement("span");
status.className = "annotation-submit-status";
toolbar.append(status, historyButton, submitButton);
const dialog = document.createElement("dialog");
dialog.className = "annotation-history-dialog";
dialog.innerHTML = `
<div class="annotation-history-head">
<h2>提交记录</h2>
<button class="annotation-history-close" aria-label="关闭">×</button>
</div>
<ol class="annotation-history-list"></ol>
`;
document.body.appendChild(dialog);
dialog.querySelector(".annotation-history-close").onclick = () =>
dialog.close();
dialog.addEventListener("click", (event) => {
if (event.target === dialog) dialog.close();
});
let history = [];
historyButton.onclick = async () => {
dialog.showModal();
await refreshHistory();
};
submitButton.onclick = async () => {
submitButton.disabled = true;
submitButton.textContent = "提交中";
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ annotations: bridge.read() }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "提交失败");
history = [
summaryOf(payload.submission),
...history.filter((item) => item.id !== payload.submission.id),
];
setStatus(payload.submission);
setRevisionUrl(payload.submission.id);
bridge.notify?.(`已提交第 ${payload.submission.version}`);
} catch (error) {
bridge.notify?.(error instanceof Error ? error.message : "提交失败");
} finally {
submitButton.disabled = false;
submitButton.textContent = "提交";
}
};
async function refreshHistory() {
const list = dialog.querySelector(".annotation-history-list");
list.innerHTML = '<li class="annotation-history-empty">正在读取</li>';
try {
const payload = await fetchJson(endpoint);
history = payload.history || [];
renderHistory();
} catch (error) {
list.innerHTML = `<li class="annotation-history-empty">${escapeHtml(error instanceof Error ? error.message : "读取失败")}</li>`;
}
}
function renderHistory() {
const list = dialog.querySelector(".annotation-history-list");
if (!history.length) {
list.innerHTML =
'<li class="annotation-history-empty">还没有提交记录</li>';
return;
}
list.innerHTML = "";
for (const item of history) {
const li = document.createElement("li");
li.className = "annotation-history-item";
li.innerHTML = `
<div>
<div class="annotation-history-title">第 ${item.version} 版 · ${escapeHtml(item.submittedBy?.username || "未知用户")}</div>
<div class="annotation-history-meta">${formatTime(item.submittedAt)}</div>
<div class="annotation-history-summary">
<span>已标 ${item.summary?.completed || 0}/${item.summary?.total || 0}</span>
<span>G ${item.summary?.G || 0}</span>
<span>S ${item.summary?.S || 0}</span>
<span>B ${item.summary?.B || 0}</span>
</div>
</div>
<div class="annotation-history-actions">
<button class="btn secondary" data-action="copy" type="button">复制链接</button>
<button class="btn secondary" data-action="view" type="button">查看</button>
</div>
`;
li.querySelector('[data-action="copy"]').onclick = async () => {
const url = revisionUrl(item.id);
try {
await navigator.clipboard.writeText(url);
bridge.notify?.("版本链接已复制");
} catch {
window.prompt("复制版本链接", url);
}
};
li.querySelector('[data-action="view"]').onclick = async () => {
try {
const payload = await fetchJson(
`${endpoint}?revision=${encodeURIComponent(item.id)}`,
);
bridge.apply(payload.submission.annotations || []);
dialog.close();
setStatus(payload.submission, "正在查看");
setRevisionUrl(payload.submission.id);
bridge.notify?.(`已打开第 ${payload.submission.version}`);
} catch (error) {
bridge.notify?.(error instanceof Error ? error.message : "读取失败");
}
};
list.appendChild(li);
}
}
async function initialize() {
try {
const requestedRevision = new URLSearchParams(window.location.search).get(
"revision",
);
if (requestedRevision) {
const payload = await fetchJson(
`${endpoint}?revision=${encodeURIComponent(requestedRevision)}`,
);
bridge.apply(payload.submission.annotations || []);
setStatus(payload.submission, "正在查看");
return;
}
const payload = await fetchJson(endpoint);
history = payload.history || [];
if (payload.latest) {
bridge.applyMissing(payload.latest.annotations || []);
setStatus(payload.latest);
} else {
status.textContent = "尚未提交";
}
} catch {
status.textContent = "提交记录暂不可用";
}
}
function setStatus(submission, prefix = "最新") {
status.textContent = `${prefix}:第 ${submission.version} 版 · ${submission.submittedBy?.username || "未知用户"}`;
}
function revisionUrl(revisionId) {
const url = new URL(window.location.href);
url.searchParams.set("revision", revisionId);
return url.toString();
}
function setRevisionUrl(revisionId) {
window.history.replaceState(null, "", revisionUrl(revisionId));
}
function button(label, variant) {
const element = document.createElement("button");
element.type = "button";
element.className = `btn ${variant}`;
element.textContent = label;
return element;
}
async function fetchJson(url) {
const response = await fetch(url, { cache: "no-store" });
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "请求失败");
return payload;
}
function summaryOf(submission) {
const { annotations: _annotations, ...summary } = submission;
return summary;
}
function formatTime(value) {
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
function escapeHtml(value) {
return String(value ?? "").replace(
/[&<>"']/g,
(character) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[character],
);
}
void initialize();
})();
+2
View File
@@ -213,6 +213,7 @@ export OPENAI_API_KEY=$(printf "%q" "${api_key}")
export OPENAI_BASE_URL=$(printf "%q" "${base_url}") export OPENAI_BASE_URL=$(printf "%q" "${base_url}")
export OPENAI_MODEL=$(printf "%q" "${model}") export OPENAI_MODEL=$(printf "%q" "${model}")
export OPENAI_TIMEOUT_SECONDS="${OPENAI_TIMEOUT_SECONDS:-3600}" export OPENAI_TIMEOUT_SECONDS="${OPENAI_TIMEOUT_SECONDS:-3600}"
export CLAW_MODEL_IDLE_TIMEOUT_SECONDS="${CLAW_MODEL_IDLE_TIMEOUT_SECONDS:-60}"
export CLAW_DEPLOY_INSTANCE=$(printf "%q" "${DEPLOY_INSTANCE}") export CLAW_DEPLOY_INSTANCE=$(printf "%q" "${DEPLOY_INSTANCE}")
export CLAW_BACKEND_HOST=$(printf "%q" "${backend_host}") export CLAW_BACKEND_HOST=$(printf "%q" "${backend_host}")
@@ -264,6 +265,7 @@ ensure_env_file() {
echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-}" echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-}"
echo "OPENAI_MODEL=${OPENAI_MODEL:-}" echo "OPENAI_MODEL=${OPENAI_MODEL:-}"
echo "OPENAI_TIMEOUT_SECONDS=${OPENAI_TIMEOUT_SECONDS:-3600}" echo "OPENAI_TIMEOUT_SECONDS=${OPENAI_TIMEOUT_SECONDS:-3600}"
echo "CLAW_MODEL_IDLE_TIMEOUT_SECONDS=${CLAW_MODEL_IDLE_TIMEOUT_SECONDS:-60}"
echo "CLAW_SERVICE_SCOPE=${SERVICE_SCOPE}" echo "CLAW_SERVICE_SCOPE=${SERVICE_SCOPE}"
echo "CLAW_ENABLE_LINUX_ACCOUNTS=${CLAW_ENABLE_LINUX_ACCOUNTS:-0}" echo "CLAW_ENABLE_LINUX_ACCOUNTS=${CLAW_ENABLE_LINUX_ACCOUNTS:-0}"
echo "OPENAI_API_KEY=已配置" echo "OPENAI_API_KEY=已配置"
+1 -1
View File
@@ -20,7 +20,7 @@ export OPENAI_API_KEY
export OPENAI_BASE_URL="${OPENAI_BASE_URL:-http://model.mify.ai.srv/v1}" export OPENAI_BASE_URL="${OPENAI_BASE_URL:-http://model.mify.ai.srv/v1}"
export OPENAI_MODEL="${OPENAI_MODEL:-tongyi/deepseek-v4-pro}" export OPENAI_MODEL="${OPENAI_MODEL:-tongyi/deepseek-v4-pro}"
export OPENAI_TIMEOUT_SECONDS="${OPENAI_TIMEOUT_SECONDS:-3600}" export OPENAI_TIMEOUT_SECONDS="${OPENAI_TIMEOUT_SECONDS:-3600}"
export CLAW_MODEL_IDLE_TIMEOUT_SECONDS="${CLAW_MODEL_IDLE_TIMEOUT_SECONDS:-${OPENAI_TIMEOUT_SECONDS}}" export CLAW_MODEL_IDLE_TIMEOUT_SECONDS="${CLAW_MODEL_IDLE_TIMEOUT_SECONDS:-60}"
# 后端也会调用 Node 生态工具,例如 feishu-mcp-pro。systemd 不读取交互式 # 后端也会调用 Node 生态工具,例如 feishu-mcp-pro。systemd 不读取交互式
# shell 的 nvm 配置,所以需要把部署时记录的 Node 路径显式传给 Python 进程。 # shell 的 nvm 配置,所以需要把部署时记录的 Node 路径显式传给 Python 进程。
+1
View File
@@ -142,6 +142,7 @@ export OPENAI_API_KEY="${OPENAI_API_KEY:-}"
export OPENAI_BASE_URL="${OPENAI_BASE_URL:-http://model.mify.ai.srv/v1}" export OPENAI_BASE_URL="${OPENAI_BASE_URL:-http://model.mify.ai.srv/v1}"
export OPENAI_MODEL="${OPENAI_MODEL:-tongyi/deepseek-v4-pro}" export OPENAI_MODEL="${OPENAI_MODEL:-tongyi/deepseek-v4-pro}"
export OPENAI_TIMEOUT_SECONDS="${OPENAI_TIMEOUT_SECONDS:-3600}" export OPENAI_TIMEOUT_SECONDS="${OPENAI_TIMEOUT_SECONDS:-3600}"
export CLAW_MODEL_IDLE_TIMEOUT_SECONDS="${CLAW_MODEL_IDLE_TIMEOUT_SECONDS:-60}"
BACKEND_LOG="${RUN_DIR}/webui-backend.log" BACKEND_LOG="${RUN_DIR}/webui-backend.log"
FRONTEND_LOG="${RUN_DIR}/webui-frontend.log" FRONTEND_LOG="${RUN_DIR}/webui-frontend.log"
+150
View File
@@ -0,0 +1,150 @@
---
name: label-fast-slow-routing
description: 依据 2026.7 产品标准,对单条或批量 query 进行“快 / 慢 / 模糊”三档可解释打标。先用精简标签知识召回垂域,再优先应用垂域规则;未覆盖时依次使用多指令、自动任务等结构规则和产品核心原则,并输出决策层级、规则 ID、证据、冲突和能力承接状态。用于快慢分流标注、评测、边界仲裁和 badcase 分析。
---
# 快慢分流打标
## 目标
按用户对响应速度和结果质量的预期判断 `快``慢``模糊`。业务垂域、结构信号和当前系统能力只参与解释与路由,不代替语义标签。
## 渐进加载
每条 case 按下面顺序读取,命中后及时停止,不要加载整个 `references/`
1. 第一动作读取 [references/index.md](references/index.md),完成垂域召回和结构信号预判;垂域不明显或命中召回边界时,再读取 [references/preclassification.md](references/preclassification.md)。
2. 读取一个主垂域卡;只有主垂域确实无法确定时,才读取第二个候选垂域卡。
3. 出现多轮依赖、多指令或自动任务信号时,再读取对应结构卡。多轮先重建当前任务,自动任务还要读取自动化垂域卡。
4. 垂域和结构卡都没有明确覆盖时,才读取 [references/core-policy.md](references/core-policy.md)。
5. 命中标记为冲突的规则,或做规则维护时,再读取 [references/policy-conflicts.md](references/policy-conflicts.md)。
6. 处理边界样本或校准时,按需读取 [references/golden-examples.md](references/golden-examples.md)。
普通 case 通常只需读取索引和一个垂域卡。不要运行时读取相邻 `label-master`;本 Skill 已包含所需的精简知识快照。
## 决策优先级
按以下优先级裁决:
1. 用户当前任务明确给出的已批准口径。
2. 2026.7 产品文档中的明确垂域规则。
3. 2026.7 跨垂域结构规则。
4. 2026.7 产品核心原则。
5. 快系统能力状态,只记录承接情况,不修改语义标签。
垂域规则和核心原则冲突时,以垂域规则为最终结论,并记录 `policy_conflict=true` 和冲突证据。
## 工作流
1. 提取当前 query、前轮、设备、端侧、时间窗口和已提供的能力清单。
2. 使用索引输出一个主垂域、最多两个候选垂域,以及多指令、自动任务、上下文依赖信号。
3. 存在上下文依赖时读取 `references/structure-multi-turn.md`,识别上下文关系并重建当前完整任务;不得按上一轮实际路由直接继承标签。
4. 读取主垂域卡并查找明确规则。命中时设置 `decision_level="domain"`
5. 未命中垂域规则时检查结构卡。命中时设置 `decision_level="structure"`
6. 仍未命中时使用核心原则,设置 `decision_level="core"`
7. 做反证检查,说明为什么没有选择其他档位。
8. 只在有明确能力清单或验证结果时填写 `fast_system_pending`;未检查时填 `null`
9. 需要完整落盘时,按 schema 输出并运行校验脚本。
## 输出
默认返回简短结论:
```text
标签:慢
判断层级:导航/生服垂域
命中规则:NAV_SLOW_CONDITIONED_POI
依据:单地点规划带明确筛选条件,命中导航垂域慢规则。
```
如果外层评测协议只接受 `prediction``reason` 等通用字段,把层级和规则 ID
写在 `reason` 开头,例如:
```text
[domain:NAV_SLOW_CONDITIONED_POI] 单地点规划带明确筛选条件,按导航垂域给慢。
```
需要落盘、批量评测或用户要求完整记录时,输出符合 [schemas/label-record.schema.json](schemas/label-record.schema.json) 的对象:
```json
{
"query": "导航去附近评分4.5以上的泰国菜",
"label": "慢",
"default_route": "慢系统",
"domain": "导航/生服",
"candidate_domains": ["导航/生服"],
"decision_level": "domain",
"decision_rule_id": "NAV_SLOW_CONDITIONED_POI",
"decision_source": "references/domain-navigation-life.md",
"decision_path": [
"preclassification:导航/生服",
"domain:NAV_SLOW_CONDITIONED_POI"
],
"reason": "单地点规划带明确筛选条件,命中导航垂域慢规则。",
"evidence": ["“评分4.5以上”是明确筛选条件"],
"counterevidence": ["核心快规则把目标明确的简单条件操作视为快"],
"structural_signals": {
"complex": null,
"multi_instruction": false,
"auto_task": false,
"context_dependent": false
},
"context_used": false,
"context_relation": "independent",
"fast_system_pending": null,
"route_override": null,
"policy_conflict": true,
"conflict_refs": ["CONFLICT_NAV_CONDITIONED_POI"],
"confidence": "high",
"review_required": false,
"uncertainties": [],
"policy_version": "FAST-SLOW-2026.7"
}
```
新生成的多轮记录应填写 `context_relation`,取值和判断方法见
`references/structure-multi-turn.md`。单轮或与历史无关时填写
`"independent"`;为兼容旧产物,该字段在 schema 中保持可选。只有实际使用了
历史内容时,`context_used``structural_signals.context_dependent` 才能为
`true`
使用 `null` 表示未评估,不要猜测端侧、上下文、能力状态或复杂度。
## 校验
单条记录:
```bash
python scripts/validate_label.py --record '<JSON object>'
```
JSON 或 JSONL
```bash
python scripts/validate_label.py --file output/fast_slow_labels.jsonl
```
校验器会检查字段不变量、决策层级、知识来源文件和规则 ID。结构通过不代表语义正确。
## 复核门禁
满足任一情况时设置 `review_required=true`
- 缺少会改变垂域或标签的前轮、端侧或设备信息。
- 当前轮明显依赖前文,但无法唯一重建原任务。
- 两个同优先级规则给出不同结论。
- 自动任务端侧未知,且不同端侧策略不同。
- 只能依赖未确认的能力清单。
- `confidence="low"`
已由优先级解决的产品文档冲突只需记录,不自动要求人工复核。
## 禁止事项
- 不按字数、句长、关键词数量或既有标签比例直接定标。
- 不因快系统当前不支持而把 `快`改成`慢`
- 不把 `complex=true` 直接等同于`慢`
- 不把所有多指令或自动任务无条件判慢,必须检查专项例外。
- 不把多轮、指代、确认、取消或上一轮实际路由直接等同于快慢标签。
- 不使用 2026.6、`FAST_DIRECT``SLOW_*` 旧策略。
- 不输出没有规则 ID、证据和判断层级的裸标签。
@@ -0,0 +1,4 @@
interface:
display_name: "快慢分流打标"
short_description: "依据 2026.7 标准按垂域、结构和核心原则分层打标"
default_prompt: "Use $label-fast-slow-routing to label these queries as 快、慢或模糊并给出可复核依据。"
@@ -0,0 +1,51 @@
# 2026.7 核心原则
本文件只在垂域和结构规则没有明确覆盖时作为兜底。
## 三档定义
| 标签 | 用户状态 | 定义 | 默认路由 |
| --- | --- | --- | --- |
| 快 | 操控 | 期望说完即得,延迟敏感 | 快系统 |
| 慢 | 委托 | 能接受等待,期待系统推理、规划或生成高质量结果 | 慢系统 |
| 模糊 | 问询 | 速度和质量都重要,或难以明确归类 | 慢系统 |
核心问题:用户说出这句话时,能接受等多久?
## 快
- `CORE_FAST_DIRECT_ACTION`:操作目标明确,动作和对象可直接识别,不需要先决定做什么。
- `CORE_FAST_OPERATION_FLOW`:用户正在明确操作流中执行直接动作。
- `CORE_FAST_DETERMINISTIC_QUERY`:高频、结果唯一、无需加工的确定性工具查询。
条件修饰、感受描述和非标准叫法不会自动变慢。只要动作与目标仍然明确,保持快。
多轮不会自动变慢。前文只补全唯一对象或简单槽位,当前轮仍是直接动作、明确操作流或确定性查询时,保持快。
## 慢
- `CORE_SLOW_INTENT_INFERENCE`:只表达感受、状态或不满,没有明确动作和功能,需要反推动作。
- `CORE_SLOW_SCENE_MAPPING`:描述想达到的场景或氛围,需要映射成一组操作。
- `CORE_SLOW_MULTI_STEP_PLANNING`:需要多步计算、换算、跨源聚合、上下文推理或先规划再执行。
- `CORE_SLOW_TRUE_DISAMBIGUATION`:存在真实多候选或必需槽位缺失,需要澄清或推断。
当前轮继续上一轮的推荐、分析、故障排查或规划,或者新增约束导致需要重新检索和规划时,按慢处理。详细关系先读取 `structure-multi-turn.md`
## 模糊
- `CORE_AMBIGUOUS_OPEN_QA`:闲聊或不属于明确垂域的开放问答。
- `CORE_AMBIGUOUS_PRODUCT_QA`:没有更具体垂域规则覆盖的产品知识、故障咨询。
- `CORE_AMBIGUOUS_STATE_QUERY`:没有更具体垂域规则覆盖的设备或环境信息查询。
- `CORE_AMBIGUOUS_POLICY_GAP`:快慢证据同时存在,当前规则没有明确优先级。
`模糊`不代表模型没把握。规则明确规定的模糊场景可以是高置信结果。
## 能力承接
语义上应为快、但当前快系统未支持时:
- `label` 保持 `快`
- 有能力清单或验证证据时设置 `fast_system_pending=true`
- 没有检查能力时设置 `fast_system_pending=null`
禁止根据当前 parser、白名单、接口或模型能力反推语义标签。
@@ -0,0 +1,27 @@
# 自动化垂域
使用前先读取 `references/structure-automation.md`,确认 query 确实是条件触发任务或自动化管理。
## 音箱 / 家庭自动化
- `AUTO_SLOW_HOME_AUTOMATION`:音箱端家庭自动化默认慢,包括明确触发动作和意向化自动化设计。
## 车载自动化
- `AUTO_FAST_CAR_REMINDER`:车载闹钟或条件提醒给快。
- `AUTO_SLOW_CAR_REGULAR_TASK`:车载常规条件任务语义上按慢记录,并设置 `route_override="座舱AC"`
## 未知端侧
- `AUTO_AMBIGUOUS_ENDPOINT_UNKNOWN`:端侧未知且音箱、车载策略会产生不同结论时,给模糊并设置 `review_required=true`
无条件创建普通闹钟、提醒或日程不属于自动任务,回到通用工具垂域。
## 多轮
先按 `structure-multi-turn.md` 合并当前轮补充的触发条件、动作和端侧,再应用本卡规则:
- `AUTO_CONTEXT_RECONSTRUCTED`:多轮只负责重建完整自动任务,不因轮次直接改变标签。
- 当前轮补全后可确定是车载提醒、车载常规任务或家庭自动化时,分别使用对应规则。
- 历史和当前轮合并后端侧仍未知,且不同端侧结论不同,命中 `AUTO_AMBIGUOUS_ENDPOINT_UNKNOWN`
- 缺少会改变自动任务类型或端侧判断的历史时,设置 `review_required=true`
@@ -0,0 +1,30 @@
# 车控垂域
适用于车辆设备、驾驶功能和座舱能力的执行控制。
## 慢
- `CAR_SLOW_INTENT_EXPRESSION`:只表达冷、热、刺眼、拥挤等感受,没有明确功能或动作。
- `CAR_SLOW_SCENE_EXPRESSION`:只描述会议、睡眠、节日氛围等场景目标,需要组合车辆功能。
- `CAR_SLOW_FUZZY_FUNCTION`:不知道功能名称,以模糊描述指代车辆功能。
- `CAR_SLOW_MULTI_POSITION_REASONING`:包含位置选择、排除或复杂设备逻辑。
- `CAR_SLOW_CONFIRMATION_CHAIN`:当前轮回答的是场景映射、模糊功能识别或复杂位置选择等慢车控任务的澄清,重建后原任务仍命中车控慢规则。
## 快
- `CAR_FAST_EXPLICIT_CONTROL`:除上述慢场景外,动作、车辆设备或功能可直接识别。
- `CAR_FAST_EXPLICIT_MULTI_CONTROL`:多个动作全部是明确车辆设备控制,且不需要规划依赖。
- `CAR_FAST_CONTEXT_DIRECT`:前文只补全唯一车辆设备、位置或属性,当前轮合并后是明确直接控制。
确认、取消或简短补槽不直接继承上一轮系统路由。先按 `structure-multi-turn.md` 重建完整车控任务。
## 冲突标记
`CAR_SLOW_FUZZY_FUNCTION` 与核心汇总中的“非标准叫法但可识别时给快”冲突。按垂域优先给慢,并记录:
```text
policy_conflict=true
conflict_refs=["CONFLICT_CAR_FUZZY_FUNCTION"]
```
示例“那个压线噔噔的声音关掉”在本层级判慢。
@@ -0,0 +1,29 @@
# 内容垂域
适用于音乐、视频、电台、新闻、歌单、内容搜索、播放控制和内容问答。
## 慢
- `CONTENT_SLOW_DESCRIPTION_SEARCH`:依赖描述性语言识别资源。
- `CONTENT_SLOW_QA`:内容知识问答或需要生成 answer。
- `CONTENT_SLOW_SCENE_RECOMMENDATION`:按场景进行内容推荐。
- `CONTENT_SLOW_LYRIC_SEARCH`:根据歌词片段搜索资源。
- `CONTENT_SLOW_FRESHNESS_OR_RANKING`:最新、最热、排行等时效或排序请求。
- `CONTENT_SLOW_PLAYLIST_OR_FAVORITES`:歌单编排、收藏搜索和复杂个人资源检索。
- `CONTENT_SLOW_NON_EXPLICIT_PLAY`:没有明确播放意图,需要先理解用户要什么。
- `CONTENT_SLOW_MULTI_STEP`:需要联网、澄清或多步推理后才能满足。
## 快
- `CONTENT_FAST_EXPLICIT_PLAY`:播放对象、歌手、类型或资源明确。
- `CONTENT_FAST_PLAYER_CONTROL`:暂停、继续、上一首、下一首、快进等直接播控。
- `CONTENT_FAST_SIMPLE_SEARCH`:精准搜索、泛推荐或已有上下文中的直接资源切换。
## 多轮
- `CONTENT_SLOW_CONTEXT_PLANNING`:当前轮继续上一轮的内容推荐、知识问答、描述搜索、歌单编排或复杂个人资源检索。
- `CONTENT_SLOW_CONTEXT_CONSTRAINT`:当前轮为既有内容请求新增时效、排行、场景、风格或其他需要重新检索的约束。
- `CONTENT_FAST_CONTEXT_CONTROL`:前轮建立明确资源上下文后,当前轮执行直接播控。
- `CONTENT_FAST_CONTEXT_SWITCH`:前轮已经唯一确定资源集合,当前轮直接选择、切换或播放其中明确的一项。
90 秒时间窗口和前后轮关键词重叠只用于判断是否延续同一任务,不直接决定快慢。仅因当前快系统没有资源或能力失败,也不修改语义标签;失败后的实际升级属于执行策略。
@@ -0,0 +1,20 @@
# 通用工具垂域
适用于计算、时间、天气、翻译、闹钟、提醒、电话等确定性工具能力。
## 慢
- `TOOLS_SLOW_MULTI_STEP_REASONING`:需要多步推理、复杂计算或多次换算,不能直接查询得到。
- `TOOLS_SLOW_EXTERNAL_PROCESSING`:需要结合上下文或外部信息进行加工。
- `TOOLS_SLOW_MISSING_SLOT`:执行所需关键槽位不全,需要追问或推断补齐。
- `TOOLS_SLOW_UNSTRUCTURED_REQUEST`:表达开放,无法稳定结构化为工具参数。
## 快
- `TOOLS_FAST_DETERMINISTIC_QUERY`:当前时间、简单天气、确定性换算等可直接查询或计算。
- `TOOLS_FAST_EXPLICIT_ACTION`:时间、对象和动作明确的闹钟、提醒、电话等操作。
- `TOOLS_FAST_CONTEXT_COMPLETED_ACTION`:前文补全唯一时间、联系人、地点或对象后,重建任务可直接结构化为一次工具调用。
普通“明天提醒我开会”属于工具操作;带事件触发条件的提醒继续读取自动化规则。
只有结合前文后关键槽位仍缺失,才命中 `TOOLS_SLOW_MISSING_SLOT`;不能因为当前轮单独看不完整就直接判慢。
@@ -0,0 +1,28 @@
# IoT 垂域
适用于家庭设备控制、房间环境查询和 IoT 设备信息查询。
## 慢
- `IOT_SLOW_INTENT_CONTROL`:只给舒适、温馨等目标状态,需要反推设备组合。
- `IOT_SLOW_ENV_QUERY`:环境查询未明确指定房间或需要比较、聚合。
- `IOT_SLOW_OTHER_QUERY`:其他 IoT 查询没有明确设备与属性。
- `IOT_SLOW_CONTEXT_REASONING`:当前轮依赖前文继续场景设计、环境比较、设备选择或聚合判断。
- `IOT_SLOW_CONTEXT_UNRESOLVED`:结合历史后设备、房间或属性仍不唯一。
## 快
- `IOT_FAST_EXPLICIT_CONTROL`:设备、位置和动作可直接识别。
- `IOT_FAST_ROOM_ENV_QUERY`:环境查询明确指定房间。
- `IOT_FAST_DEVICE_PROPERTY_QUERY`:明确指定设备和属性。
- `IOT_FAST_EXPLICIT_MULTI_CONTROL`:多个动作全部是明确 IoT 设备控制。
- `IOT_FAST_CONTEXT_DIRECT`:前文已经唯一确定设备、房间或属性,当前轮是直接控制或单属性查询。
## 冲突标记
IoT 查询的细粒度规则覆盖核心汇总的“设备/环境信息问答给模糊”。命中查询规则时记录:
```text
policy_conflict=true
conflict_refs=["CONFLICT_IOT_QUERY_VS_AMBIGUOUS"]
```
@@ -0,0 +1,34 @@
# 导航 / 生活服务垂域
适用于导航、路线、地图操作、位置与路况问答、POI 搜索及相关生活服务请求。
## 慢
- `NAV_SLOW_CONDITIONED_POI`:单地点规划带明确筛选条件,如评分、品类属性或多个约束。
- `NAV_SLOW_FUZZY_POI`:单地点规划使用隐喻、外形或模糊描述识别目标。
- `NAV_SLOW_MULTI_PLACE`:一次规划多个地点或先后目的地。
- `NAV_SLOW_MULTI_ACTION`:导航请求同时要求路线选择、沿途搜索、终点搜索等多个动作。
- `NAV_SLOW_LOW_FREQUENCY_QA`:沿途城市、红绿灯数量等非高频复杂问答。
- `NAV_SLOW_COMPLEX_EXPRESSION`:自纠正、非人机话语或描述性表达使目标需要额外推理。
- `NAV_SLOW_CONTEXT_PLANNING`:当前轮继续上一轮的路线规划、沿途搜索或多地点任务。
- `NAV_SLOW_CONTEXT_CONSTRAINT`:当前轮为既有目的地新增评分、品类属性、排除条件或多个约束,需要重新检索或规划。
## 快
- `NAV_FAST_SIMPLE_SINGLE_POI`:不带筛选条件的单地点规划,包括 A、A 的 B、A 附近的 B、顺路的 A、A 最近的 B。
- `NAV_FAST_MAP_OPERATION`:删除途经点、地址设置、导航播报、路线切换等地图操作。
- `NAV_FAST_HIGH_FREQUENCY_QA`:到目的地或途经点的时间、距离、位置,当前位置、路名和路况查询。
- `NAV_FAST_CONTEXT_SIMPLE`:前文已经唯一确定目的地、途经点或路线,当前轮只是开始导航、查询时间距离、切换路线或其他简单续接。
## 冲突标记
`NAV_SLOW_CONDITIONED_POI` 与核心汇总中的“目标明确且带简单条件可快”冲突。按垂域优先给慢,并记录:
```text
policy_conflict=true
conflict_refs=["CONFLICT_NAV_CONDITIONED_POI"]
```
“最近、附近、顺路”在本垂域被明确列为无筛选条件的快路径,不按普通筛选条件处理。
上一轮实际走快系统还是慢系统不直接决定当前标签。先按 `structure-multi-turn.md` 重建任务,再判断当前续接是否需要重新规划。
@@ -0,0 +1,28 @@
# 产品问答垂域
适用于产品知识、故障诊断、设置入口和车辆动态信息查询。
## 慢
- `PRODUCT_SLOW_FAULT_DIAGNOSIS`:报告异常、询问原因、要求排查或处理建议。
- `PRODUCT_SLOW_STATIC_KNOWLEDGE`:询问功能说明、概念、规则、规格、使用方法或知识库内容。
- `PRODUCT_SLOW_COMPLEX_DYNAMIC_QUERY`:读取本车信号、档案或统计值,并进行条件判断、聚合、比较、换算或够否判断。
- `PRODUCT_SLOW_CONTEXT_REASONING`:当前轮依赖前文继续知识问答、故障诊断、条件判断、比较、聚合或够否判断。
- `PRODUCT_SLOW_CONTEXT_UNRESOLVED`:结合已有历史后对象或属性仍不唯一,需要追问或推断。
## 快
- `PRODUCT_FAST_SETTINGS_ENTRY`:定位设置项入口、询问在哪里设置或直接跳转设置页。
- `PRODUCT_FAST_SIMPLE_DYNAMIC_QUERY`:直接读取一个当前值、档案值或统计值。
- `PRODUCT_FAST_CONTEXT_SIMPLE_QUERY`:前文已经唯一确定对象或属性,当前轮只读取一个当前值、档案值或统计值。
“依赖上一轮”本身不是慢规则。历史只完成唯一指代消解时,应按重建后的完整问题判断。
## 冲突标记
本垂域与核心汇总的模糊档存在两组冲突:
- 静态知识和故障诊断按本垂域给慢:`CONFLICT_PRODUCT_QA_SLOW_VS_AMBIGUOUS`
- 简单动态状态查询按本垂域给快:`CONFLICT_PRODUCT_STATE_FAST_VS_AMBIGUOUS`
命中时设置 `policy_conflict=true` 并记录对应冲突 ID。
@@ -0,0 +1,21 @@
# 系统 / App 控制垂域
适用于系统设置、应用操作、屏幕操作和系统功能控制。
## 慢
- `SYSTEM_SLOW_INTENT_EXPRESSION`:只表达刺眼、吵、费眼等状态,没有明确操作目标。
- `SYSTEM_SLOW_SCENE_EXPRESSION`:描述会议、睡眠、专注等场景,需要映射多个系统功能。
- `SYSTEM_SLOW_DISAMBIGUATION`:指代不清、存在多个候选或功能名称无法唯一识别,需要追问。
- `SYSTEM_SLOW_COMPLEX_TASK`:包含选择、排除、条件判断或多步编排。
## 快
- `SYSTEM_FAST_EXPLICIT_CONTROL`:系统功能、应用、页面或屏幕元素可直接识别,动作明确。
- `SYSTEM_FAST_OPERATION_FLOW`:当前操作流中的返回、确认、取消、点击、切换等直接动作。
- `SYSTEM_FAST_EXPLICIT_MULTI_CONTROL`:多个动作全部是明确系统或设备控制,且没有规划依赖。
- `SYSTEM_FAST_CONTEXT_DIRECT`:前文已经唯一确定应用、页面、功能或屏幕元素,当前轮执行直接操作。
应用内搜索、内容消费或外部服务如果已有更具体垂域卡,优先进入对应垂域。
当前轮虽然含“这个、那里、确认”等词,但历史能唯一解析且动作直接时给快;历史仍有多个候选时命中 `SYSTEM_SLOW_DISAMBIGUATION`
@@ -0,0 +1,65 @@
# 2026.7 黄金边界样例
样例按“垂域优先、结构其次、核心兜底”口径校准。
| Query / 上下文 | 标签 | 判断层级 | 规则 ID |
| --- | --- | --- | --- |
| 开空调 | 快 | 车控 | `CAR_FAST_EXPLICIT_CONTROL` |
| 太冷了,调一下空调 | 快 | 车控 | `CAR_FAST_EXPLICIT_CONTROL` |
| 我感觉好冷快冻死了,帮我调一下 | 慢 | 车控 | `CAR_SLOW_INTENT_EXPRESSION` |
| 调一个浪漫点的氛围灯 | 快 | 车控 | `CAR_FAST_EXPLICIT_CONTROL` |
| 帮我把车里布置得浪漫一点 | 慢 | 车控 | `CAR_SLOW_SCENE_EXPRESSION` |
| 导航去附近评分 4.5 以上的泰国菜 | 慢 | 导航/生服 | `NAV_SLOW_CONDITIONED_POI` |
| 导航去最近的加油站 | 快 | 导航/生服 | `NAV_FAST_SIMPLE_SINGLE_POI` |
| 导航去大裤衩,再沿途找充电站 | 慢 | 导航/生服 | `NAV_SLOW_MULTI_ACTION` |
| 删除途经点 | 快 | 导航/生服 | `NAV_FAST_MAP_OPERATION` |
| 那个压线噔噔的声音关掉 | 慢 | 车控 | `CAR_SLOW_FUZZY_FUNCTION` |
| 小米 SU7 支持哪些驾驶模式 | 慢 | 产品问答 | `PRODUCT_SLOW_STATIC_KNOWLEDGE` |
| 我的车怎么突然没声音了 | 慢 | 产品问答 | `PRODUCT_SLOW_FAULT_DIAGNOSIS` |
| 现在胎压是多少 | 快 | 产品问答 | `PRODUCT_FAST_SIMPLE_DYNAMIC_QUERY` |
| 哨兵模式在哪里设置 | 快 | 产品问答 | `PRODUCT_FAST_SETTINGS_ENTRY` |
| 播放周杰伦的歌 | 快 | 内容 | `CONTENT_FAST_EXPLICIT_PLAY` |
| 播放最近短视频里很火的那首歌 | 慢 | 内容 | `CONTENT_SLOW_FRESHNESS_OR_RANKING` |
| 现在几点了 | 快 | 通用工具 | `TOOLS_FAST_DETERMINISTIC_QUERY` |
| 上午 9:18 到 12:18,再加下午 2 点到 6 点共多久 | 慢 | 通用工具 | `TOOLS_SLOW_MULTI_STEP_REASONING` |
| 把空调调到 24 度,然后打开座椅加热 | 快 | 车控 | `CAR_FAST_EXPLICIT_MULTI_CONTROL` |
| 查天气再决定要不要开窗 | 慢 | 多指令结构 | `MULTI_SLOW_CROSS_DOMAIN_WORKFLOW` |
| 音箱端:每天晚上 8 点开灯开空调 | 慢 | 自动化 | `AUTO_SLOW_HOME_AUTOMATION` |
| 车载端:车速超过 70 提醒我减速 | 快 | 自动化 | `AUTO_FAST_CAR_REMINDER` |
| 车载端:每次上车就导航去公司 | 慢 | 自动化 | `AUTO_SLOW_CAR_REGULAR_TASK` |
| 自动帮我设置一下,端侧未知 | 模糊 | 自动化 | `AUTO_AMBIGUOUS_ENDPOINT_UNKNOWN` |
| 无聊了,陪我聊聊天 | 模糊 | 核心 | `CORE_AMBIGUOUS_OPEN_QA` |
| 上轮:导航去公司;当前:换一条路线 | 快 | 导航/生服 | `NAV_FAST_CONTEXT_SIMPLE` |
| 上轮:导航去附近餐厅;当前:要评分 4.5 以上并且方便停车 | 慢 | 导航/生服 | `NAV_SLOW_CONTEXT_CONSTRAINT` |
| 上轮:公司和家选一个;当前:去公司 | 快 | 导航/生服 | `NAV_FAST_CONTEXT_SIMPLE` |
| 上轮:播放周杰伦的歌;当前:下一首 | 快 | 内容 | `CONTENT_FAST_CONTEXT_CONTROL` |
| 上轮:推荐通勤音乐;当前:安静一点但不能太困 | 慢 | 内容 | `CONTENT_SLOW_CONTEXT_CONSTRAINT` |
| 上轮:要打开哪个车窗;当前:副驾 | 快 | 车控 | `CAR_FAST_CONTEXT_DIRECT` |
| 上轮:帮我把车里布置得浪漫一点;系统询问是否继续;当前:确认 | 慢 | 车控 | `CAR_SLOW_CONFIRMATION_CHAIN` |
| 上轮:现在胎压是多少;当前:后轮呢 | 快 | 产品问答 | `PRODUCT_FAST_CONTEXT_SIMPLE_QUERY` |
| 上轮:为什么车机没声音;当前:刚升级系统以后开始的 | 慢 | 产品问答 | `PRODUCT_SLOW_CONTEXT_REASONING` |
| 上轮:打开微信设置;当前:点通知 | 快 | 系统/App控制 | `SYSTEM_FAST_CONTEXT_DIRECT` |
| 上轮:明天上午开会;当前:十点提醒我 | 快 | 通用工具 | `TOOLS_FAST_CONTEXT_COMPLETED_ACTION` |
| 上轮:客厅和卧室都有空调;当前:打开那个 | 慢 | IoT | `IOT_SLOW_CONTEXT_UNRESOLVED` |
| 当前:就按刚才那个办,但未提供任何历史(需复核) | 模糊 | 多轮结构 | `CONTEXT_REVIEW_HISTORY_MISSING` |
重点检查以下紧邻对:
```text
太冷了,调一下空调 -> 快
我感觉好冷,帮我调一下 -> 慢
导航去最近的加油站 -> 快
导航去评分 4.5 以上的泰国菜 -> 慢
现在胎压是多少 -> 快
刚刚谁动了我的车 -> 慢
多个明确设备控制 -> 快
查询结果决定后续控制 -> 慢
前文只补全唯一对象 -> 按完整任务判断,直接动作通常快
当前轮增加检索或规划约束 -> 慢
确认或取消 -> 重建原任务,不按表面词定标
缺少会改变结论的历史 -> 不猜测,进入复核
```
@@ -0,0 +1,52 @@
# 渐进加载索引
本文件只负责召回知识,不直接给快慢结论。
## 第一阶段输出
先提取:
- 当前 query 和可见上下文。
- 操作、询问或聊天。
- 对象、设备、资源、位置和端侧。
- 主垂域和最多两个候选垂域。
- 多指令、自动任务和上下文依赖信号。
主垂域不明显、控制与问答边界不清或同时召回多个领域时,读取
`references/preclassification.md`;明显的单垂域请求无需额外加载。
## 垂域召回
| 主要信号 | 主垂域 | 读取文件 |
| --- | --- | --- |
| 导航、路线、目的地、途经点、当前位置、路况、附近 POI、沿途 POI | 导航/生服 | `references/domain-navigation-life.md` |
| 车窗、座椅、空调、雨刮、车灯、驾驶模式、智驾、泊车等车辆操作 | 车控 | `references/domain-car-control.md` |
| 小米产品、汽车功能说明、故障、设置入口、本车动态状态 | 产品问答 | `references/domain-product-qa.md` |
| 音乐、视频、电台、新闻、歌单、播放和内容搜索 | 内容 | `references/domain-content.md` |
| 系统设置、应用打开关闭、屏幕操作、免打扰、亮度、音量 | 系统/App控制 | `references/domain-system-app.md` |
| 计算、时间、天气、翻译、闹钟、提醒、电话等工具能力 | 通用工具 | `references/domain-general-tools.md` |
| 家庭灯具、家电、扫地机、房间环境、IoT 设备状态 | IoT | `references/domain-iot.md` |
| 当、如果、时候、之后、每、一...就、自动化、智能习惯、超级任务 | 自动化 | `references/domain-automation.md` |
## 召回边界
- 车辆“怎么设置、什么意思、为什么异常”优先产品问答;“打开、关闭、调节”优先车控。
- 家庭设备“打开、关闭、调节”优先 IoT;系统自身的亮度、音量、应用和页面操作优先系统/App控制。
- “附近有什么”同时可能命中导航和生活服务,先看用户要导航、查询还是消费服务。
- 闹钟或提醒没有条件触发结构时属于通用工具;存在事件条件触发时同时召回自动化。
- query 只表达通用知识、闲聊或开放问题时,不强行归入垂域,后续读取核心原则。
## 结构信号
| 信号 | 何时读取 |
| --- | --- |
| 多个独立动作、跨对象/跨垂域、并列连接、多方向调整 | `references/structure-multi-instruction.md` |
| 条件触发 + 动作、自动化、智能习惯、超级任务 | `references/structure-automation.md`,并读取 `references/domain-automation.md` |
| 当前轮含省略、指代、确认/取消、修正或补充,且结论可能依赖前轮 | 先读取 `references/structure-multi-turn.md` 重建任务,再读取主垂域卡 |
## 停止条件
- 主垂域卡命中明确规则后停止加载其他垂域卡。
- 只有主垂域无法确定时才读取第二张候选垂域卡。
- 垂域和结构规则均未覆盖时读取 `references/core-policy.md`
- 命中卡片中的冲突标记时读取 `references/policy-conflicts.md`
@@ -0,0 +1,19 @@
# 2026.7 规则冲突表
本表记录产品文档内部已知冲突。当前口径为垂域规则优先,冲突不会静默覆盖。
| 冲突 ID | 场景 | 垂域结论 | 核心汇总结论 | 当前结论 |
| --- | --- | --- | --- | --- |
| `CONFLICT_NAV_CONDITIONED_POI` | 带评分等明确条件的单地点导航 | 慢 | 快 | 慢 |
| `CONFLICT_CAR_FUZZY_FUNCTION` | 非标准名称描述车辆功能 | 慢 | 快 | 慢 |
| `CONFLICT_PRODUCT_QA_SLOW_VS_AMBIGUOUS` | 静态产品知识、故障诊断 | 慢 | 模糊 | 慢 |
| `CONFLICT_PRODUCT_STATE_FAST_VS_AMBIGUOUS` | 简单车辆动态状态查询 | 快 | 模糊 | 快 |
| `CONFLICT_IOT_QUERY_VS_AMBIGUOUS` | IoT 环境或设备信息查询 | 细分为快/慢 | 模糊 | 按 IoT 细则 |
命中时:
1. 使用垂域标签。
2. 设置 `policy_conflict=true`
3. 把对应冲突 ID 写入 `conflict_refs`
4.`counterevidence` 中简述另一层规则。
5. 优先级已经解决时不自动设置 `review_required=true`
@@ -0,0 +1,31 @@
# 垂域预分类知识
本文件是从标签大师提炼的粗粒度召回知识,只帮助选择快慢分流垂域卡,不输出业务标签、function 或 Agent target。
## 预分类顺序
1. 先识别自动任务和多指令结构。
2. 再判断请求是控制、查询、内容消费还是知识问答。
3. 最后结合对象和端侧选择主垂域。
## 粗粒度映射
| 标签大师领域或典型标签 | 快慢分流垂域 |
| --- | --- |
| 地图导航、地图设置、地图问答、地址设置、走哪问哪、餐饮/旅游/汽车服务 POI | 导航/生服 |
| 车载控制 | 车控 |
| 小米产品帮助、手车互联、系统/车载/家用设备状态查询 | 产品问答 |
| 音乐、视频、电台、新闻、播放控制、歌单、内容问答 | 内容 |
| 系统控制、应用控制、屏幕操作、相机、声纹 | 系统/App控制 |
| 时间、天气、计算、翻译、闹钟、提醒、电话等工具标签 | 通用工具 |
| 设备控制、家庭 IoT 环境和设备查询 | IoT |
| 自动任务、系统/应用/设备定时控制 | 自动化 |
## 关键边界
- 同一个设备名可能落入控制或产品问答。动作执行进入控制垂域,功能说明、故障和状态查询进入产品问答。
- “附近餐厅”根据满足方式区分:要路线或地图结果进入导航/生服;要团购、订座或消费服务仍属于导航/生服大类,但保留候选业务标签。
- “提醒我明天开会”是普通提醒;“到公司时提醒我开会”是自动任务。
- 自动任务和多指令是结构信号,也可能同时存在业务垂域。
预分类不做最终裁决。关键词冲突时,以用户真实满足方式和候选垂域卡的适用范围为准。
@@ -0,0 +1,29 @@
# 自动任务结构
本文件提炼自标签大师的自动任务知识,只判断是否存在条件触发结构。
## 正例
常见结构:
- 当 / 如果 / 时候 / 之后 / 到达 / 每 / 一...就。
- 条件状态 + 动作,如“电量低于 20% 时提醒我充电”。
- 自动化、智能场景、智能习惯、超级任务等显式管理请求。
- 持续或延迟条件,如“座椅加热十分钟”。
`就`分隔条件和动作时,`就`之前的完整内容属于条件;`且`连接的多个状态都属于条件。条件默认向右作用,不影响前面的动作。
## 负例
- 无条件创建普通闹钟、提醒、日程或倒计时。
- 车辆已有固定功能的描述或控制,如“打开车道偏离预警”。
- 只有条件描述而没有要执行的动作。
- 普通功能名称中带“自动”,但用户没有创建条件任务。
## 输出信号
- `auto_task=true`:确认存在条件触发任务或自动化管理。
- `auto_task=false`:普通控制、查询或无条件工具操作。
- `auto_task=null`:缺少端侧或上下文,无法可靠判断。
确认 `auto_task=true` 后,继续读取 `references/domain-automation.md`。自动任务结构本身不直接决定快慢。
@@ -0,0 +1,28 @@
# 多指令结构
本文件提炼自标签大师的多指令知识。它只判断结构及快慢例外,不生成拆分后的业务 target。
## 识别
`multi_instruction=true` 需要至少两个可独立执行的子任务。常见信号:
- 和、并、然后、再、顺便、同时、并且连接独立动作。
- 多个对象分别执行不同操作。
- 同一设备存在多个独立方向调整。
- 跨垂域动作或前一步结果决定下一步。
以下通常仍是单意图:
- 同一设备的打开后设置属性,如“打开空调调到 26 度”。
- 同一动作作用于同品类多个位置。
- 打开应用后搜索、点击、播放等共同完成一个任务的顺序操作。
- 口误、重复、改口和连续相反操作。
- 多个并列查询目标。
## 快慢规则
- `MULTI_FAST_EXPLICIT_DEVICE_CONTROLS`:全部子任务都是明确设备控制动作,不含查询依赖、选择、排除或规划,给快。
- `MULTI_SLOW_INDEPENDENT_ACTIONS`:多个独立动作需要编排,且不满足设备控制快例外,给慢。
- `MULTI_SLOW_CROSS_DOMAIN_WORKFLOW`:跨垂域、跨工具,或前一步结果决定后一步,给慢。
垂域卡已经明确覆盖时,保留多指令信号并服从垂域规则。结构规则只在垂域未覆盖时裁决。
@@ -0,0 +1,79 @@
# 多轮上下文结构
本文件统一判断当前轮如何依赖前文。多轮本身不是快慢依据;先重建用户当前真正要完成的任务,再由主垂域裁决。
## 第一步:判断是否真的依赖前文
移除对话历史后,当前 query 的对象、动作、约束和满足方式仍然明确,则:
- `CONTEXT_INDEPENDENT`:前文与当前结论无关,按当前 query 的普通垂域规则判断。
- `context_dependent=false`
- `context_used=false`
- `context_relation="independent"`
只有省略、指代、确认、修正或补充会改变当前任务解释时,才设置 `context_dependent=true``context_used=true`
时间相邻、同一 session、相同关键词或上一轮走过某个系统,只能作为同一任务的辅助证据,不能单独证明上下文依赖。
## 第二步:重建当前任务
只继承完成当前判断所需的最少信息:
1. 找到仍在延续的最近一个有效用户任务。
2. 解析当前轮是在直接执行、补槽、改约束、继续规划,还是回答澄清。
3. 合并成语义完整的当前任务,但不得继承已经被用户取消、否定或改写的条件。
4. 前文存在多个可继承候选时,不猜测用户指向。
## 继承边界
- 当前轮明确说出新对象、新任务或“不是 A,是 B”时,以当前轮为准;被替换的旧信息不得继续保留。
- 当前轮已经能独立构成完整任务时,不因词面相似强行继承前文。
- 助手上一轮的追问可以帮助理解用户简短回答,但助手自行猜测、推荐或生成的内容不能当作用户已确认事实。
- 工具失败、能力不足、拒绝或上一轮实际路由不改变任务的语义快慢;只在能力承接字段中记录。
- 多段历史都可能被指代且无法唯一选择时,按未消歧处理。
## 上下文关系
### 可快的关系
- `CONTEXT_FAST_RESOLVED_DIRECT`:前文只提供唯一对象、位置、资源或简单槽位,当前轮是直接控制、确定性查询或明确资源切换。设置 `context_relation="resolved_direct"`
- `CONTEXT_FAST_OPERATION_CONTINUATION`:用户处于已经明确的操作流中,当前轮执行返回、确认、取消、暂停、继续、切换等直接动作。设置 `context_relation="operation_continuation"`
这两类只是“具备判快条件”。若重建后的完整任务命中垂域慢规则,仍按垂域规则给慢。
### 应慢的关系
- `CONTEXT_SLOW_CONSTRAINT_EXTENSION`:当前轮新增或修改筛选、排序、比较、排除、路线、场景等约束,需要重新检索或规划。设置 `context_relation="constraint_extension"`
- `CONTEXT_SLOW_PLANNING_CONTINUATION`:当前轮继续上一轮的分析、推荐、生成、故障排查或多步规划。设置 `context_relation="planning_continuation"`
- `CONTEXT_SLOW_TRUE_DISAMBIGUATION`:结合现有历史后仍有多个真实候选,或关键槽位仍缺失,需要追问或推断。设置 `context_relation="unresolved_reference"`
### 回答澄清
- `CONTEXT_CLARIFICATION_COMPLETION`:当前轮只回答系统上一轮的澄清、确认或补槽问题。设置 `context_relation="clarification_completion"`
这类不按“是、不是、确认、取消”等表面词直接定标,而是重建完整任务:
- 澄清完成后是明确直接操作或确定性查询,按对应快规则判断。
- 澄清完成后仍是推荐、规划、故障分析或场景映射,按对应慢规则判断。
- 无法确认系统究竟在澄清哪个任务时,进入复核。
## 历史缺失与复核
- `CONTEXT_REVIEW_HISTORY_MISSING`:当前轮明显依赖前文,但输入没有提供足够历史,且不同解释会改变垂域或标签。
此时不得凭空补全:
```text
context_relation="unresolved_reference"
confidence="low"
review_required=true
```
## 裁决顺序
1. 使用本文件识别 `context_relation` 并重建当前任务。
2. 使用重建后的任务读取主垂域卡。
3. 主垂域有明确多轮或业务规则时,以垂域规则为最终结论。
4. 主垂域没有覆盖时,才使用本文件中的 `CONTEXT_FAST_*``CONTEXT_SLOW_*` 作为结构层结论。
禁止根据上一轮实际走快系统还是慢系统直接继承标签。
@@ -0,0 +1,257 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://local.skills/label-fast-slow-routing/label-record.schema.json",
"title": "2026.7 快慢分流分层打标记录",
"type": "object",
"additionalProperties": false,
"required": [
"query",
"label",
"default_route",
"domain",
"candidate_domains",
"decision_level",
"decision_rule_id",
"decision_source",
"decision_path",
"reason",
"evidence",
"structural_signals",
"context_used",
"fast_system_pending",
"route_override",
"policy_conflict",
"conflict_refs",
"confidence",
"review_required",
"uncertainties",
"policy_version"
],
"properties": {
"id": {
"type": ["string", "number", "null"]
},
"query": {
"type": "string",
"minLength": 1
},
"label": {
"enum": ["快", "慢", "模糊"]
},
"default_route": {
"enum": ["快系统", "慢系统"]
},
"domain": {
"enum": [
"导航/生服",
"车控",
"产品问答",
"内容",
"系统/App控制",
"通用工具",
"IoT",
"自动化",
null
]
},
"candidate_domains": {
"type": "array",
"uniqueItems": true,
"items": {
"enum": [
"导航/生服",
"车控",
"产品问答",
"内容",
"系统/App控制",
"通用工具",
"IoT",
"自动化"
]
}
},
"decision_level": {
"enum": ["domain", "structure", "core"]
},
"decision_rule_id": {
"type": "string",
"pattern": "^[A-Z][A-Z0-9_]+$"
},
"decision_source": {
"type": "string",
"pattern": "^references/[a-z0-9-]+\\.md(?:#.*)?$"
},
"decision_path": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
},
"reason": {
"type": "string",
"minLength": 1
},
"evidence": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
},
"counterevidence": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"structural_signals": {
"type": "object",
"additionalProperties": false,
"required": [
"complex",
"multi_instruction",
"auto_task",
"context_dependent"
],
"properties": {
"complex": {
"type": ["boolean", "null"]
},
"multi_instruction": {
"type": ["boolean", "null"]
},
"auto_task": {
"type": ["boolean", "null"]
},
"context_dependent": {
"type": ["boolean", "null"]
}
}
},
"context_used": {
"type": "boolean"
},
"context_relation": {
"enum": [
"independent",
"resolved_direct",
"operation_continuation",
"constraint_extension",
"planning_continuation",
"clarification_completion",
"unresolved_reference",
null
]
},
"fast_system_pending": {
"type": ["boolean", "null"]
},
"route_override": {
"type": ["string", "null"]
},
"policy_conflict": {
"type": "boolean"
},
"conflict_refs": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^CONFLICT_[A-Z0-9_]+$"
}
},
"confidence": {
"enum": ["high", "medium", "low"]
},
"review_required": {
"type": "boolean"
},
"uncertainties": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"policy_version": {
"const": "FAST-SLOW-2026.7"
}
},
"allOf": [
{
"if": {
"properties": {
"label": {
"const": "快"
}
},
"required": ["label"]
},
"then": {
"properties": {
"default_route": {
"const": "快系统"
}
}
}
},
{
"if": {
"properties": {
"label": {
"enum": ["慢", "模糊"]
}
},
"required": ["label"]
},
"then": {
"properties": {
"default_route": {
"const": "慢系统"
},
"fast_system_pending": {
"enum": [false, null]
}
}
}
},
{
"if": {
"properties": {
"policy_conflict": {
"const": true
}
},
"required": ["policy_conflict"]
},
"then": {
"properties": {
"conflict_refs": {
"minItems": 1
}
}
}
},
{
"if": {
"properties": {
"confidence": {
"const": "low"
}
},
"required": ["confidence"]
},
"then": {
"properties": {
"review_required": {
"const": true
}
}
}
}
]
}
@@ -0,0 +1,436 @@
#!/usr/bin/env python3
"""Validate 2026.7 hierarchical fast/slow routing records."""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
POLICY_VERSION = "FAST-SLOW-2026.7"
SKILL_ROOT = Path(__file__).resolve().parent.parent
CONFLICT_FILE = SKILL_ROOT / "references" / "policy-conflicts.md"
DEFAULT_ROUTE_BY_LABEL = {
"": "快系统",
"": "慢系统",
"模糊": "慢系统",
}
CONFIDENCE_VALUES = {"high", "medium", "low"}
DECISION_LEVELS = {"domain", "structure", "core"}
DOMAIN_VALUES = {
"导航/生服",
"车控",
"产品问答",
"内容",
"系统/App控制",
"通用工具",
"IoT",
"自动化",
}
RULE_ID_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]+$")
CONFLICT_ID_PATTERN = re.compile(r"^CONFLICT_[A-Z0-9_]+$")
REQUIRED_FIELDS = {
"query",
"label",
"default_route",
"domain",
"candidate_domains",
"decision_level",
"decision_rule_id",
"decision_source",
"decision_path",
"reason",
"evidence",
"structural_signals",
"context_used",
"fast_system_pending",
"route_override",
"policy_conflict",
"conflict_refs",
"confidence",
"review_required",
"uncertainties",
"policy_version",
}
OPTIONAL_FIELDS = {
"id",
"counterevidence",
"context_relation",
}
CONTEXT_RELATION_VALUES = {
"independent",
"resolved_direct",
"operation_continuation",
"constraint_extension",
"planning_continuation",
"clarification_completion",
"unresolved_reference",
}
def _is_nonempty_string(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def _check_string_list(
value: Any,
*,
field: str,
errors: list[str],
require_nonempty: bool = False,
unique: bool = False,
) -> None:
if not isinstance(value, list):
errors.append(f"{field} 必须是数组")
return
if require_nonempty and not value:
errors.append(f"{field} 至少包含一项")
if any(not _is_nonempty_string(item) for item in value):
errors.append(f"{field} 的每一项都必须是非空字符串")
if unique and all(isinstance(item, str) for item in value):
if len(value) != len(set(value)):
errors.append(f"{field} 不允许重复项")
def _validate_decision_source(
record: dict[str, Any],
*,
errors: list[str],
) -> None:
source = record.get("decision_source")
rule_id = record.get("decision_rule_id")
level = record.get("decision_level")
if not _is_nonempty_string(source):
errors.append("decision_source 必须是非空字符串")
return
relative_path = source.split("#", 1)[0]
if not relative_path.startswith("references/") or not relative_path.endswith(".md"):
errors.append("decision_source 必须指向 references/*.md")
return
resolved = (SKILL_ROOT / relative_path).resolve()
references_root = (SKILL_ROOT / "references").resolve()
if references_root not in resolved.parents:
errors.append("decision_source 不允许跳出 references 目录")
return
if not resolved.is_file():
errors.append(f"decision_source 文件不存在: {relative_path}")
return
try:
source_text = resolved.read_text(encoding="utf-8")
except OSError as exc:
errors.append(f"无法读取 decision_source: {exc}")
return
if _is_nonempty_string(rule_id) and rule_id not in source_text:
errors.append(f"decision_rule_id 未出现在来源文件中: {rule_id}")
filename = resolved.name
if level == "domain" and not filename.startswith("domain-"):
errors.append("decision_level=domain 时来源文件必须是 domain-*.md")
if level == "structure" and not filename.startswith("structure-"):
errors.append("decision_level=structure 时来源文件必须是 structure-*.md")
if level == "core" and filename != "core-policy.md":
errors.append("decision_level=core 时来源文件必须是 core-policy.md")
def _validate_conflicts(
record: dict[str, Any],
*,
errors: list[str],
) -> None:
policy_conflict = record.get("policy_conflict")
conflict_refs = record.get("conflict_refs")
if not isinstance(policy_conflict, bool):
errors.append("policy_conflict 必须是布尔值")
_check_string_list(
conflict_refs,
field="conflict_refs",
errors=errors,
unique=True,
)
if not isinstance(conflict_refs, list):
return
if policy_conflict is True and not conflict_refs:
errors.append("policy_conflict=true 时 conflict_refs 至少包含一项")
if policy_conflict is False and conflict_refs:
errors.append("policy_conflict=false 时 conflict_refs 必须为空")
try:
known_conflicts = CONFLICT_FILE.read_text(encoding="utf-8")
except OSError as exc:
errors.append(f"无法读取冲突表: {exc}")
return
for conflict_id in conflict_refs:
if not isinstance(conflict_id, str):
continue
if not CONFLICT_ID_PATTERN.fullmatch(conflict_id):
errors.append(f"冲突 ID 格式非法: {conflict_id!r}")
elif conflict_id not in known_conflicts:
errors.append(f"冲突 ID 未登记: {conflict_id}")
def validate_record(record: Any, index: int) -> dict[str, Any]:
errors: list[str] = []
warnings: list[str] = []
if not isinstance(record, dict):
return {
"index": index,
"valid": False,
"errors": ["记录必须是 JSON 对象"],
"warnings": [],
}
if "__parse_error__" in record:
return {
"index": index,
"valid": False,
"errors": [str(record["__parse_error__"])],
"warnings": [],
}
missing = sorted(REQUIRED_FIELDS - set(record))
if missing:
errors.append(f"缺少必填字段: {', '.join(missing)}")
unknown = sorted(set(record) - REQUIRED_FIELDS - OPTIONAL_FIELDS)
if unknown:
errors.append(f"存在未知字段: {', '.join(unknown)}")
if not _is_nonempty_string(record.get("query")):
errors.append("query 必须是非空字符串")
label = record.get("label")
if not isinstance(label, str) or label not in DEFAULT_ROUTE_BY_LABEL:
errors.append("label 必须是 快、慢、模糊 之一")
expected_route = DEFAULT_ROUTE_BY_LABEL.get(label) if isinstance(label, str) else None
if expected_route is not None and record.get("default_route") != expected_route:
errors.append(f"label={label} 时 default_route 必须是 {expected_route}")
domain = record.get("domain")
if domain is not None and (
not isinstance(domain, str) or domain not in DOMAIN_VALUES
):
errors.append("domain 不是受支持的粗粒度垂域")
_check_string_list(
record.get("candidate_domains"),
field="candidate_domains",
errors=errors,
unique=True,
)
candidate_domains = record.get("candidate_domains")
if isinstance(candidate_domains, list):
unknown_domains = [
value
for value in candidate_domains
if not isinstance(value, str) or value not in DOMAIN_VALUES
]
if unknown_domains:
errors.append(f"candidate_domains 包含未知垂域: {unknown_domains}")
level = record.get("decision_level")
if not isinstance(level, str) or level not in DECISION_LEVELS:
errors.append("decision_level 必须是 domain、structure、core 之一")
if level == "domain" and domain is None:
errors.append("decision_level=domain 时 domain 不能为空")
rule_id = record.get("decision_rule_id")
if not _is_nonempty_string(rule_id) or not RULE_ID_PATTERN.fullmatch(rule_id):
errors.append("decision_rule_id 必须是大写下划线规则 ID")
_check_string_list(
record.get("decision_path"),
field="decision_path",
errors=errors,
require_nonempty=True,
)
decision_path = record.get("decision_path")
if (
isinstance(decision_path, list)
and _is_nonempty_string(rule_id)
and not any(rule_id in str(item) for item in decision_path)
):
errors.append("decision_path 必须包含 decision_rule_id")
_validate_decision_source(record, errors=errors)
if not _is_nonempty_string(record.get("reason")):
errors.append("reason 必须是非空字符串")
_check_string_list(
record.get("evidence"),
field="evidence",
errors=errors,
require_nonempty=True,
)
if "counterevidence" in record:
_check_string_list(
record.get("counterevidence"),
field="counterevidence",
errors=errors,
)
structural_signals = record.get("structural_signals")
expected_signal_keys = {
"complex",
"multi_instruction",
"auto_task",
"context_dependent",
}
if not isinstance(structural_signals, dict):
errors.append("structural_signals 必须是对象")
else:
actual_keys = set(structural_signals)
if actual_keys != expected_signal_keys:
errors.append(
"structural_signals 必须且只能包含 "
"complex、multi_instruction、auto_task、context_dependent"
)
for key in expected_signal_keys:
value = structural_signals.get(key)
if value is not None and not isinstance(value, bool):
errors.append(f"structural_signals.{key} 必须是布尔值或 null")
if not isinstance(record.get("context_used"), bool):
errors.append("context_used 必须是布尔值")
context_relation = record.get("context_relation")
if context_relation is not None and (
not isinstance(context_relation, str)
or context_relation not in CONTEXT_RELATION_VALUES
):
errors.append("context_relation 不是受支持的上下文关系")
if context_relation == "independent" and record.get("context_used") is True:
errors.append("context_relation=independent 时 context_used 必须为 false")
if context_relation not in {None, "independent"}:
if record.get("context_used") is not True:
errors.append("依赖上下文的 context_relation 要求 context_used=true")
if isinstance(structural_signals, dict) and (
structural_signals.get("context_dependent") is not True
):
errors.append(
"依赖上下文的 context_relation 要求 "
"structural_signals.context_dependent=true"
)
fast_system_pending = record.get("fast_system_pending")
if fast_system_pending is not None and not isinstance(fast_system_pending, bool):
errors.append("fast_system_pending 必须是布尔值或 null")
if fast_system_pending is True and label != "":
errors.append("只有 label=快 时 fast_system_pending 才能为 true")
route_override = record.get("route_override")
if route_override is not None and not _is_nonempty_string(route_override):
errors.append("route_override 必须是非空字符串或 null")
_validate_conflicts(record, errors=errors)
confidence = record.get("confidence")
if not isinstance(confidence, str) or confidence not in CONFIDENCE_VALUES:
errors.append("confidence 必须是 high、medium、low 之一")
review_required = record.get("review_required")
if not isinstance(review_required, bool):
errors.append("review_required 必须是布尔值")
if confidence == "low" and review_required is not True:
errors.append("confidence=low 时 review_required 必须为 true")
_check_string_list(
record.get("uncertainties"),
field="uncertainties",
errors=errors,
)
uncertainties = record.get("uncertainties")
if isinstance(uncertainties, list) and uncertainties and review_required is False:
warnings.append("uncertainties 非空但 review_required=false,请确认不确定点不影响标签")
if record.get("policy_version") != POLICY_VERSION:
errors.append(f"policy_version 必须是 {POLICY_VERSION}")
return {
"index": index,
"id": record.get("id"),
"valid": not errors,
"errors": errors,
"warnings": warnings,
}
def _load_records_from_file(path: Path) -> list[Any]:
text = path.read_text(encoding="utf-8-sig")
if path.suffix.lower() == ".jsonl":
records: list[Any] = []
for line_number, line in enumerate(text.splitlines(), start=1):
if not line.strip():
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError as exc:
records.append(
{
"__parse_error__": (
f"JSONL 第 {line_number} 行解析失败: {exc.msg}"
)
}
)
return records
payload = json.loads(text)
if isinstance(payload, list):
return payload
if isinstance(payload, dict) and isinstance(payload.get("records"), list):
return payload["records"]
return [payload]
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="校验 FAST-SLOW-2026.7 分层打标 JSON/JSONL"
)
source_group = parser.add_mutually_exclusive_group(required=True)
source_group.add_argument("--record", help="单条 JSON 对象字符串")
source_group.add_argument("--file", type=Path, help="JSON 或 JSONL 文件路径")
parser.add_argument(
"--strict",
action="store_true",
help="把 warning 也视为校验失败",
)
return parser.parse_args()
def main() -> int:
args = _parse_args()
try:
if args.record is not None:
records = [json.loads(args.record)]
else:
records = _load_records_from_file(args.file)
except (OSError, json.JSONDecodeError) as exc:
result = {
"valid": False,
"total": 0,
"invalid": 1,
"warning_count": 0,
"errors": [str(exc)],
"results": [],
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 1
results = [validate_record(record, index) for index, record in enumerate(records)]
invalid = sum(not result["valid"] for result in results)
warning_count = sum(len(result["warnings"]) for result in results)
valid = invalid == 0 and (not args.strict or warning_count == 0)
summary = {
"valid": valid,
"total": len(records),
"invalid": invalid,
"warning_count": warning_count,
"results": results,
}
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0 if valid else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,35 @@
# 屏幕操作
## 标注输出
- 旧 tag 标签:屏幕操作
- Agent 形式(推荐最终输出):Agent(tag="屏幕操作")
- 输出形态:intent 包装,直接 Agent(tag="屏幕操作"),非 function program
## 功能抽象
屏幕内已呈现 GUI 元素的点击/点选操作
## 适用范围
针对当前屏幕上已呈现的 UI 元素(按钮、图标、卡片、海报、列表项、开关、菜单项等)执行点击、点选、选中,如"点击订购信息""点我的历史""点某电影海报""点某图标""点会员卡片"。是对已可见界面元素的直接操作。
## 典型 Query
点击订购信息 / 点一下账号管理 / 我想点我的历史 / 帮我点我的想看 / 点击设置 / 点一下通知中心 / 点某电影海报 / 点某节目封面 / 点某开关图标 / 点小米影视VIP会员卡片
## 易混淆标签
- 应用控制:打开/关闭/卸载某 App 或跳转到某页面("打开页面"),屏幕操作是"点击页面内已有元素"
- 系统控制:系统级设置项开关调节(亮度/音量/护眼等),非 GUI 元素点击
- 图片问答(VisionQA):对屏幕内容做识别/问答,非点击操作
## 划分原则
- 已呈现界面元素的点击/点选 → 屏幕操作
- 打开/进入某应用或某设置页 → 应用控制 / 系统控制
- query 明确"点/点击/点一下/选中" + 界面元素名 → 屏幕操作
## 未解决问题
待补充。
@@ -28,6 +28,7 @@
| 小米产品问答 | 小米产品、设备状态、车机/家居产品知识等 | `标签/小米产品问答/` | | 小米产品问答 | 小米产品、设备状态、车机/家居产品知识等 | `标签/小米产品问答/` |
| 内容和媒体播放 | 音乐、视频、电台、新闻、古诗、笑话等播放类能力 | `标签/内容和媒体播放/` | | 内容和媒体播放 | 音乐、视频、电台、新闻、古诗、笑话等播放类能力 | `标签/内容和媒体播放/` |
| 应用控制和搜索 | 应用搜索、应用打开、浏览器搜索等 | `标签/应用控制和搜索/` | | 应用控制和搜索 | 应用搜索、应用打开、浏览器搜索等 | `标签/应用控制和搜索/` |
| 屏幕操作 | 当前屏幕已呈现 GUI 元素的点击、点选、选中 | `标签/屏幕操作/` |
| AI创作 | 文本、图片、视频、代码等创作类能力 | `标签/AI创作/` | | AI创作 | 文本、图片、视频、代码等创作类能力 | `标签/AI创作/` |
| 工具类 | 翻译、计算、日历、提醒、计时、查询等工具型能力 | `标签/工具类/` | | 工具类 | 翻译、计算、日历、提醒、计时、查询等工具型能力 | `标签/工具类/` |
@@ -54,6 +54,7 @@
"小憩模式", "小憩模式",
"小爱帮助", "小爱帮助",
"小米产品帮助", "小米产品帮助",
"屏幕操作",
"工资税收计算", "工资税收计算",
"平台比价->商品比价", "平台比价->商品比价",
"应用定时控制", "应用定时控制",
@@ -213,10 +214,10 @@
} }
], ],
"counts": { "counts": {
"agent_tags": 134, "agent_tags": 135,
"functions": 13, "functions": 13,
"intents": 83, "intents": 83,
"labels": 125, "labels": 126,
"objects": 14 "objects": 14
}, },
"description": "标签大师的中控标签知识库机器索引;由 scripts/build_label_manifest.py 从 Markdown 生成。", "description": "标签大师的中控标签知识库机器索引;由 scripts/build_label_manifest.py 从 Markdown 生成。",
@@ -1597,6 +1598,19 @@
"recommended_output_shape": "待确认。", "recommended_output_shape": "待确认。",
"scope": "针对小米汽车特有的,区别与其他小米终端类型的设置项查询,限定在车载范围。只有在车载端给小米产品问答,在其他端是QA(),通用问答会根据知识回答其他非小米产品或通用车载功能的问题。" "scope": "针对小米汽车特有的,区别与其他小米终端类型的设置项查询,限定在车载范围。只有在车载端给小米产品问答,在其他端是QA(),通用问答会根据知识回答其他非小米产品或通用车载功能的问题。"
}, },
{
"agent_candidate": "",
"confusing_labels": "- 应用控制:打开/关闭/卸载某 App 或跳转到某页面(\"打开页面\"),屏幕操作是\"点击页面内已有元素\"\n- 系统控制:系统级设置项开关调节(亮度/音量/护眼等),非 GUI 元素点击\n- 图片问答(VisionQA):对屏幕内容做识别/问答,非点击操作",
"domain": "屏幕操作",
"examples": "点击订购信息 / 点一下账号管理 / 我想点我的历史 / 帮我点我的想看 / 点击设置 / 点一下通知中心 / 点某电影海报 / 点某节目封面 / 点某开关图标 / 点小米影视VIP会员卡片",
"function_candidate": "",
"name": "屏幕操作",
"old_tag": "屏幕操作",
"path": "knowledge/标签/屏幕操作/屏幕操作.md",
"principles": "- 已呈现界面元素的点击/点选 → 屏幕操作\n- 打开/进入某应用或某设置页 → 应用控制 / 系统控制\n- query 明确\"点/点击/点一下/选中\" + 界面元素名 → 屏幕操作",
"recommended_output_shape": "",
"scope": "针对当前屏幕上已呈现的 UI 元素(按钮、图标、卡片、海报、列表项、开关、菜单项等)执行点击、点选、选中,如\"点击订购信息\"\"点我的历史\"\"点某电影海报\"\"点某图标\"\"点会员卡片\"。是对已可见界面元素的直接操作。"
},
{ {
"agent_candidate": "待确认。", "agent_candidate": "待确认。",
"confusing_labels": "音乐", "confusing_labels": "音乐",
@@ -21,6 +21,7 @@
| 介绍、解释、是什么、为什么、来源、原理、百科、搜索一下 | QA、问答、搜索、具体问答垂域 | [问答](../标签/通用问答/问答.md)[函数目录 QA](../输出能力/函数目录.md) | | 介绍、解释、是什么、为什么、来源、原理、百科、搜索一下 | QA、问答、搜索、具体问答垂域 | [问答](../标签/通用问答/问答.md)[函数目录 QA](../输出能力/函数目录.md) |
| 写、生成、创作、润色、改写、作文、文案、剧本、诗歌 | 文本创作、作文、Generate、QA | [文本创作](../标签/AI创作/文本创作.md)[函数目录 Generate](../输出能力/函数目录.md) | | 写、生成、创作、润色、改写、作文、文案、剧本、诗歌 | 文本创作、作文、Generate、QA | [文本创作](../标签/AI创作/文本创作.md)[函数目录 Generate](../输出能力/函数目录.md) |
| 打开、关闭、调节、设置 + 音量、亮度、蓝牙、护眼、关机、截图、朗读屏幕 | 系统控制、应用控制、相机、声纹 | [系统控制-设备控制-车载控制](../边界/高频混淆/系统控制-设备控制-车载控制.md)[系统控制](../标签/系统控制和IOT设备控制/系统控制.md) | | 打开、关闭、调节、设置 + 音量、亮度、蓝牙、护眼、关机、截图、朗读屏幕 | 系统控制、应用控制、相机、声纹 | [系统控制-设备控制-车载控制](../边界/高频混淆/系统控制-设备控制-车载控制.md)[系统控制](../标签/系统控制和IOT设备控制/系统控制.md) |
| 点、点击、点一下、选中 + 当前屏幕已呈现的按钮、图标、卡片、海报、列表项、菜单项 | 屏幕操作、应用控制、系统控制、图片问答 | [屏幕操作](../标签/屏幕操作/屏幕操作.md)[应用控制](../标签/应用控制和搜索/应用控制.md)[图片问答](../标签/通用问答/图片问答.md) |
| 打开、关闭、调节、设置 + 家居设备、灯、电视、扫地机、洗衣机、主卧/客厅/厨房 | 设备控制、设备定时控制、系统控制 | [系统控制-设备控制-车载控制](../边界/高频混淆/系统控制-设备控制-车载控制.md)[设备控制](../标签/系统控制和IOT设备控制/设备控制.md) | | 打开、关闭、调节、设置 + 家居设备、灯、电视、扫地机、洗衣机、主卧/客厅/厨房 | 设备控制、设备定时控制、系统控制 | [系统控制-设备控制-车载控制](../边界/高频混淆/系统控制-设备控制-车载控制.md)[设备控制](../标签/系统控制和IOT设备控制/设备控制.md) |
| 打开、关闭、调节、设置 + 车窗、座椅、雨刮、前备箱、智驾、泊车、哨兵、驾驶模式 | 车载控制、车载设备状态查询、自动任务、系统控制 | [系统控制-设备控制-车载控制](../边界/高频混淆/系统控制-设备控制-车载控制.md)[车载控制](../标签/系统控制和IOT设备控制/车载控制.md) | | 打开、关闭、调节、设置 + 车窗、座椅、雨刮、前备箱、智驾、泊车、哨兵、驾驶模式 | 车载控制、车载设备状态查询、自动任务、系统控制 | [系统控制-设备控制-车载控制](../边界/高频混淆/系统控制-设备控制-车载控制.md)[车载控制](../标签/系统控制和IOT设备控制/车载控制.md) |
| 当、如果、时候、到达、之后、每、一...就、自动化、智能习惯、超级任务 | 自动任务、提醒、设备控制、车载控制 | [自动任务判断](../判断维度/自动任务判断.md)[自动任务](../标签/系统控制和IOT设备控制/自动任务.md) | | 当、如果、时候、到达、之后、每、一...就、自动化、智能习惯、超级任务 | 自动任务、提醒、设备控制、车载控制 | [自动任务判断](../判断维度/自动任务判断.md)[自动任务](../标签/系统控制和IOT设备控制/自动任务.md) |
@@ -78,6 +78,7 @@
| 系统控制和IOT设备控制 | 小憩模式 | 小憩模式开关、休息意图触发("我要休息"/"我要睡觉"等)、小憩状态查询 | Agent(tag="小憩模式") | x0 = Alarm(type="RESTMODE")Search(alarm=[x0]) | [小憩模式](../标签/系统控制和IOT设备控制/小憩模式.md) | | 系统控制和IOT设备控制 | 小憩模式 | 小憩模式开关、休息意图触发("我要休息"/"我要睡觉"等)、小憩状态查询 | Agent(tag="小憩模式") | x0 = Alarm(type="RESTMODE")Search(alarm=[x0]) | [小憩模式](../标签/系统控制和IOT设备控制/小憩模式.md) |
| 应用控制和搜索 | 应用定时控制 | 应用定时控制 | Agent(tag="应用定时控制") | 待确认 | [应用定时控制](../标签/应用控制和搜索/应用定时控制.md) | | 应用控制和搜索 | 应用定时控制 | 应用定时控制 | Agent(tag="应用定时控制") | 待确认 | [应用定时控制](../标签/应用控制和搜索/应用定时控制.md) |
| 应用控制和搜索 | 应用控制 | 应用控制 | Agent(tag="应用控制") | function定义:参数定义:app: 对应的应用名page: 打开的对应的页面Action: 具体的操作,如:ope... | [应用控制](../标签/应用控制和搜索/应用控制.md) | | 应用控制和搜索 | 应用控制 | 应用控制 | Agent(tag="应用控制") | function定义:参数定义:app: 对应的应用名page: 打开的对应的页面Action: 具体的操作,如:ope... | [应用控制](../标签/应用控制和搜索/应用控制.md) |
| 屏幕操作 | 屏幕操作 | 屏幕操作 | Agent(tag="屏幕操作") | 无,intent 包装,非 function program | [屏幕操作](../标签/屏幕操作/屏幕操作.md) |
| 应用控制和搜索 | 搜索\|应用名(搜索\|QQ音乐) | 搜索\|应用名(搜索\|QQ音乐) | Agent(tag="搜索\|应用名(搜索\|QQ音乐)") | function定义:参数定义:app 对应的应用名content: 需要搜索的内容示例:Query:打开快手搜索情侣... | [搜索\|应用名(搜索\|QQ音乐)](../标签/应用控制和搜索/搜索-应用名-QQ音乐.md) | | 应用控制和搜索 | 搜索\|应用名(搜索\|QQ音乐) | 搜索\|应用名(搜索\|QQ音乐) | Agent(tag="搜索\|应用名(搜索\|QQ音乐)") | function定义:参数定义:app 对应的应用名content: 需要搜索的内容示例:Query:打开快手搜索情侣... | [搜索\|应用名(搜索\|QQ音乐)](../标签/应用控制和搜索/搜索-应用名-QQ音乐.md) |
| 应用控制和搜索 | 浏览器搜索 | 浏览器搜索 | Agent(tag="浏览器搜索") | 待确认 | [浏览器搜索](../标签/应用控制和搜索/浏览器搜索.md) | | 应用控制和搜索 | 浏览器搜索 | 浏览器搜索 | Agent(tag="浏览器搜索") | 待确认 | [浏览器搜索](../标签/应用控制和搜索/浏览器搜索.md) |
| 生活服务 | 交通购票 | 交通购票 | Agent(tag="交通购票") | function定义:参数定义: | [交通购票](../标签/生活服务/交通购票.md) | | 生活服务 | 交通购票 | 交通购票 | Agent(tag="交通购票") | function定义:参数定义: | [交通购票](../标签/生活服务/交通购票.md) |
+126 -10
View File
@@ -1,8 +1,8 @@
--- ---
name: model-training-lite name: model-training-lite
name_zh: 轻量模型训练 name_zh: 轻量模型训练
description: 从 Codex、Claude Code 或其他 Agent 通过用户提供的 Jupyter 环境和数据版本发起一次模型训练,聚焦“准备远端工作区、同步数据、提交 CML SFT、返回 JobID”,不包含完整自主迭代闭环。 description: 从 Codex、Claude Code 或其他 Agent 通过用户提供的 Jupyter 环境和数据版本发起一次模型训练,并可选发起评测和生成轻量结果摘要;不包含自动数据增强闭环。
when_to_use: 当用户已经准备好训练数据,想让 Agent 使用 Jupyter/CloudML 发起一次模型训练、复用当前仓库的训练脚本和模板、或询问如何把模型训练流程沉淀成轻量 skill 时使用。 when_to_use: 当用户已经准备好训练数据,想让 Agent 使用 Jupyter/CloudML 基于某个数据分支或 commit 发起一次模型训练、训练后评测、汇总结果,或询问如何把模型训练流程沉淀成轻量 skill 时使用。
aliases: jupyter-model-training, sft-submit, 轻量训练, 模型训练提交 aliases: jupyter-model-training, sft-submit, 轻量训练, 模型训练提交
examples: examples:
- 用这个 Jupyter 和 ai-planning 当前分支重新发起训练 - 用这个 Jupyter 和 ai-planning 当前分支重新发起训练
@@ -14,7 +14,17 @@ allowed_tools: read_file, write_file, grep_search, glob_search, ask_user_questio
# 轻量模型训练 # 轻量模型训练
本 skill 只负责一次训练提交: 本 skill 是人机协同的训练/评测执行器:人负责目标和边界裁决,AI 负责候选、统计、证据整理和确定性执行。
```text
人提出目标问题
-> AI 整理候选、统计分布、生成 review 表
-> 人确认边界、保留样本、数据是否入库
-> AI 提交训练、发起评测、汇总结果
-> 人判断是否继续下一轮
```
训练评测执行链路:
```text ```text
Jupyter 授权环境 Jupyter 授权环境
@@ -23,26 +33,67 @@ Jupyter 授权环境
-> 同步训练脚本与数据版本 -> 同步训练脚本与数据版本
-> 检查 CML / 数据 / zk_trainer -> 检查 CML / 数据 / zk_trainer
-> 提交 SFT -> 提交 SFT
-> 返回 JobID、产物路径、下一步评测入口 -> 可选:SFT 成功后提交 CML 评测
-> 可选:读取 workflow metric_diff 生成轻量结果摘要
-> 返回 JobID、workflow ID、产物路径、摘要路径
``` ```
不要把它扩展成 `model-iteration` 那种完整“baseline -> 分析 -> 增强 -> 训练 -> 评测 -> 再分析”的自主循环。 不要把它扩展成 `model-iteration` 那种完整“baseline -> 分析 -> 增强 -> 训练 -> 评测 -> 再分析”的自主循环。本 skill 可以辅助人完成每一步决策前的信息准备,但不自动裁决边界、不自动修改数据、不自动生成增强样本、不自动进入下一轮训练。
## 必要输入 ## 必要输入
开始前必须拿到: 开始前必须拿到:
- **Jupyter 地址和授权方式**URL;密码、token、cookie/session,或说明当前环境已有可用登录态 - **Jupyter 地址和授权方式**URL;密码、token、cookie/session,或使用本地默认配置
- **数据版本**git repo + branch + commit,或远端工作区中已存在的数据路径。 - **数据版本**git repo + branch + commit,或 git repo + branch,或远端工作区中已存在的数据路径。
- **训练配方**:如果是当前 ZK 中控 SFT,默认复用 `skills/model-iteration/scripts/`;如果不是,必须让用户提供训练脚本、模板或命令。 - **训练配方**:如果是当前 ZK 中控 SFT,默认复用 `skills/model-iteration/scripts/`;如果不是,必须让用户提供训练脚本、模板或命令。
通常用户只需要显式给 `Jupyter 地址``数据版本`。当前项目的默认训练配方可从本仓库继承,不必每次追问。 通常用户只需要显式给 `Jupyter 地址``数据版本`。当前项目的默认训练配方可从本仓库继承,不必每次追问。
组内默认 Jupyter 配置路径:
```text
skills/model-training-lite/jupyter_defaults.json
```
这个文件随 skill 提交,组内默认可直接使用。脚本输出只显示认证是否已配置,不回显密码。
个人覆盖配置路径:
```text
skills/model-training-lite/.local/jupyter_defaults.json
```
`.local/` 已加入 gitignore。需要临时覆盖组内默认配置时,使用 `.local/` 或环境变量,不要改共享配置。
配置格式可参考:
```text
skills/model-training-lite/jupyter_defaults.example.json
```
脚本也支持环境变量:
```text
MODEL_TRAINING_LITE_JUPYTER_URL
MODEL_TRAINING_LITE_JUPYTER_PASSWORD
MODEL_TRAINING_LITE_JUPYTER_TOKEN
MODEL_TRAINING_LITE_JUPYTER_COOKIE
MODEL_TRAINING_LITE_JUPYTER_AUTH_TYPE
```
配置优先级:
```text
脚本输入 > 环境变量 > .local/jupyter_defaults.json > jupyter_defaults.json
```
可选但推荐确认: 可选但推荐确认:
- `owner`:远端输出目录使用的用户前缀,例如 `wuyang6` - `owner`:远端输出目录使用的用户前缀,例如 `wuyang6`
- `run_name`:本次训练工作区名,例如 `manual_YYYYMMDD_zk_intent_xxx` - `run_name`:本次训练工作区名,例如 `manual_YYYYMMDD_zk_intent_xxx`
- 是否训练后立刻发起评测。默认只提交训练,不自动串接评测 - 是否训练后立刻发起评测。默认只提交训练;用户要求“训练后评测/看效果/分析结果”时启用
- `model_old`:评测工作流中的对照模型路径;未提供时使用默认线上基线。
## 默认 ZK SFT 配方 ## 默认 ZK SFT 配方
@@ -62,6 +113,22 @@ skills/model-iteration/assets/config.yaml
## 推荐流程 ## 推荐流程
### 0. 人机协同边界
AI 应主动辅助人完成信息准备,但不能替人拍板:
| 阶段 | AI 负责 | 人负责 |
|---|---|---|
| 目标定义 | 把问题拆成可评测口径,列出候选集合和风险 | 确认目标问题和主指标 |
| 数据准备 | 调用相关 skill 生成候选、统计、review 表 | 判断边界样本是否保留 |
| 数据构造 | 参考 `product-data` 生成训练/评测格式 | 确认数据是否入库 |
| 标签判断 | 参考 `label-master` 校验 tag / function / complex | 确认争议规则 |
| 线上挖掘 | 参考 `online-mining-v2` 获取 rid/session/prompt/output | 确认挖掘口径 |
| 批量打标 | 参考 `model-labeling` 请求线上模型,生成基线 | 确认宽口径和人工 override |
| 训练评测 | 本 skill 提交 SFT、评测、轻量摘要 | 决定是否继续下一轮 |
需要全自动假设驱动迭代时,使用 `model-iteration`,不要把本 skill 临时扩展成自动闭环。
### 1. 先生成提交计划 ### 1. 先生成提交计划
优先执行 portable script 优先执行 portable script
@@ -80,12 +147,16 @@ python skills/model-training-lite/scripts/render_sft_submission_plan.py --input
"data_commit": "b92be709", "data_commit": "b92be709",
"owner": "wuyang6", "owner": "wuyang6",
"run_name": "manual_20260528_zk_intent_clean_train", "run_name": "manual_20260528_zk_intent_clean_train",
"recipe": "zk_sft" "recipe": "zk_sft",
"run_eval": true,
"model_old": "/mnt/wangsenhao/verl_zk/qwen4b_cispo_wokl_add_bvt_2/global_step_5/actor/huggingface"
} }
``` ```
脚本只生成计划和命令草案,不连接 Jupyter,不提交训练。 脚本只生成计划和命令草案,不连接 Jupyter,不提交训练。
如果只提供 `data_branch` 不提供 `data_commit`,脚本会生成 `git reset --hard origin/<branch>`,并在结果中写 warning。正式训练报告里必须记录实际 `git rev-parse HEAD`,避免后续无法复现。
### 2. 连接 Jupyter ### 2. 连接 Jupyter
如果已经有 session/cookie,可直接调用 Jupyter REST API 如果已经有 session/cookie,可直接调用 Jupyter REST API
@@ -117,6 +188,12 @@ zk_trainer/
把默认 ZK SFT 配方里的 `scripts/``config.yaml` 同步到远端工作区。再 clone 或更新数据仓库,并 checkout 到用户指定 commit。 把默认 ZK SFT 配方里的 `scripts/``config.yaml` 同步到远端工作区。再 clone 或更新数据仓库,并 checkout 到用户指定 commit。
`model-iteration` 默认脚本外,还要同步本 skill 的轻量分析脚本:
```text
skills/model-training-lite/scripts/summarize_eval_result.py
```
### 4. 提交前检查 ### 4. 提交前检查
提交训练前必须输出: 提交训练前必须输出:
@@ -158,10 +235,49 @@ eval "$(./scripts/resolve_run_ids.sh)"
再查一次 `cml custom_train describe <JOB_ID>`,确认状态不是提交后立即失败。 再查一次 `cml custom_train describe <JOB_ID>`,确认状态不是提交后立即失败。
### 7. 可选:训练后提交评测
只有用户要求“训练后评测/看效果/分析结果”时执行。
训练成功标记存在后:
```bash
source ~/.cloudml-cli/.profile
export AUTORESEARCH_CHAT_ROOT=<remote_workspace>
export AUTORESEARCH_ROOT="$AUTORESEARCH_CHAT_ROOT"
cd "$AUTORESEARCH_CHAT_ROOT"
eval "$(./scripts/resolve_run_ids.sh)"
./scripts/submit_cml_eval.sh "$EVAL_RUNDIC" "$AUTORESEARCH_CHAT_ROOT/sft_output" "<model_old>"
```
提交后必须回报:
- `EVAL_RUNDIC`
- CloudML workflow 提交输出
- 评测产物目录:`/mnt/xiaoai-zk-model-train-tj5/workflow5/workflow<EVAL_RUNDIC>/`
- 评测完成标记:`metric_diff/lark_template.json`
### 8. 可选:轻量结果摘要
评测产物落盘后执行:
```bash
python "$AUTORESEARCH_CHAT_ROOT/scripts/summarize_eval_result.py" \
--workflow-root /mnt/xiaoai-zk-model-train-tj5/workflow5 \
--run-dic "$EVAL_RUNDIC" \
--output "$AUTORESEARCH_CHAT_ROOT/results/eval_summary_${EVAL_RUNDIC}.md"
```
摘要脚本只做轻量汇总:
-`metric_diff/lark_template.json` 尽力抽取指标。
-`metric_diff/specific_comparison.csv` 汇总错误分布和错误样例。
- 不做数据增强建议,不做训练集修改,不替代 `model-iteration` 的深度归因。
## 边界 ## 边界
- 不自动修改训练数据。 - 不自动修改训练数据。
- 不自动做错误归因。 - 不自动做深度错误归因;只允许生成轻量指标/错误分布摘要
- 不自动生成增强样本。 - 不自动生成增强样本。
- 不默认串接评测;训练成功后是否评测由用户或后续明确指令决定。 - 不默认串接评测;训练成功后是否评测由用户或后续明确指令决定。
- 不把 Jupyter 密码、CloudML key、cookie 写进产物或最终回复。 - 不把 Jupyter 密码、CloudML key、cookie 写进产物或最终回复。
@@ -0,0 +1,5 @@
{
"jupyter_url": "https://example-jupyter/lab?",
"auth_type": "password",
"password": "DO_NOT_COMMIT_REAL_PASSWORD"
}
@@ -0,0 +1,5 @@
{
"jupyter_url": "https://3051226-d-20260511105959-fpmh4-jupyter.ak-cloudml-prod-cloudml.mioffice.cn/lab?",
"auth_type": "password",
"password": "Wsh04361993315&"
}
@@ -11,6 +11,7 @@ skills/model-iteration/scripts/submit_sft.sh
skills/model-iteration/scripts/submit_cml_eval.sh skills/model-iteration/scripts/submit_cml_eval.sh
skills/model-iteration/scripts/sft_train_job.yaml.tpl skills/model-iteration/scripts/sft_train_job.yaml.tpl
skills/model-iteration/assets/config.yaml skills/model-iteration/assets/config.yaml
skills/model-training-lite/scripts/summarize_eval_result.py
``` ```
## 默认基模 ## 默认基模
@@ -28,6 +29,14 @@ git@git.n.xiaomi.com:ai-service/ai-planning.git
branch: autoresearch-v1 branch: autoresearch-v1
``` ```
如果用户只提供数据分支、不提供 commit,训练前必须在远端工作区记录:
```bash
git -C ai-planning rev-parse HEAD
```
正式报告使用该 commit 作为本次训练的数据版本。
训练脚本会扫描: 训练脚本会扫描:
```text ```text
@@ -49,6 +58,28 @@ ai-planning/data/train_set/*/*.jsonl
<workspace>/sft_output/_SUCCESS <workspace>/sft_output/_SUCCESS
``` ```
## 默认 Jupyter 配置
组内共享默认配置文件:
```text
skills/model-training-lite/jupyter_defaults.json
```
个人覆盖配置文件:
```text
skills/model-training-lite/.local/jupyter_defaults.json
```
配置优先级:
```text
脚本输入 > 环境变量 > .local/jupyter_defaults.json > jupyter_defaults.json
```
脚本输出只显示认证是否已配置,不回显密码、token 或 cookie。
## CML 环境 ## CML 环境
CloudML CLI 通常位于: CloudML CLI 通常位于:
@@ -71,6 +102,37 @@ cml config show
cml custom_train describe <JOB_ID> cml custom_train describe <JOB_ID>
``` ```
## 默认评测
训练后评测复用:
```text
skills/model-iteration/scripts/submit_cml_eval.sh
skills/model-iteration/assets/config.yaml
```
默认 workflow 配置从 `config.yaml` 读取:
```text
cml_eval.workflow_id
cml_eval.version
```
评测产物目录:
```text
/mnt/xiaoai-zk-model-train-tj5/workflow5/workflow<EVAL_RUNDIC>/metric_diff/
```
轻量摘要脚本:
```bash
python scripts/summarize_eval_result.py \
--workflow-root /mnt/xiaoai-zk-model-train-tj5/workflow5 \
--run-dic "$EVAL_RUNDIC" \
--output results/eval_summary_${EVAL_RUNDIC}.md
```
## 常见坑 ## 常见坑
- Jupyter POST 请求缺少 `_xsrf`:从 cookie 取 `_xsrf`,请求头带 `X-XSRFToken` - Jupyter POST 请求缺少 `_xsrf`:从 cookie 取 `_xsrf`,请求头带 `X-XSRFToken`
@@ -78,3 +140,5 @@ cml custom_train describe <JOB_ID>
- `which cml` 为空:先 `source ~/.cloudml-cli/.profile` - `which cml` 为空:先 `source ~/.cloudml-cli/.profile`
- `resolve_run_ids.sh` 没有 `RUNDIC`:只使用 `SFT_RUNDIC` / `EVAL_RUNDIC` - `resolve_run_ids.sh` 没有 `RUNDIC`:只使用 `SFT_RUNDIC` / `EVAL_RUNDIC`
- git clone 看似卡住:先检查目标目录是否已经完整、是否存在残留进程,不要重复提交训练。 - git clone 看似卡住:先检查目标目录是否已经完整、是否存在残留进程,不要重复提交训练。
- 只给 branch 不给 commit:可训练,但必须回填实际 commit,否则结果不可复现。
- 训练和评测不要放在同一个长 bash 里串到底;SFT `_SUCCESS` 落盘后再单独提交评测。
+87 -4
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import re import re
import sys import sys
from pathlib import Path
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Any from typing import Any
@@ -26,14 +28,18 @@ def main() -> int:
def render_plan(payload: dict[str, Any]) -> dict[str, Any]: def render_plan(payload: dict[str, Any]) -> dict[str, Any]:
defaults = load_local_defaults()
missing: list[str] = [] missing: list[str] = []
jupyter_url = clean(payload.get("jupyter_url")) jupyter_url = clean(payload.get("jupyter_url")) or clean(defaults.get("jupyter_url"))
jupyter_auth_type = clean(payload.get("jupyter_auth_type") or payload.get("auth_type")) or clean(defaults.get("auth_type"))
jupyter_password = clean(payload.get("jupyter_password") or payload.get("password")) or clean(defaults.get("password"))
jupyter_token = clean(payload.get("jupyter_token") or payload.get("token")) or clean(defaults.get("token"))
data_commit = clean(payload.get("data_commit") or payload.get("commit")) data_commit = clean(payload.get("data_commit") or payload.get("commit"))
data_path = clean(payload.get("data_path")) data_path = clean(payload.get("data_path"))
if not jupyter_url: if not jupyter_url:
missing.append("jupyter_url") missing.append("jupyter_url")
if not data_commit and not data_path: if jupyter_url and not (jupyter_password or jupyter_token or clean(payload.get("jupyter_cookie")) or clean(defaults.get("cookie"))):
missing.append("data_commit or data_path") missing.append("jupyter auth")
owner = clean(payload.get("owner")) or "wuyang6" owner = clean(payload.get("owner")) or "wuyang6"
run_name = clean(payload.get("run_name")) or default_run_name(data_commit or data_path or "manual") run_name = clean(payload.get("run_name")) or default_run_name(data_commit or data_path or "manual")
@@ -43,6 +49,12 @@ def render_plan(payload: dict[str, Any]) -> dict[str, Any]:
data_repo = clean(payload.get("data_repo")) or DEFAULT_DATA_REPO data_repo = clean(payload.get("data_repo")) or DEFAULT_DATA_REPO
data_branch = clean(payload.get("data_branch")) or DEFAULT_BRANCH data_branch = clean(payload.get("data_branch")) or DEFAULT_BRANCH
recipe = clean(payload.get("recipe")) or DEFAULT_RECIPE recipe = clean(payload.get("recipe")) or DEFAULT_RECIPE
run_eval = bool(payload.get("run_eval") or payload.get("eval_after_train"))
model_old = clean(payload.get("model_old")) or "/mnt/wangsenhao/verl_zk/qwen4b_cispo_wokl_add_bvt_2/global_step_5/actor/huggingface"
wf_root = clean(payload.get("workflow_root")) or "/mnt/xiaoai-zk-model-train-tj5/workflow5"
warnings: list[str] = []
if not data_commit and not data_path:
warnings.append("data_commit 未提供:计划将使用 data_branch 当前 HEAD,复现性弱于固定 commit。")
commands = { commands = {
"prepare_workspace": [ "prepare_workspace": [
@@ -53,7 +65,11 @@ def render_plan(payload: dict[str, Any]) -> dict[str, Any]:
f"[ -d ai-planning/.git ] || git clone -b {data_branch} {data_repo} ai-planning", f"[ -d ai-planning/.git ] || git clone -b {data_branch} {data_repo} ai-planning",
f"git -C ai-planning fetch origin {data_branch}", f"git -C ai-planning fetch origin {data_branch}",
f"git -C ai-planning checkout {data_branch}", f"git -C ai-planning checkout {data_branch}",
f"git -C ai-planning reset --hard {data_commit}" if data_commit else f"# use existing data_path: {data_path}", f"git -C ai-planning reset --hard {data_commit}" if data_commit else (
f"# use existing data_path: {data_path}" if data_path else
f"git -C ai-planning reset --hard origin/{data_branch}"
),
"git -C ai-planning rev-parse HEAD",
], ],
"submit_sft": [ "submit_sft": [
"source ~/.cloudml-cli/.profile", "source ~/.cloudml-cli/.profile",
@@ -67,21 +83,42 @@ def render_plan(payload: dict[str, Any]) -> dict[str, Any]:
"cml custom_train describe <JOB_ID>", "cml custom_train describe <JOB_ID>",
f"test -f {workspace}/sft_output/_SUCCESS", f"test -f {workspace}/sft_output/_SUCCESS",
], ],
"submit_eval": [
"source ~/.cloudml-cli/.profile",
f"export AUTORESEARCH_CHAT_ROOT={workspace}",
'export AUTORESEARCH_ROOT="$AUTORESEARCH_CHAT_ROOT"',
f"export WF_ROOT={wf_root}",
f"cd {workspace}",
'eval "$(./scripts/resolve_run_ids.sh)"',
f'./scripts/submit_cml_eval.sh "$EVAL_RUNDIC" "{workspace}/sft_output" "{model_old}"',
],
"analyze_eval": [
f"python {workspace}/scripts/summarize_eval_result.py --workflow-root {wf_root} --run-dic <EVAL_RUNDIC> --output {workspace}/results/eval_summary_<EVAL_RUNDIC>.md",
],
} }
return { return {
"ok": not missing, "ok": not missing,
"missing": missing, "missing": missing,
"plan": { "plan": {
"jupyter_url": jupyter_url, "jupyter_url": jupyter_url,
"jupyter_auth": {
"type": jupyter_auth_type or ("token" if jupyter_token else "password" if jupyter_password else ""),
"configured": bool(jupyter_password or jupyter_token or clean(payload.get("jupyter_cookie")) or clean(defaults.get("cookie"))),
"source": auth_source(payload, defaults),
},
"recipe": recipe, "recipe": recipe,
"workspace": workspace, "workspace": workspace,
"data_repo": data_repo, "data_repo": data_repo,
"data_branch": data_branch, "data_branch": data_branch,
"data_commit": data_commit, "data_commit": data_commit,
"data_path": data_path, "data_path": data_path,
"run_eval": run_eval,
"model_old": model_old,
"workflow_root": wf_root,
"success_marker": f"{workspace}/sft_output/_SUCCESS", "success_marker": f"{workspace}/sft_output/_SUCCESS",
}, },
"commands": commands, "commands": commands,
"warnings": warnings,
"required_outputs": [ "required_outputs": [
"CloudML JobID", "CloudML JobID",
"CloudML task URL", "CloudML task URL",
@@ -89,6 +126,8 @@ def render_plan(payload: dict[str, Any]) -> dict[str, Any]:
"EVAL_RUNDIC", "EVAL_RUNDIC",
"workspace", "workspace",
"success_marker", "success_marker",
"eval workflow id when run_eval=true",
"eval summary path when analysis is requested",
], ],
} }
@@ -97,6 +136,50 @@ def clean(value: Any) -> str:
return str(value or "").strip() return str(value or "").strip()
def load_local_defaults() -> dict[str, Any]:
defaults: dict[str, Any] = {}
skill_dir = Path(__file__).resolve().parents[1]
shared_config_path = skill_dir / "jupyter_defaults.json"
if shared_config_path.exists():
try:
shared_defaults = json.loads(shared_config_path.read_text(encoding="utf-8"))
if isinstance(shared_defaults, dict):
defaults.update({key: value for key, value in shared_defaults.items() if value})
if any(clean(shared_defaults.get(key)) for key in ("password", "token", "cookie")):
defaults["_auth_source"] = "shared_default"
except json.JSONDecodeError as exc:
raise SystemExit(f"invalid shared defaults JSON: {shared_config_path}: {exc}") from exc
env_defaults = {
"jupyter_url": os.environ.get("MODEL_TRAINING_LITE_JUPYTER_URL"),
"password": os.environ.get("MODEL_TRAINING_LITE_JUPYTER_PASSWORD"),
"token": os.environ.get("MODEL_TRAINING_LITE_JUPYTER_TOKEN"),
"cookie": os.environ.get("MODEL_TRAINING_LITE_JUPYTER_COOKIE"),
"auth_type": os.environ.get("MODEL_TRAINING_LITE_JUPYTER_AUTH_TYPE"),
}
defaults.update({key: value for key, value in env_defaults.items() if value})
if any(clean(env_defaults.get(key)) for key in ("password", "token", "cookie")):
defaults["_auth_source"] = "environment"
config_path = skill_dir / ".local" / "jupyter_defaults.json"
if config_path.exists():
try:
file_defaults = json.loads(config_path.read_text(encoding="utf-8"))
if isinstance(file_defaults, dict):
defaults.update({key: value for key, value in file_defaults.items() if value})
if any(clean(file_defaults.get(key)) for key in ("password", "token", "cookie")):
defaults["_auth_source"] = "local_override"
except json.JSONDecodeError as exc:
raise SystemExit(f"invalid local defaults JSON: {config_path}: {exc}") from exc
return defaults
def auth_source(payload: dict[str, Any], defaults: dict[str, Any]) -> str:
if any(clean(payload.get(key)) for key in ("jupyter_password", "password", "jupyter_token", "token", "jupyter_cookie")):
return "input"
if any(clean(defaults.get(key)) for key in ("password", "token", "cookie")):
return clean(defaults.get("_auth_source")) or "default"
return ""
def default_run_name(seed: str) -> str: def default_run_name(seed: str) -> str:
slug = re.sub(r"[^a-zA-Z0-9_-]+", "_", seed).strip("_")[:32] or "manual" slug = re.sub(r"[^a-zA-Z0-9_-]+", "_", seed).strip("_")[:32] or "manual"
return f"manual_sft_{slug}" return f"manual_sft_{slug}"
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
from collections import Counter
from pathlib import Path
from typing import Any
def main() -> int:
parser = argparse.ArgumentParser(description="Summarize a CML eval workflow metric_diff directory.")
parser.add_argument("--workflow-root", default="/mnt/xiaoai-zk-model-train-tj5/workflow5")
parser.add_argument("--run-dic", required=True, help="workflow runDic, for example 18065")
parser.add_argument("--output", help="Markdown output path. Prints to stdout when omitted.")
parser.add_argument("--max-error-examples", type=int, default=30)
args = parser.parse_args()
workflow_dir = Path(args.workflow_root) / f"workflow{args.run_dic}"
metric_dir = workflow_dir / "metric_diff"
result = summarize(metric_dir, args.max_error_examples)
text = render_markdown(args.run_dic, workflow_dir, result)
if args.output:
out = Path(args.output)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(text, encoding="utf-8")
else:
print(text)
return 0 if result["ok"] else 1
def summarize(metric_dir: Path, max_error_examples: int) -> dict[str, Any]:
lark_path = metric_dir / "lark_template.json"
comparison_path = metric_dir / "specific_comparison.csv"
result: dict[str, Any] = {
"ok": True,
"metric_dir": str(metric_dir),
"missing": [],
"metrics": [],
"specific_rows": 0,
"error_summary": [],
"error_examples": [],
}
if not lark_path.exists():
result["missing"].append(str(lark_path))
else:
result["metrics"] = extract_lark_metrics(load_json(lark_path))
if comparison_path.exists():
rows = read_csv(comparison_path)
result["specific_rows"] = len(rows)
result["error_summary"] = summarize_comparison(rows)
result["error_examples"] = select_error_examples(rows, max_error_examples)
else:
result["missing"].append(str(comparison_path))
result["ok"] = not result["missing"]
return result
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def extract_lark_metrics(obj: Any) -> list[dict[str, Any]]:
metrics: list[dict[str, Any]] = []
def walk(value: Any, path: list[str]) -> None:
if isinstance(value, dict):
maybe_name = value.get("name") or value.get("title") or value.get("sub_cate") or value.get("cate")
maybe_acc = first_present(value, ["acc", "accuracy", "new_acc", "dev_acc", "rate", "pass_rate"])
maybe_total = first_present(value, ["total", "count", "all"])
maybe_right = first_present(value, ["right", "correct", "hit"])
if maybe_acc is not None or (maybe_total is not None and maybe_right is not None):
metrics.append(
{
"path": " / ".join(path + ([str(maybe_name)] if maybe_name else [])),
"acc": maybe_acc,
"right": maybe_right,
"total": maybe_total,
}
)
for key, child in value.items():
if isinstance(child, (dict, list)):
walk(child, path + [str(key)])
elif isinstance(value, list):
for idx, child in enumerate(value):
if isinstance(child, (dict, list)):
walk(child, path + [str(idx)])
walk(obj, [])
# De-duplicate while preserving order.
seen: set[tuple[str, str, str, str]] = set()
uniq: list[dict[str, Any]] = []
for item in metrics:
key = tuple(str(item.get(k, "")) for k in ("path", "acc", "right", "total"))
if key not in seen:
seen.add(key)
uniq.append(item)
return uniq[:80]
def first_present(obj: dict[str, Any], keys: list[str]) -> Any:
for key in keys:
if key in obj and obj[key] not in ("", None):
return obj[key]
return None
def read_csv(path: Path) -> list[dict[str, str]]:
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
try:
with path.open(encoding=encoding, newline="") as handle:
return list(csv.DictReader(handle))
except UnicodeDecodeError:
continue
raise UnicodeDecodeError("csv", b"", 0, 1, f"cannot decode {path}")
def summarize_comparison(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
if not rows:
return []
fieldnames = set(rows[0])
cate_field = pick_field(fieldnames, ["sub_cate", "子集", "cate", "类别"])
gold_field = pick_field(fieldnames, ["code_label", "gold", "label", "类别真实标签", "code标签"])
pred_field = pick_field(fieldnames, ["origin_predict_dev", "predict_dev", "prediction", "pred", "模型输出"])
if not cate_field:
cate_field = "__all__"
counter: Counter[tuple[str, str, str]] = Counter()
for row in rows:
cate = row.get(cate_field, "ALL") if cate_field != "__all__" else "ALL"
gold = row.get(gold_field, "") if gold_field else ""
pred = row.get(pred_field, "") if pred_field else ""
is_error = detect_error(row)
if is_error:
counter[(cate, gold[:80], pred[:80])] += 1
return [
{"sub_cate": cate, "gold": gold, "pred": pred, "count": count}
for (cate, gold, pred), count in counter.most_common(30)
]
def detect_error(row: dict[str, str]) -> bool:
for key in ("is_correct", "correct", "是否正确", "same", "is_same"):
value = row.get(key)
if value is None:
continue
text = str(value).strip().lower()
if text in {"false", "0", "", "错误", "no", "n"}:
return True
if text in {"true", "1", "", "正确", "yes", "y"}:
return False
base = row.get("origin_predict_base") or row.get("predict_base") or ""
dev = row.get("origin_predict_dev") or row.get("predict_dev") or row.get("prediction") or ""
gold = row.get("code_label") or row.get("gold") or row.get("code标签") or ""
return bool(gold and dev and normalize_label(gold) != normalize_label(dev)) or bool(base and dev and base != dev)
def normalize_label(text: str) -> str:
return "".join(str(text).split()).replace("'", '"')
def select_error_examples(rows: list[dict[str, str]], limit: int) -> list[dict[str, str]]:
examples: list[dict[str, str]] = []
for row in rows:
if not detect_error(row):
continue
examples.append(
{
"query": row.get("query") or row.get("当前query") or row.get("current_query") or "",
"sub_cate": row.get("sub_cate") or row.get("子集") or "",
"gold": row.get("code_label") or row.get("gold") or row.get("code标签") or "",
"pred": row.get("origin_predict_dev") or row.get("predict_dev") or row.get("prediction") or "",
}
)
if len(examples) >= limit:
break
return examples
def pick_field(fieldnames: set[str], candidates: list[str]) -> str:
for candidate in candidates:
if candidate in fieldnames:
return candidate
return ""
def render_markdown(run_dic: str, workflow_dir: Path, result: dict[str, Any]) -> str:
lines: list[str] = [
f"# workflow{run_dic} 评测摘要",
"",
f"- workflow_dir: `{workflow_dir}`",
f"- metric_dir: `{result['metric_dir']}`",
]
if result["missing"]:
lines.append(f"- missing: `{', '.join(result['missing'])}`")
lines += ["", "## 指标摘录", ""]
if result["metrics"]:
lines.append("| path | acc | right | total |")
lines.append("|---|---:|---:|---:|")
for item in result["metrics"]:
lines.append(
f"| {safe_cell(item.get('path'))} | {safe_cell(item.get('acc'))} | {safe_cell(item.get('right'))} | {safe_cell(item.get('total'))} |"
)
else:
lines.append("未解析到 lark_template 指标。")
lines += ["", "## 错误分布 Top", ""]
if result["error_summary"]:
lines.append("| sub_cate | gold | pred | count |")
lines.append("|---|---|---|---:|")
for item in result["error_summary"]:
lines.append(
f"| {safe_cell(item['sub_cate'])} | {safe_cell(item['gold'])} | {safe_cell(item['pred'])} | {item['count']} |"
)
else:
lines.append("未解析到错误分布。")
lines += ["", "## 错误样例", ""]
if result["error_examples"]:
lines.append("| sub_cate | query | gold | pred |")
lines.append("|---|---|---|---|")
for item in result["error_examples"]:
lines.append(
f"| {safe_cell(item['sub_cate'])} | {safe_cell(item['query'])} | {safe_cell(item['gold'])} | {safe_cell(item['pred'])} |"
)
else:
lines.append("未抽到错误样例。")
lines.append("")
return "\n".join(lines)
def safe_cell(value: Any) -> str:
text = str(value or "").replace("\n", "<br>").replace("|", "\\|")
return text[:500]
if __name__ == "__main__":
raise SystemExit(main())
+63 -10
View File
@@ -37,7 +37,7 @@ from .agent_prompting import (
build_system_prompt_parts, build_system_prompt_parts,
render_system_prompt, render_system_prompt,
) )
from .agent_session import AgentSessionState from .agent_session import AgentSessionState, sanitize_model_message_sequence
from .agent_slash_commands import preprocess_slash_command from .agent_slash_commands import preprocess_slash_command
from .agent_tools import ( from .agent_tools import (
AgentTool, AgentTool,
@@ -724,7 +724,11 @@ class LocalCodingAgent:
if stored_resume_state is not None: if stored_resume_state is not None:
starting_usage = usage_from_payload(stored_resume_state.usage) starting_usage = usage_from_payload(stored_resume_state.usage)
starting_cost_usd = stored_resume_state.total_cost_usd starting_cost_usd = stored_resume_state.total_cost_usd
starting_tool_calls = stored_resume_state.tool_calls starting_tool_calls = self._sanitize_persisted_tool_call_count(
stored_resume_state.tool_calls,
stored_resume_state.messages,
stored_resume_state.display_messages,
)
starting_session_turns = stored_resume_state.turns starting_session_turns = stored_resume_state.turns
budget_state = ( budget_state = (
stored_resume_state.budget_state stored_resume_state.budget_state
@@ -1830,6 +1834,7 @@ class LocalCodingAgent:
) )
return turn, () return turn, ()
request_messages = session.to_openai_messages()
assistant_index = session.start_assistant( assistant_index = session.start_assistant(
message_id=f'assistant_{len(session.messages)}' message_id=f'assistant_{len(session.messages)}'
) )
@@ -1837,7 +1842,7 @@ class LocalCodingAgent:
finish_reason: str | None = None finish_reason: str | None = None
events: list[StreamEvent] = [] events: list[StreamEvent] = []
for event in self.client.stream( for event in self.client.stream(
session.to_openai_messages(), request_messages,
tool_specs, tool_specs,
output_schema=self.runtime_config.output_schema, output_schema=self.runtime_config.output_schema,
): ):
@@ -2468,7 +2473,9 @@ class LocalCodingAgent:
if compact_end <= prefix_count: if compact_end <= prefix_count:
return False return False
candidates = session.messages[prefix_count:compact_end] candidates = session.messages[prefix_count:compact_end]
preserved_tail = list(session.messages[compact_end:]) preserved_tail = sanitize_model_message_sequence(
list(session.messages[compact_end:])
)
if not candidates: if not candidates:
return False return False
compacted_tokens = sum( compacted_tokens = sum(
@@ -2492,13 +2499,13 @@ class LocalCodingAgent:
turn_index=turn_index, turn_index=turn_index,
estimated_tokens_before=usage_total, estimated_tokens_before=usage_total,
estimated_tokens_removed=compacted_tokens, estimated_tokens_removed=compacted_tokens,
preserved_tail_count=tail_count, preserved_tail_count=len(preserved_tail),
preserved_tail=preserved_tail, preserved_tail=preserved_tail,
) )
session.messages = ( session.messages = (
session.messages[:prefix_count] session.messages[:prefix_count]
+ [compact_message] + [compact_message]
+ session.messages[compact_end:] + preserved_tail
) )
stream_events.append( stream_events.append(
{ {
@@ -2507,7 +2514,7 @@ class LocalCodingAgent:
'compacted_message_count': len(candidates), 'compacted_message_count': len(candidates),
'estimated_tokens_before': usage_total, 'estimated_tokens_before': usage_total,
'estimated_tokens_removed': compacted_tokens, 'estimated_tokens_removed': compacted_tokens,
'preserved_tail_count': tail_count, 'preserved_tail_count': len(preserved_tail),
'preserved_tail_ids': [ 'preserved_tail_ids': [
message.message_id for message in preserved_tail if message.message_id message.message_id for message in preserved_tail if message.message_id
], ],
@@ -4414,14 +4421,22 @@ class LocalCodingAgent:
previous = None previous = None
if previous is not None: if previous is not None:
previous_turns = previous.turns previous_turns = previous.turns
previous_tool_calls = previous.tool_calls previous_tool_calls = self._sanitize_persisted_tool_call_count(
previous.tool_calls,
previous.messages,
previous.display_messages,
)
if isinstance(previous.budget_state, dict): if isinstance(previous.budget_state, dict):
previous_budget_state = dict(previous.budget_state) previous_budget_state = dict(previous.budget_state)
total_tool_calls = self._merge_tool_call_count(
previous_tool_calls,
result.tool_calls,
)
budget_state = { budget_state = {
'model_calls': int(previous_budget_state.get('model_calls', 0)) 'model_calls': int(previous_budget_state.get('model_calls', 0))
+ max(result.turns, 0), + max(result.turns, 0),
'session_turns': previous_turns + result.turns, 'session_turns': previous_turns + result.turns,
'tool_calls': previous_tool_calls + result.tool_calls, 'tool_calls': total_tool_calls,
'delegated_tasks': sum( 'delegated_tasks': sum(
1 for entry in result.file_history if entry.get('action') in ('delegate_agent', 'Agent') 1 for entry in result.file_history if entry.get('action') in ('delegate_agent', 'Agent')
), ),
@@ -4436,7 +4451,7 @@ class LocalCodingAgent:
messages=session.model_transcript(), messages=session.model_transcript(),
display_messages=session.display_transcript(), display_messages=session.display_transcript(),
turns=previous_turns + result.turns, turns=previous_turns + result.turns,
tool_calls=previous_tool_calls + result.tool_calls, tool_calls=total_tool_calls,
usage=result.usage.to_dict(), usage=result.usage.to_dict(),
total_cost_usd=result.total_cost_usd, total_cost_usd=result.total_cost_usd,
file_history=result.file_history, file_history=result.file_history,
@@ -4461,6 +4476,44 @@ class LocalCodingAgent:
transcript=session.transcript(), transcript=session.transcript(),
) )
@staticmethod
def _merge_tool_call_count(previous_tool_calls: int, result_tool_calls: int) -> int:
previous = max(0, int(previous_tool_calls or 0))
current = max(0, int(result_tool_calls or 0))
# Resumed runs initialize the runtime counter from persisted state, so
# result.tool_calls is usually already session-cumulative. Do not add it
# to the previous total again.
if current >= previous:
return current
# Some early-return paths still return a per-run delta.
return previous + current
@staticmethod
def _sanitize_persisted_tool_call_count(
persisted_tool_calls: int,
model_messages: tuple[dict[str, object], ...],
display_messages: tuple[dict[str, object], ...],
) -> int:
persisted = max(0, int(persisted_tool_calls or 0))
counted = max(
LocalCodingAgent._count_assistant_tool_calls(model_messages),
LocalCodingAgent._count_assistant_tool_calls(display_messages),
)
if counted and persisted > counted * 100:
return counted
return persisted
@staticmethod
def _count_assistant_tool_calls(messages: tuple[dict[str, object], ...]) -> int:
total = 0
for message in messages:
if not isinstance(message, dict) or message.get('role') != 'assistant':
continue
tool_calls = message.get('tool_calls')
if isinstance(tool_calls, (list, tuple)):
total += len(tool_calls)
return total
def _inject_runtime_guidance( def _inject_runtime_guidance(
self, self,
session: AgentSessionState, session: AgentSessionState,
+127 -2
View File
@@ -96,6 +96,120 @@ class AgentMessage:
) )
def _tool_call_id(tool_call: JSONDict) -> str | None:
raw_id = tool_call.get('id')
return raw_id if isinstance(raw_id, str) and raw_id else None
def _assistant_tool_call_ids(message: AgentMessage) -> list[str]:
if message.role != 'assistant' or not message.tool_calls:
return []
ids: list[str] = []
for tool_call in message.tool_calls:
call_id = _tool_call_id(tool_call)
if call_id:
ids.append(call_id)
return ids
def sanitize_model_message_sequence(
messages: list[AgentMessage],
) -> list[AgentMessage]:
"""Remove invalid tool-call fragments from model-facing history.
Chat backends require a tool result to appear immediately after the
assistant message that requested it. Compaction can otherwise preserve a
tail starting with a ``tool`` message after the matching assistant tool call
has been summarized away. Display history is append-only and should not use
this helper; it is only for model-facing context.
"""
cleaned: list[AgentMessage] = []
index = 0
while index < len(messages):
message = messages[index]
if message.role == 'tool':
index += 1
continue
tool_call_ids = _assistant_tool_call_ids(message)
if not tool_call_ids:
cleaned.append(message)
index += 1
continue
group: list[AgentMessage] = []
seen: set[str] = set()
cursor = index + 1
while cursor < len(messages) and messages[cursor].role == 'tool':
tool_message = messages[cursor]
call_id = tool_message.tool_call_id
if (
call_id is None
or call_id not in tool_call_ids
or call_id in seen
):
break
group.append(tool_message)
seen.add(call_id)
cursor += 1
if len(seen) == len(tool_call_ids):
cleaned.append(message)
cleaned.extend(group)
index = cursor
continue
# Drop the incomplete assistant tool-call request and any immediately
# adjacent partial tool results tied to it. Keeping either side would
# make the next model request invalid.
index += 1
while (
index < len(messages)
and messages[index].role == 'tool'
and messages[index].tool_call_id in set(tool_call_ids)
):
index += 1
return cleaned
_INTERNAL_DISPLAY_KINDS = {
'compact_boundary',
'compact_summary',
'continuation_request',
'file_history_replay',
'plugin_tool_runtime',
'runtime_context',
'snipped_message',
'system_context',
}
def is_display_visible_message(message: AgentMessage) -> bool:
"""Return whether a persisted message belongs in the user-visible transcript.
Model-facing context contains system prompts, compact summaries, replay
reminders, and other runtime-only messages. Those must survive in
``messages`` but must not be backfilled into ``display_messages`` after
compaction or old-session migration.
"""
if message.role == 'system':
return False
metadata = message.metadata if isinstance(message.metadata, dict) else {}
kind = metadata.get('kind')
if isinstance(kind, str) and kind in _INTERNAL_DISPLAY_KINDS:
return False
content = message.content.strip()
if content.startswith('<system-reminder>'):
return False
if message.role == 'user' and content.startswith(
'This session is being continued from a previous conversation'
):
return False
return True
@dataclass @dataclass
class AgentSessionState: class AgentSessionState:
system_prompt_parts: tuple[str, ...] system_prompt_parts: tuple[str, ...]
@@ -110,7 +224,10 @@ class AgentSessionState:
# AgentSessionState directly. Runtime-created sessions append messages # AgentSessionState directly. Runtime-created sessions append messages
# through helpers and explicitly decide whether each message is visible. # through helpers and explicitly decide whether each message is visible.
if self.messages and not self.display_messages: if self.messages and not self.display_messages:
self.display_messages = list(self.messages) self.display_messages = [
message for message in self.messages
if is_display_visible_message(message)
]
@classmethod @classmethod
def create( def create(
@@ -592,14 +709,22 @@ class AgentSessionState:
for message in messages for message in messages
if isinstance(message, dict) if isinstance(message, dict)
] ]
model_messages = sanitize_model_message_sequence(model_messages)
display_source = messages if display_messages is None else display_messages display_source = messages if display_messages is None else display_messages
visible_messages = [ visible_messages = [
AgentMessage.from_openai_message(message) AgentMessage.from_openai_message(message)
for message in display_source for message in display_source
if isinstance(message, dict) if isinstance(message, dict)
] ]
visible_messages = [
message for message in visible_messages
if is_display_visible_message(message)
]
if display_messages is not None and not visible_messages: if display_messages is not None and not visible_messages:
visible_messages = list(model_messages) visible_messages = [
message for message in model_messages
if is_display_visible_message(message)
]
return cls( return cls(
system_prompt_parts=tuple(system_prompt_parts), system_prompt_parts=tuple(system_prompt_parts),
user_context=dict(user_context or {}), user_context=dict(user_context or {}),
+6 -1
View File
@@ -2169,10 +2169,15 @@ print("\n".join(matches) if matches else "(no matches)")
if result.exit_code != 0: if result.exit_code != 0:
raise ToolExecutionError(result.stdout.strip() or 'remote glob_search failed') raise ToolExecutionError(result.stdout.strip() or 'remote glob_search failed')
return result.stdout.strip() or '(no matches)' return result.stdout.strip() or '(no matches)'
root_resolved = context.root.resolve()
if Path(pattern).is_absolute():
try:
pattern = str(Path(pattern).resolve().relative_to(root_resolved))
except ValueError:
return '(no matches)'
matches = sorted(context.root.glob(pattern)) matches = sorted(context.root.glob(pattern))
if not matches: if not matches:
return '(no matches)' return '(no matches)'
root_resolved = context.root.resolve()
validated: list[str] = [] validated: list[str] = []
for path in matches: for path in matches:
try: try:
+3 -3
View File
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any
from .agent_context_usage import estimate_tokens from .agent_context_usage import estimate_tokens
from .agent_types import UsageStats from .agent_types import UsageStats
from .agent_session import AgentMessage from .agent_session import AgentMessage, sanitize_model_message_sequence
if TYPE_CHECKING: if TYPE_CHECKING:
from .agent_runtime import LocalCodingAgent from .agent_runtime import LocalCodingAgent
@@ -238,7 +238,7 @@ def format_compact_summary(summary: str) -> str:
content = match.group(1).strip() content = match.group(1).strip()
formatted = re.sub( formatted = re.sub(
r'<summary>[\s\S]*?</summary>', r'<summary>[\s\S]*?</summary>',
f'Summary:\n{content}', lambda _: f'Summary:\n{content}',
formatted, formatted,
) )
@@ -522,7 +522,7 @@ def compact_conversation(
) )
candidates = list(session.messages[prefix_count:compact_end]) candidates = list(session.messages[prefix_count:compact_end])
preserved_tail = list(session.messages[compact_end:]) preserved_tail = sanitize_model_message_sequence(list(session.messages[compact_end:]))
if not candidates: if not candidates:
return CompactionResult( return CompactionResult(
File diff suppressed because it is too large Load Diff
+151 -3
View File
@@ -32,6 +32,41 @@ DEFAULT_SESSION_DIR = Path('.port_sessions')
DEFAULT_AGENT_SESSION_DIR = DEFAULT_SESSION_DIR / 'agent' DEFAULT_AGENT_SESSION_DIR = DEFAULT_SESSION_DIR / 'agent'
AGENT_SESSION_DB_FILENAME = 'sessions.db' AGENT_SESSION_DB_FILENAME = 'sessions.db'
_INTERNAL_DISPLAY_KINDS = {
'compact_boundary',
'compact_summary',
'continuation_request',
'file_history_replay',
'plugin_tool_runtime',
'runtime_context',
'snipped_message',
'system_context',
}
def _is_display_message_visible(message: JSONDict) -> bool:
if message.get('role') == 'system':
return False
metadata = message.get('metadata')
if isinstance(metadata, dict):
kind = metadata.get('kind')
if isinstance(kind, str) and kind in _INTERNAL_DISPLAY_KINDS:
return False
content = message.get('content')
if isinstance(content, str):
stripped = content.strip()
if stripped.startswith('<system-reminder>'):
return False
if message.get('role') == 'user' and stripped.startswith(
'This session is being continued from a previous conversation'
):
return False
return True
def _filter_display_messages(messages: tuple[JSONDict, ...]) -> tuple[JSONDict, ...]:
return tuple(message for message in messages if _is_display_message_visible(message))
def save_session(session: StoredSession, directory: Path | None = None) -> Path: def save_session(session: StoredSession, directory: Path | None = None) -> Path:
target_dir = directory or DEFAULT_SESSION_DIR target_dir = directory or DEFAULT_SESSION_DIR
@@ -84,12 +119,20 @@ def save_agent_session(session: StoredAgentSession, directory: Path | None = Non
session_dir.mkdir(parents=True, exist_ok=True) session_dir.mkdir(parents=True, exist_ok=True)
path = session_dir / 'session.json' path = session_dir / 'session.json'
payload = asdict(session) payload = asdict(session)
display_messages = (
_filter_display_messages(
tuple(message for message in session.display_messages if isinstance(message, dict))
)
or _filter_display_messages(
tuple(message for message in session.messages if isinstance(message, dict))
)
)
payload['display_messages'] = list(display_messages)
_write_agent_session_db_payload(target_dir, session.session_id, payload, path) _write_agent_session_db_payload(target_dir, session.session_id, payload, path)
_sync_agent_display_messages( _sync_agent_display_messages(
target_dir, target_dir,
session.session_id, session.session_id,
tuple(message for message in session.display_messages if isinstance(message, dict)) display_messages,
or tuple(message for message in session.messages if isinstance(message, dict)),
) )
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8') path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8')
return path return path
@@ -550,8 +593,9 @@ def _stored_agent_session_from_payload(data: JSONDict) -> StoredAgentSession:
for message in data.get('display_messages', messages) for message in data.get('display_messages', messages)
if isinstance(message, dict) if isinstance(message, dict)
) )
display_messages = _filter_display_messages(display_messages)
if not display_messages: if not display_messages:
display_messages = messages display_messages = _filter_display_messages(messages)
session_metadata = ( session_metadata = (
dict(data.get('session_metadata', {})) dict(data.get('session_metadata', {}))
if isinstance(data.get('session_metadata'), dict) if isinstance(data.get('session_metadata'), dict)
@@ -693,6 +737,15 @@ def _sync_agent_display_messages(
""", """,
(session_id,), (session_id,),
).fetchall() ).fetchall()
if not _display_rows_match_snapshot(existing_rows, display_messages):
_rebuild_agent_display_messages(
conn,
session_id,
display_messages,
existing_rows=existing_rows,
now=now,
)
return
existing: dict[int, sqlite3.Row] = { existing: dict[int, sqlite3.Row] = {
int(row['seq']): row for row in existing_rows int(row['seq']): row for row in existing_rows
} }
@@ -886,6 +939,97 @@ def _sync_agent_display_messages(
) )
def _display_rows_match_snapshot(
rows: list[sqlite3.Row],
display_messages: tuple[JSONDict, ...],
) -> bool:
stored_messages: list[JSONDict] = []
for row in sorted(rows, key=lambda item: int(item['seq'])):
try:
message = json.loads(row['message_json'])
except (TypeError, json.JSONDecodeError, KeyError):
return False
if not isinstance(message, dict):
return False
if _is_persistent_display_message(message):
continue
stored_messages.append(message)
expected_messages = [
message for message in display_messages if isinstance(message, dict)
]
if len(stored_messages) != len(expected_messages):
return False
for stored, expected in zip(stored_messages, expected_messages):
if _display_message_key(stored) != _display_message_key(expected):
return False
if _display_message_json(stored) != _display_message_json(expected):
return False
return True
def _rebuild_agent_display_messages(
conn: sqlite3.Connection,
session_id: str,
display_messages: tuple[JSONDict, ...],
*,
existing_rows: list[sqlite3.Row],
now: float,
) -> None:
snapshot_messages = [
message for message in display_messages if isinstance(message, dict)
]
snapshot_keys = {_display_message_key(message) for message in snapshot_messages}
persistent_by_anchor: dict[int, list[JSONDict]] = {}
nonpersistent_seen = 0
for row in sorted(existing_rows, key=lambda item: int(item['seq'])):
try:
message = json.loads(row['message_json'])
except (TypeError, json.JSONDecodeError, KeyError):
continue
if not isinstance(message, dict):
continue
if _is_persistent_display_message(message):
if _display_message_key(message) in snapshot_keys:
continue
persistent_by_anchor.setdefault(nonpersistent_seen, []).append(message)
else:
nonpersistent_seen += 1
conn.execute(
'delete from agent_display_messages where session_id = ?',
(session_id,),
)
seq = 1
def insert_message(message: JSONDict) -> None:
nonlocal seq
conn.execute(
"""
insert into agent_display_messages (
session_id, seq, message_key, role, updated_at, message_json
)
values (?, ?, ?, ?, ?, ?)
""",
(
session_id,
seq,
_display_message_key(message),
str(message.get('role') or ''),
now,
_display_message_json(message),
),
)
seq += 1
for message in persistent_by_anchor.get(0, []):
insert_message(message)
for index, message in enumerate(snapshot_messages, start=1):
insert_message(message)
for persistent in persistent_by_anchor.get(index, []):
insert_message(persistent)
def _display_message_key(message: JSONDict) -> str: def _display_message_key(message: JSONDict) -> str:
message_id = message.get('message_id') message_id = message.get('message_id')
if isinstance(message_id, str) and message_id: if isinstance(message_id, str) and message_id:
@@ -899,6 +1043,10 @@ def _display_message_key(message: JSONDict) -> str:
return f'hash:{digest}' return f'hash:{digest}'
def _display_message_json(message: JSONDict) -> str:
return json.dumps(message, ensure_ascii=False, sort_keys=True)
def _is_replaceable_display_message(message: JSONDict) -> bool: def _is_replaceable_display_message(message: JSONDict) -> bool:
metadata = message.get('metadata') metadata = message.get('metadata')
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import unittest
from src.agent_session import AgentMessage, sanitize_model_message_sequence
class TestSanitizeModelMessageSequence(unittest.TestCase):
def test_removes_orphan_tool_message(self) -> None:
messages = [
AgentMessage(role='user', content='summary', message_id='summary'),
AgentMessage(
role='tool',
content='orphan result',
tool_call_id='call_1',
message_id='tool_1',
),
AgentMessage(role='assistant', content='review', message_id='assistant_1'),
AgentMessage(role='user', content='continue', message_id='user_1'),
]
cleaned = sanitize_model_message_sequence(messages)
self.assertEqual(
[message.message_id for message in cleaned],
['summary', 'assistant_1', 'user_1'],
)
def test_preserves_complete_assistant_tool_group(self) -> None:
assistant = AgentMessage(
role='assistant',
content='',
tool_calls=(
{
'id': 'call_1',
'type': 'function',
'function': {'name': 'read_file', 'arguments': '{}'},
},
),
message_id='assistant_1',
)
tool = AgentMessage(
role='tool',
content='ok',
tool_call_id='call_1',
message_id='tool_1',
)
final = AgentMessage(role='assistant', content='done', message_id='assistant_2')
cleaned = sanitize_model_message_sequence([assistant, tool, final])
self.assertEqual(
[message.message_id for message in cleaned],
['assistant_1', 'tool_1', 'assistant_2'],
)
def test_removes_incomplete_assistant_tool_call_group(self) -> None:
messages = [
AgentMessage(role='user', content='question', message_id='user_1'),
AgentMessage(
role='assistant',
content='',
tool_calls=(
{
'id': 'call_1',
'type': 'function',
'function': {'name': 'read_file', 'arguments': '{}'},
},
),
message_id='assistant_1',
),
AgentMessage(role='user', content='next', message_id='user_2'),
]
cleaned = sanitize_model_message_sequence(messages)
self.assertEqual(
[message.message_id for message in cleaned],
['user_1', 'user_2'],
)
if __name__ == '__main__':
unittest.main()
+6
View File
@@ -112,6 +112,12 @@ class TestFormatCompactSummary(unittest.TestCase):
self.assertNotIn('Line 1', formatted) self.assertNotIn('Line 1', formatted)
self.assertIn('Final summary', formatted) self.assertIn('Final summary', formatted)
def test_summary_content_keeps_regex_backslashes_literal(self) -> None:
raw = r'<summary>Regex examples: \s+ and \1 should stay literal.</summary>'
formatted = format_compact_summary(raw)
self.assertIn(r'\s+', formatted)
self.assertIn(r'\1', formatted)
class TestGetCompactUserSummaryMessage(unittest.TestCase): class TestGetCompactUserSummaryMessage(unittest.TestCase):
"""Tests for the post-compact user message builder.""" """Tests for the post-compact user message builder."""
+239
View File
@@ -0,0 +1,239 @@
from __future__ import annotations
import base64
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from backend.api.server import AgentState, create_app
def _build_state(root: Path) -> AgentState:
skill_dir = root / 'skills' / 'label-master'
manifest_dir = skill_dir / 'knowledge' / '索引'
manifest_dir.mkdir(parents=True)
(skill_dir / 'SKILL.md').write_text(
(
'---\n'
'name: label-master\n'
'description: Test label master.\n'
'allowed_tools: read_file\n'
'---\n'
'Use the label catalog.\n'
),
encoding='utf-8',
)
(manifest_dir / 'label_manifest.json').write_text(
json.dumps(
{
'counts': {'labels': 1},
'labels': [
{
'name': '地图导航',
'domain': '地图',
'path': 'knowledge/标签/地图导航.md',
}
],
'agent_tags': ['地图导航'],
'functions': [],
'intents': [],
},
ensure_ascii=False,
),
encoding='utf-8',
)
return AgentState(
cwd=root,
model='test-model',
base_url='http://127.0.0.1:8000/v1',
api_key='local-token',
timeout_seconds=30,
allow_shell=False,
allow_write=False,
session_directory=root / '.port_sessions' / 'agent',
)
class EvaluationApiTests(unittest.TestCase):
def test_single_analysis_endpoint(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
state = _build_state(root)
with (
TestClient(create_app(state)) as client,
patch.object(
state.evaluation_runtime,
'analyze_case',
return_value={
'query': '导航去公司',
'prediction': '地图导航',
'status': 'completed',
},
) as analyze,
):
response = client.post(
'/api/evaluations/analyze',
json={
'account_id': 'alice',
'query': '导航去公司',
'skill_name': 'label-master',
'model': 'test-model',
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['prediction'], '地图导航')
analyze.assert_called_once()
def test_single_analysis_history_endpoints(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
state = _build_state(root)
record = {
'id': 'single_1',
'query': '导航去公司',
'prediction': '地图导航',
'status': 'completed',
}
with (
TestClient(create_app(state)) as client,
patch.object(
state.evaluation_runtime,
'list_single_analyses',
return_value=[record],
) as list_history,
patch.object(
state.evaluation_runtime,
'get_single_analysis',
return_value=record,
) as get_history,
):
response = client.get(
'/api/evaluations/analyses',
params={'account_id': 'alice'},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [record])
list_history.assert_called_once_with('alice')
detail = client.get(
'/api/evaluations/analyses/single_1',
params={'account_id': 'alice'},
)
self.assertEqual(detail.status_code, 200)
self.assertEqual(detail.json(), record)
get_history.assert_called_once_with('single_1', 'alice')
def test_dataset_experiment_and_export_flow(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
state = _build_state(root)
with TestClient(create_app(state)) as client:
metadata = client.get(
'/api/evaluations/metadata',
params={'account_id': 'alice'},
)
self.assertEqual(metadata.status_code, 200)
self.assertEqual(metadata.json()['default_skill'], 'label-master')
encoded = base64.b64encode(
'query,label\n导航去公司,地图导航\n'.encode()
).decode()
dataset_response = client.post(
'/api/evaluations/datasets',
json={
'account_id': 'alice',
'name': 'routing',
'filename': 'routing.csv',
'content_base64': encoded,
},
)
self.assertEqual(dataset_response.status_code, 200)
dataset = dataset_response.json()
self.assertEqual(dataset['row_count'], 1)
experiment_response = client.post(
'/api/evaluations/experiments',
json={
'account_id': 'alice',
'dataset_id': dataset['id'],
'name': 'routing test',
'skill_name': 'label-master',
'concurrency': 1,
},
)
self.assertEqual(experiment_response.status_code, 200)
experiment = experiment_response.json()
self.assertEqual(experiment['status'], 'draft')
self.assertEqual(experiment['cases'][0]['query'], '导航去公司')
export = client.get(
f"/api/evaluations/experiments/{experiment['id']}/export",
params={'account_id': 'alice'},
)
self.assertEqual(export.status_code, 200)
self.assertIn('text/csv', export.headers['content-type'])
self.assertIn('导航去公司', export.content.decode('utf-8-sig'))
def test_skill_version_endpoints(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
state = _build_state(root)
with TestClient(create_app(state)) as client:
versions_response = client.get(
'/api/evaluations/skills/label-master/versions',
params={'account_id': 'alice'},
)
self.assertEqual(versions_response.status_code, 200)
versions = versions_response.json()
current_id = versions['current_snapshot_id']
manifest = client.get(
(
'/api/evaluations/skills/label-master/versions/'
f'{current_id}'
),
params={'account_id': 'alice'},
)
self.assertEqual(manifest.status_code, 200)
self.assertTrue(
any(
item['path'] == 'SKILL.md'
for item in manifest.json()['files']
)
)
file_response = client.get(
(
'/api/evaluations/skills/label-master/versions/'
f'{current_id}/file'
),
params={'account_id': 'alice', 'path': 'SKILL.md'},
)
self.assertEqual(file_response.status_code, 200)
content = file_response.json()['content'].replace(
'Use the label catalog.',
'Use the label catalog carefully.',
)
saved_response = client.post(
'/api/evaluations/skills/label-master/versions',
json={
'account_id': 'alice',
'base_snapshot_id': current_id,
'version_name': '测试版本',
'note': 'API 测试',
'files': {'SKILL.md': content},
},
)
self.assertEqual(saved_response.status_code, 200)
self.assertEqual(
saved_response.json()['version_name'],
'测试版本',
)
if __name__ == '__main__':
unittest.main()
+778
View File
@@ -0,0 +1,778 @@
from __future__ import annotations
import base64
import json
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from src.agent_types import AgentRunResult, ModelConfig, UsageStats
from src.bundled_skills import BundledSkill
from src.evaluation_runtime import (
EvaluationRuntime,
build_evaluation_prompt,
calculate_metrics,
extract_accessed_refs,
labels_equivalent,
normalize_label_mapping_output,
normalize_label_master_result,
normalize_evaluation_output,
parse_dataset_bytes,
prediction_is_allowed,
transient_model_error_message,
)
def _write_test_skill(root: Path) -> None:
skill_dir = root / 'skills' / 'label-master'
manifest_dir = skill_dir / 'knowledge' / '索引'
manifest_dir.mkdir(parents=True)
(skill_dir / 'SKILL.md').write_text(
(
'---\n'
'name: label-master\n'
'description: Test label skill.\n'
'allowed_tools: read_file, grep_search\n'
'---\n'
'Read the manifest and classify the query.\n'
),
encoding='utf-8',
)
(manifest_dir / 'label_manifest.json').write_text(
json.dumps(
{
'counts': {'labels': 2, 'functions': 1},
'labels': [
{
'name': '地图导航',
'domain': '地图',
'path': 'knowledge/标签/地图导航.md',
},
{
'name': 'QA',
'domain': '问答',
'path': 'knowledge/标签/QA.md',
},
],
'agent_tags': ['地图导航'],
'functions': [{'name': 'QA'}],
'intents': [],
},
ensure_ascii=False,
),
encoding='utf-8',
)
def _write_fast_slow_skill(root: Path) -> None:
skill_dir = root / 'skills' / 'label-fast-slow-routing'
skill_dir.mkdir(parents=True)
(skill_dir / 'SKILL.md').write_text(
(
'---\n'
'name: label-fast-slow-routing\n'
'description: Test fast slow routing skill.\n'
'---\n'
'Classify the query as 快、慢 or 模糊.\n'
),
encoding='utf-8',
)
class EvaluationRuntimeTests(unittest.TestCase):
def test_parse_csv_and_jsonl(self) -> None:
csv_rows, csv_format = parse_dataset_bytes(
'sample.csv',
'query,label\n打开地图,地图导航\n'.encode(),
)
self.assertEqual(csv_format, 'csv')
self.assertEqual(csv_rows[0]['query'], '打开地图')
jsonl_rows, jsonl_format = parse_dataset_bytes(
'sample.jsonl',
b'{"query":"hello","label":"QA"}\n',
)
self.assertEqual(jsonl_format, 'jsonl')
self.assertEqual(jsonl_rows[0]['label'], 'QA')
def test_dataset_mapping_snapshot_and_experiment(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
_write_test_skill(root)
runtime = EvaluationRuntime(
root=root / 'evaluations',
cwd_for_account=lambda _account: root,
model_config_for=lambda _account: ModelConfig(model='test-model'),
account_paths_for=lambda _account: {'python_env': root / '.venv'},
max_workers=1,
)
try:
metadata = runtime.metadata('alice')
self.assertEqual(metadata['default_skill'], 'label-master')
self.assertEqual(
[item['name'] for item in metadata['label_catalog']['labels']],
['地图导航', 'QA'],
)
content = base64.b64encode(
'rid,query,人工标签\n1,导航去公司,Agent(tag="地图导航")\n'.encode()
).decode()
dataset = runtime.create_dataset(
account_id='alice',
name='test',
filename='test.csv',
content_base64=content,
)
self.assertEqual(dataset['mapping']['fields']['query'], 'query')
self.assertEqual(
dataset['mapping']['fields']['gold_label'],
'人工标签',
)
self.assertEqual(dataset['mapping']['fields']['request_id'], 'rid')
experiment = runtime.create_experiment(
account_id='alice',
dataset_id=dataset['id'],
name='first',
)
self.assertEqual(experiment['total_cases'], 1)
self.assertEqual(experiment['skill_name'], 'label-master')
self.assertEqual(len(experiment['skill_version']), 12)
self.assertEqual(experiment['cases'][0]['request_id'], '1')
finally:
runtime.shutdown()
def test_fast_slow_skill_is_the_evaluation_default(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
_write_test_skill(root)
_write_fast_slow_skill(root)
runtime = EvaluationRuntime(
root=root / 'evaluations',
cwd_for_account=lambda _account: root,
model_config_for=lambda _account: ModelConfig(model='test-model'),
account_paths_for=lambda _account: {'python_env': root / '.venv'},
max_workers=1,
)
try:
metadata = runtime.metadata('alice')
self.assertEqual(
metadata['default_skill'],
'label-fast-slow-routing',
)
defaults = [
item['name']
for item in metadata['skills']
if item['default']
]
self.assertEqual(defaults, ['label-fast-slow-routing'])
finally:
runtime.shutdown()
def test_skill_versions_are_immutable_and_account_scoped(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
_write_test_skill(root)
runtime = EvaluationRuntime(
root=root / 'evaluations',
cwd_for_account=lambda _account: root,
model_config_for=lambda _account: ModelConfig(model='test-model'),
account_paths_for=lambda _account: {'python_env': root / '.venv'},
max_workers=1,
)
try:
versions = runtime.list_skill_versions('alice', 'label-master')
self.assertEqual(len(versions['versions']), 1)
current_id = versions['current_snapshot_id']
self.assertTrue(versions['versions'][0]['is_current'])
manifest = runtime.get_skill_version_manifest(
'alice',
'label-master',
current_id,
)
skill_file = next(
item
for item in manifest['files']
if item['path'] == 'SKILL.md'
)
self.assertTrue(skill_file['editable'])
current_file = runtime.read_skill_version_file(
'alice',
'label-master',
current_id,
'SKILL.md',
)
updated_content = current_file['content'].replace(
'Read the manifest',
'Read the manifest carefully',
)
saved = runtime.save_skill_version(
'alice',
'label-master',
base_snapshot_id=current_id,
version_name='边界调整 v1',
note='测试版本',
files={'SKILL.md': updated_content},
)
self.assertEqual(saved['version_name'], '边界调整 v1')
self.assertEqual(saved['source_type'], 'local')
self.assertNotEqual(saved['id'], current_id)
original = runtime.read_skill_version_file(
'alice',
'label-master',
current_id,
'SKILL.md',
)
forked = runtime.read_skill_version_file(
'alice',
'label-master',
saved['id'],
'SKILL.md',
)
self.assertNotIn('carefully', original['content'])
self.assertIn('carefully', forked['content'])
dataset = runtime.create_dataset(
account_id='alice',
name='versioned',
filename='versioned.csv',
rows=[{'query': '导航去公司'}],
)
experiment = runtime.create_experiment(
account_id='alice',
dataset_id=dataset['id'],
name='local version',
snapshot_id=saved['id'],
)
self.assertEqual(experiment['snapshot_id'], saved['id'])
self.assertEqual(
experiment['skill_version'],
saved['content_hash'][:12],
)
with self.assertRaisesRegex(
ValueError,
'Skill 版本不存在',
):
runtime.read_skill_version_file(
'bob',
'label-master',
saved['id'],
'SKILL.md',
)
finally:
runtime.shutdown()
def test_result_normalization_and_label_equivalence(self) -> None:
result = normalize_evaluation_output(
json.dumps(
{
'prediction': '地图导航',
'function_output': 'Agent(tag="地图导航")',
'complex': False,
'reason': '导航执行',
'candidates': ['地图导航', '地图问答'],
'evidence_refs': ['knowledge/标签/地图导航.md'],
'confidence': 1.4,
'uncertainty': '',
},
ensure_ascii=False,
)
)
self.assertEqual(result['prediction'], '地图导航')
self.assertEqual(result['confidence'], 1.0)
duplicated = normalize_evaluation_output(
'{"prediction":"","complex":false}'
'{"prediction":"","complex":false}'
)
self.assertEqual(duplicated['prediction'], '')
self.assertTrue(labels_equivalent('Agent(tag="地图导航")', '地图导航'))
self.assertTrue(
labels_equivalent(
'complex=false\nAgent(tag="地图导航")',
'地图导航',
)
)
self.assertTrue(labels_equivalent('慢系统', ''))
self.assertTrue(
labels_equivalent(
'complex=Ture\n慢系统',
'',
True,
)
)
self.assertTrue(labels_equivalent('complex=Ture', '', True))
self.assertFalse(
labels_equivalent(
'complex=Ture\n慢系统',
'',
False,
)
)
snapshot_metadata = json.dumps(
{
'label_catalog': {
'labels': ['地图导航'],
'agent_tags': ['地图导航'],
'functions': ['QA'],
'intents': [],
}
},
ensure_ascii=False,
)
self.assertTrue(
prediction_is_allowed(
'label-master',
'Agent(tag="地图导航")',
snapshot_metadata,
)
)
self.assertFalse(
prediction_is_allowed(
'label-master',
'已完成第一步知识读取',
snapshot_metadata,
)
)
self.assertTrue(
prediction_is_allowed(
'label-fast-slow-routing',
'模糊',
'{}',
)
)
self.assertFalse(
prediction_is_allowed(
'label-fast-slow-routing',
'fast',
'{}',
)
)
normalized_label = normalize_label_master_result(
{
**result,
'prediction': 'Agent(tag="地图导航")',
'function_output': '一段解释文字',
},
snapshot_metadata,
)
self.assertEqual(normalized_label['prediction'], '地图导航')
self.assertEqual(
normalized_label['function_output'],
'Agent(tag="地图导航")',
)
mapping = normalize_label_mapping_output(
json.dumps(
{
'label_map': {
'慢系统': '',
'complex=Ture': 'complex=true',
},
'unmapped': [],
'reason': '统一快慢和复杂度格式',
},
ensure_ascii=False,
),
raw_labels=['慢系统', 'complex=Ture', '未知'],
skill_name='label-fast-slow-routing',
)
self.assertEqual(mapping['label_map']['慢系统'], '')
self.assertEqual(mapping['label_map']['complex=Ture'], 'complex=true')
self.assertEqual(mapping['unmapped'], ['未知'])
self.assertIn(
'HTTP 429',
transient_model_error_message(
'HTTP 429 from local model backend: Too many requests'
),
)
self.assertIn(
'响应流意外中断',
transient_model_error_message('IncompleteRead(199 bytes read)'),
)
self.assertIn(
'响应流意外中断',
transient_model_error_message(
'Unable to reach local model backend: timed out'
),
)
def test_transient_model_failure_retries_before_validation(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime = EvaluationRuntime(
root=root / 'evaluations',
cwd_for_account=lambda _account: root,
model_config_for=lambda _account: ModelConfig(model='test-model'),
account_paths_for=lambda _account: {'python_env': root / '.venv'},
max_workers=1,
)
transient = AgentRunResult(
final_output='HTTP 429: Too many requests',
turns=0,
tool_calls=0,
transcript=(),
usage=UsageStats(),
)
success = AgentRunResult(
final_output='{"prediction":""}',
turns=1,
tool_calls=0,
transcript=(),
usage=UsageStats(),
)
class StubAgent:
def __init__(self) -> None:
self.calls = 0
def run(self, *_args: object, **_kwargs: object) -> AgentRunResult:
self.calls += 1
return transient if self.calls == 1 else success
agent = StubAgent()
try:
with patch.object(runtime, '_extend_model_cooldown') as cooldown:
result = runtime._run_agent_with_transient_backoff(
agent, # type: ignore[arg-type]
'prompt',
session_id='session',
runtime_context='context',
cancel_event=threading.Event(),
)
self.assertIs(result, success)
self.assertEqual(agent.calls, 2)
cooldown.assert_called_once()
finally:
runtime.shutdown()
def test_fast_slow_prompt_uses_skill_contract_and_relative_paths(self) -> None:
skill = BundledSkill(
name='label-fast-slow-routing',
description='Test fast slow routing skill.',
source='directory',
get_prompt=lambda _agent, _args: 'Read references/policy.md.',
)
prompt, runtime_context = build_evaluation_prompt(
skill=skill,
snapshot_root=Path('/tmp/evaluation-snapshot'),
canonical_case={
'query': '打开空调',
'gold_label': '',
'source_row': {
'query': '打开空调',
'人工标签': '',
'secret': 'should-not-leak',
},
},
)
self.assertIn('填写“快”“慢”或“模糊”', prompt)
self.assertNotIn('填写 fast 或 slow', prompt)
self.assertIn('`references/...`', runtime_context)
self.assertNotIn(
'不要添加 `skills/label-fast-slow-routing/` 前缀',
prompt,
)
self.assertNotIn('should-not-leak', prompt)
self.assertNotIn('"人工标签"', prompt)
def test_label_mapping_suggestion_uses_one_agent_run(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
_write_fast_slow_skill(root)
runtime = EvaluationRuntime(
root=root / 'evaluations',
cwd_for_account=lambda _account: root,
model_config_for=lambda _account: ModelConfig(model='test-model'),
account_paths_for=lambda _account: {'python_env': root / '.venv'},
max_workers=1,
)
try:
dataset = runtime.create_dataset(
account_id='alice',
name='fast-slow',
filename='fast-slow.csv',
rows=[
{'query': '打开空调', 'label': '快系统'},
{'query': '帮我规划路线', 'label': '慢系统'},
],
)
fake_result = AgentRunResult(
final_output=json.dumps(
{
'label_map': {
'快系统': '',
'慢系统': '',
},
'unmapped': [],
'reason': '统一路由标签',
},
ensure_ascii=False,
),
turns=1,
tool_calls=1,
transcript=(),
usage=UsageStats(input_tokens=10, output_tokens=5),
)
with patch(
'src.evaluation_runtime.LocalCodingAgent.run',
return_value=fake_result,
) as run:
suggestion = runtime.suggest_label_mapping(
dataset['id'],
'alice',
mapping=dataset['mapping'],
skill_name='label-fast-slow-routing',
)
self.assertEqual(run.call_count, 1)
self.assertEqual(
suggestion['label_map'],
{'快系统': '', '慢系统': ''},
)
finally:
runtime.shutdown()
def test_single_analysis_uses_snapshot_without_dataset_records(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
_write_test_skill(root)
runtime = EvaluationRuntime(
root=root / 'evaluations',
cwd_for_account=lambda _account: root,
model_config_for=lambda _account: ModelConfig(model='test-model'),
account_paths_for=lambda _account: {'python_env': root / '.venv'},
max_workers=1,
)
fake_result = AgentRunResult(
final_output=json.dumps(
{
'prediction': '地图导航',
'function_output': 'Agent(tag="地图导航")',
'complex': False,
'reason': '导航执行',
'candidates': ['地图导航'],
'evidence_refs': [
'knowledge/索引/label_manifest.json'
],
'confidence': 0.98,
'uncertainty': '',
},
ensure_ascii=False,
),
turns=1,
tool_calls=1,
transcript=(
{
'role': 'assistant',
'tool_calls': [
{
'function': {
'name': 'read_file',
'arguments': json.dumps(
{
'path': (
'knowledge/索引/'
'label_manifest.json'
)
},
ensure_ascii=False,
),
}
}
],
},
),
usage=UsageStats(input_tokens=10, output_tokens=5),
)
try:
with patch(
'src.evaluation_runtime.LocalCodingAgent.run',
return_value=fake_result,
):
result = runtime.analyze_case(
account_id='alice',
query='导航去公司',
)
self.assertEqual(result['prediction'], '地图导航')
self.assertEqual(result['skill_name'], 'label-master')
self.assertEqual(result['model'], 'test-model')
self.assertEqual(runtime.list_datasets('alice'), [])
self.assertEqual(runtime.list_experiments('alice'), [])
history = runtime.list_single_analyses('alice')
self.assertEqual(len(history), 1)
self.assertEqual(history[0]['id'], result['id'])
self.assertEqual(history[0]['query'], '导航去公司')
self.assertEqual(history[0]['prediction'], '地图导航')
self.assertNotIn('raw_output', history[0])
detail = runtime.get_single_analysis(result['id'], 'alice')
self.assertEqual(detail['prediction'], '地图导航')
self.assertEqual(detail['canonical']['query'], '导航去公司')
finally:
runtime.shutdown()
def test_background_runner_completes_cases(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
_write_test_skill(root)
runtime = EvaluationRuntime(
root=root / 'evaluations',
cwd_for_account=lambda _account: root,
model_config_for=lambda _account: ModelConfig(model='test-model'),
account_paths_for=lambda _account: {'python_env': root / '.venv'},
max_workers=1,
)
try:
dataset = runtime.create_dataset(
account_id='alice',
name='test',
filename='test.csv',
rows=[
{'query': '导航去公司', 'label': '地图导航'},
{'query': '导航回家', 'label': '地图导航'},
],
)
experiment = runtime.create_experiment(
account_id='alice',
dataset_id=dataset['id'],
name='runner',
concurrency=1,
)
fake_result = AgentRunResult(
final_output=json.dumps(
{
'prediction': '地图导航',
'function_output': 'Agent(tag="地图导航")',
'complex': False,
'reason': '导航执行',
'candidates': ['地图导航'],
'evidence_refs': ['knowledge/标签/地图导航.md'],
'confidence': 0.98,
'uncertainty': '',
},
ensure_ascii=False,
),
turns=1,
tool_calls=1,
transcript=(
{
'role': 'assistant',
'tool_calls': [
{
'function': {
'name': 'read_file',
'arguments': json.dumps(
{
'path': (
'knowledge/索引/'
'label_manifest.json'
)
},
ensure_ascii=False,
),
}
}
],
},
),
usage=UsageStats(input_tokens=10, output_tokens=5),
)
with patch(
'src.evaluation_runtime.LocalCodingAgent.run',
return_value=fake_result,
):
runtime.start(experiment['id'], 'alice')
deadline = time.time() + 3
current = runtime.get_experiment(experiment['id'], 'alice')
while (
current['status'] not in {'completed', 'completed_with_errors'}
and time.time() < deadline
):
time.sleep(0.02)
current = runtime.get_experiment(
experiment['id'],
'alice',
)
self.assertEqual(current['status'], 'completed')
self.assertEqual(current['completed_cases'], 2)
self.assertEqual(current['metrics']['accuracy'], 1.0)
finally:
runtime.shutdown()
def test_accessed_refs_only_include_snapshot_paths(self) -> None:
with tempfile.TemporaryDirectory() as directory:
snapshot_root = Path(directory)
evidence = snapshot_root / 'skills' / 'label-master' / 'knowledge.md'
evidence.parent.mkdir(parents=True)
evidence.write_text('evidence', encoding='utf-8')
transcript = (
{
'role': 'assistant',
'tool_calls': [
{
'function': {
'name': 'read_file',
'arguments': json.dumps(
{
'path': (
'skills/label-master/knowledge.md'
)
}
),
}
},
{
'function': {
'name': 'read_file',
'arguments': json.dumps({'path': '/etc/hosts'}),
}
},
],
},
)
self.assertEqual(
extract_accessed_refs(
transcript,
snapshot_root=snapshot_root,
),
[
{
'tool': 'read_file',
'path': 'skills/label-master/knowledge.md',
}
],
)
def test_metrics_count_disagreements(self) -> None:
metrics = calculate_metrics(
[
{
'status': 'completed',
'gold_label': 'A',
'prediction': 'A',
'correct': True,
'confidence': 0.9,
'evidence_refs': ['a.md'],
'domain': 'one',
},
{
'status': 'completed',
'gold_label': 'A',
'prediction': 'B',
'correct': False,
'confidence': 0.5,
'evidence_refs': [],
'domain': 'one',
},
]
)
self.assertEqual(metrics['labeled'], 2)
self.assertEqual(metrics['disagreements'], 1)
self.assertEqual(metrics['accuracy'], 0.5)
self.assertEqual(metrics['low_confidence'], 1)
self.assertEqual(metrics['no_evidence'], 1)
if __name__ == '__main__':
unittest.main()
+39
View File
@@ -99,6 +99,45 @@ class ExtendedToolTests(unittest.TestCase):
self.assertIn('[line truncated,', result.content) self.assertIn('[line truncated,', result.content)
self.assertLess(len(result.content), 1200) self.assertLess(len(result.content), 1200)
def test_glob_search_accepts_absolute_path_inside_workspace(self) -> None:
registry = default_tool_registry()
with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir)
target_dir = workspace / 'output'
target_dir.mkdir()
(target_dir / 'records.jsonl').write_text('{}\n', encoding='utf-8')
context = build_tool_context(
AgentRuntimeConfig(cwd=workspace),
tool_registry=registry,
)
result = execute_tool(
registry,
'glob_search',
{'pattern': str(target_dir / '*.jsonl')},
context,
)
self.assertTrue(result.ok)
self.assertIn('output/records.jsonl', result.content)
def test_glob_search_external_absolute_path_returns_no_matches(self) -> None:
registry = default_tool_registry()
with tempfile.TemporaryDirectory() as workspace_dir, tempfile.TemporaryDirectory() as external_dir:
external = Path(external_dir) / '*.jsonl'
context = build_tool_context(
AgentRuntimeConfig(cwd=Path(workspace_dir)),
tool_registry=registry,
)
result = execute_tool(
registry,
'glob_search',
{'pattern': str(external)},
context,
)
self.assertTrue(result.ok)
self.assertEqual(result.content, '(no matches)')
def test_read_file_can_read_explicit_external_path(self) -> None: def test_read_file_can_read_explicit_external_path(self) -> None:
registry = default_tool_registry() registry = default_tool_registry()
with tempfile.TemporaryDirectory() as workspace_dir, tempfile.TemporaryDirectory() as external_dir: with tempfile.TemporaryDirectory() as workspace_dir, tempfile.TemporaryDirectory() as external_dir:
+28
View File
@@ -109,6 +109,34 @@ class FeishuIntegrationTests(unittest.TestCase):
) )
self.assertEqual(online_docs['files'][str(file_path)]['kind'], 'doc') self.assertEqual(online_docs['files'][str(file_path)]['kind'], 'doc')
def test_create_online_doc_returns_login_required_when_mcp_token_expired(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
client, state = _build_client(Path(tmp_dir))
file_path = _write_account_file(state, 'alice', 'draft.md', '# hello')
with patch.object(
gui_server,
'_feishu_status_payload',
return_value={'logged_in': True, 'status': 'logged_in'},
), patch.object(
gui_server.MCPRuntime,
'call_tool',
side_effect=RuntimeError('登录已过期或未登录,请重新授权'),
), patch.object(
gui_server,
'_run_feishu_cli',
return_value={'returncode': 0, 'output': 'logged out'},
):
response = client.post(
'/api/files/online-doc',
json={'account_id': 'alice', 'path': str(file_path)},
)
self.assertEqual(response.status_code, 409)
detail = response.json()['detail']
self.assertEqual(detail['code'], 'feishu_auth_expired')
self.assertTrue(detail['force_login'])
def test_create_online_doc_converts_csv_to_feishu_sheet(self) -> None: def test_create_online_doc_converts_csv_to_feishu_sheet(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
client, state = _build_client(Path(tmp_dir)) client, state = _build_client(Path(tmp_dir))