Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e7570e72d | |||
| 459bee47a7 | |||
| 96809fa5c8 | |||
| 174d1d3d01 | |||
| e2bf012f60 | |||
| a505261bde | |||
| 6630e82069 | |||
| ecc02c06b7 | |||
| 6116d10e8f | |||
| 5be9434ba3 | |||
| 9067914ecc | |||
| 017743a415 | |||
| 0e4f5041f1 | |||
| 97e1e1dd48 | |||
| 7cd6e5e464 | |||
| b7798e9985 | |||
| ef213c4125 | |||
| 359b84ba55 | |||
| f5f98688dd | |||
| da8b63443b | |||
| 2a6a7c2780 | |||
| 8eadc25b19 | |||
| 0007d99763 | |||
| 84ff22fc65 | |||
| 3637e2c73a |
@@ -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
|
||||||
+497
-17
@@ -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,7 +4850,13 @@ 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(
|
||||||
|
state: AgentState,
|
||||||
|
account_id: str | None,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not force:
|
||||||
status = _feishu_status_payload(state, account_id)
|
status = _feishu_status_payload(state, account_id)
|
||||||
if status.get('logged_in'):
|
if status.get('logged_in'):
|
||||||
return status
|
return status
|
||||||
@@ -4386,7 +4864,7 @@ def _start_feishu_login(state: AgentState, account_id: str | None) -> dict[str,
|
|||||||
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 |
@@ -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 分钟内 | 约 6~12 倍 | |
|
||||||
|
| 线上配置修改(熟练工) | 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
|
||||||
|
实验 A:Skill abc1234 + 模型 A
|
||||||
|
实验 B:Skill def5678 + 模型 A
|
||||||
|
实验 C:Skill 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。
|
||||||
@@ -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>`);
|
||||||
|
}
|
||||||
@@ -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 : "操作失败";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll(""", '"')
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
await mutateSessions((sessionsFile) => {
|
||||||
sessionsFile.sessions[token] = {
|
sessionsFile.sessions[token] = {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: now,
|
||||||
updatedAt: new Date().toISOString(),
|
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) =>
|
||||||
|
({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
})[character],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void initialize();
|
||||||
|
})();
|
||||||
@@ -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=已配置"
|
||||||
|
|||||||
@@ -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 进程。
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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` 落盘后再单独提交评测。
|
||||||
|
|||||||
Regular → Executable
+87
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user