Merge remote-tracking branch 'origin/master'

# Conflicts:
#	docs/specs/v0.6.0/00-release-scope.md
#	docs/specs/v0.6.0/05-worker-health.md
#	docs/specs/v0.6.0/README.md
#	docs/specs/v0.6.0/TODO-remaining-domains.md
This commit is contained in:
funnywolf
2026-08-03 20:09:14 +08:00
17 changed files with 982 additions and 408 deletions
+1 -1
Submodule asp-doc updated: c87ef70df2...528ff15c2e
@@ -0,0 +1,57 @@
# Generated by Django 6.0.7 on 2026-08-03
from django.db import migrations, models
def copy_string_values(apps, schema_editor):
CustomVariable = apps.get_model("settings", "CustomVariable")
for variable in CustomVariable.objects.only("id", "value").iterator():
variable.typed_value = variable.value
variable.save(update_fields=["typed_value"])
class Migration(migrations.Migration):
dependencies = [
("settings", "0005_customvariable"),
]
operations = [
migrations.AddField(
model_name="customvariable",
name="value_type",
field=models.CharField(
choices=[
("string", "String"),
("integer", "Integer"),
("float", "Float"),
("boolean", "Boolean"),
("list", "List"),
("dictionary", "Dictionary"),
],
default="string",
max_length=16,
),
preserve_default=False,
),
migrations.AddField(
model_name="customvariable",
name="typed_value",
field=models.JSONField(null=True),
),
migrations.RunPython(copy_string_values),
migrations.RemoveField(
model_name="customvariable",
name="value",
),
migrations.RenameField(
model_name="customvariable",
old_name="typed_value",
new_name="value",
),
migrations.AlterField(
model_name="customvariable",
name="value",
field=models.JSONField(),
),
]
+10 -1
View File
@@ -160,6 +160,14 @@ class RuntimeConfig(models.Model):
class CustomVariable(models.Model):
class ValueType(models.TextChoices):
STRING = "string", "String"
INTEGER = "integer", "Integer"
FLOAT = "float", "Float"
BOOLEAN = "boolean", "Boolean"
LIST = "list", "List"
DICTIONARY = "dictionary", "Dictionary"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
key = models.CharField(
max_length=128,
@@ -171,7 +179,8 @@ class CustomVariable(models.Model):
)
],
)
value = models.TextField()
value_type = models.CharField(max_length=16, choices=ValueType.choices)
value = models.JSONField()
is_secret = models.BooleanField(default=False)
description = models.TextField(blank=True, default="")
enabled = models.BooleanField(default=True)
+122 -18
View File
@@ -1,3 +1,6 @@
import json
import math
from rest_framework import serializers
from .models import (
@@ -13,9 +16,94 @@ from .models import (
MAX_CUSTOM_VARIABLE_VALUE_BYTES = 65_536
MAX_CUSTOM_VARIABLE_DEPTH = 20
MAX_SAFE_INTEGER = 9_007_199_254_740_991
def _validate_structured_custom_variable(value, depth=0):
if isinstance(value, list):
if depth > MAX_CUSTOM_VARIABLE_DEPTH:
raise serializers.ValidationError(
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_DEPTH} levels of nesting."
)
for item in value:
_validate_structured_custom_variable(item, depth + 1)
return
if isinstance(value, dict):
if depth > MAX_CUSTOM_VARIABLE_DEPTH:
raise serializers.ValidationError(
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_DEPTH} levels of nesting."
)
if any(not isinstance(key, str) for key in value):
raise serializers.ValidationError("Dictionary keys must be strings.")
for item in value.values():
_validate_structured_custom_variable(item, depth + 1)
return
if value is None or type(value) in {str, int, float, bool}:
return
raise serializers.ValidationError("Value must contain valid JSON values.")
def _validate_custom_variable_value(value_type, value):
if value_type == CustomVariable.ValueType.STRING:
if not isinstance(value, str):
raise serializers.ValidationError("Value must be a string.")
if value == "":
raise serializers.ValidationError("Value cannot be empty.")
encoded_value = value.encode("utf-8")
elif value_type == CustomVariable.ValueType.INTEGER:
if type(value) is not int:
raise serializers.ValidationError("Value must be an integer.")
if not -MAX_SAFE_INTEGER <= value <= MAX_SAFE_INTEGER:
raise serializers.ValidationError(
f"Value must be between {-MAX_SAFE_INTEGER:,} and {MAX_SAFE_INTEGER:,}."
)
encoded_value = json.dumps(value).encode("utf-8")
elif value_type == CustomVariable.ValueType.FLOAT:
if type(value) not in {int, float}:
raise serializers.ValidationError("Value must be a number.")
value = float(value)
if not math.isfinite(value):
raise serializers.ValidationError("Value must be a finite number.")
encoded_value = json.dumps(value).encode("utf-8")
elif value_type == CustomVariable.ValueType.BOOLEAN:
if type(value) is not bool:
raise serializers.ValidationError("Value must be a boolean.")
encoded_value = json.dumps(value).encode("utf-8")
elif value_type == CustomVariable.ValueType.LIST:
if not isinstance(value, list):
raise serializers.ValidationError("Value must be a list.")
encoded_value = _encode_structured_custom_variable(value)
elif value_type == CustomVariable.ValueType.DICTIONARY:
if not isinstance(value, dict):
raise serializers.ValidationError("Value must be a dictionary.")
encoded_value = _encode_structured_custom_variable(value)
else:
raise serializers.ValidationError("Unsupported value type.")
if len(encoded_value) > MAX_CUSTOM_VARIABLE_VALUE_BYTES:
raise serializers.ValidationError(
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_VALUE_BYTES:,} UTF-8 bytes."
)
return value
def _encode_structured_custom_variable(value):
_validate_structured_custom_variable(value, depth=1)
try:
serialized = json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
separators=(",", ":"),
)
except (TypeError, ValueError) as exc:
raise serializers.ValidationError("Value must contain valid JSON values.") from exc
return serialized.encode("utf-8")
class CustomVariableSerializer(serializers.ModelSerializer):
value = serializers.JSONField(required=False)
value_configured = serializers.SerializerMethodField()
confirm_secret_exposure = serializers.BooleanField(write_only=True, required=False, default=False)
@@ -24,6 +112,7 @@ class CustomVariableSerializer(serializers.ModelSerializer):
fields = (
"id",
"key",
"value_type",
"value",
"value_configured",
"is_secret",
@@ -35,50 +124,65 @@ class CustomVariableSerializer(serializers.ModelSerializer):
)
read_only_fields = ("id", "value_configured", "created_at", "updated_at")
extra_kwargs = {
"value": {"required": False, "trim_whitespace": False, "allow_blank": False},
"description": {"required": False, "allow_blank": True},
}
def get_value_configured(self, obj):
return bool(obj.value)
return obj.value is not None
def validate_key(self, value):
if self.instance is not None and value != self.instance.key:
raise serializers.ValidationError("Key cannot be changed.")
return value
def validate_value(self, value):
if value == "":
raise serializers.ValidationError("Value cannot be empty.")
if len(value.encode("utf-8")) > MAX_CUSTOM_VARIABLE_VALUE_BYTES:
raise serializers.ValidationError(
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_VALUE_BYTES:,} UTF-8 bytes."
)
return value
def validate(self, attrs):
attrs = super().validate(attrs)
confirmation = attrs.pop("confirm_secret_exposure", False)
if self.instance is None:
if "value_type" not in attrs:
raise serializers.ValidationError({"value_type": "Value type is required."})
if "value" not in attrs:
raise serializers.ValidationError({"value": "Value is required."})
return attrs
elif attrs.get("value_type", self.instance.value_type) != self.instance.value_type:
if "value" not in attrs:
raise serializers.ValidationError({
"value": "Value is required when changing the value type."
})
if not self.partial and "value" not in attrs and not self.instance.is_secret:
raise serializers.ValidationError({"value": "Value is required."})
next_is_secret = attrs.get("is_secret", self.instance.is_secret)
if self.instance.is_secret and not next_is_secret and not confirmation:
value_type = attrs.get(
"value_type",
self.instance.value_type if self.instance else None,
)
next_is_secret = attrs.get(
"is_secret",
self.instance.is_secret if self.instance else False,
)
if next_is_secret and value_type != CustomVariable.ValueType.STRING:
raise serializers.ValidationError({
"is_secret": "Only String variables can be secret."
})
if (
self.instance is not None
and self.instance.is_secret
and not next_is_secret
and not confirmation
):
raise serializers.ValidationError({
"confirm_secret_exposure": "Confirm that this secret value may be exposed."
})
if "value" in attrs:
try:
attrs["value"] = _validate_custom_variable_value(value_type, attrs["value"])
except serializers.ValidationError as exc:
raise serializers.ValidationError({"value": exc.detail}) from exc
return attrs
def to_representation(self, instance):
data = super().to_representation(instance)
if instance.is_secret:
data["value"] = ""
data["value"] = None
return data
+18 -3
View File
@@ -66,7 +66,14 @@ RUNTIME_AUDIT_FIELDS = (
"stream_maxlen",
"dashboard_refresh_interval_seconds",
)
CUSTOM_VARIABLE_AUDIT_FIELDS = ("key", "value", "is_secret", "description", "enabled")
CUSTOM_VARIABLE_AUDIT_FIELDS = (
"key",
"value_type",
"value",
"is_secret",
"description",
"enabled",
)
def _snapshot(instance, fields):
@@ -211,11 +218,19 @@ class CustomVariableViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated, IsAdmin]
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
search_fields = ("key", "description")
filterset_fields = ("is_secret", "enabled")
ordering_fields = ("key", "is_secret", "enabled", "created_at", "updated_at")
filterset_fields = ("value_type", "is_secret", "enabled")
ordering_fields = (
"key",
"value_type",
"is_secret",
"enabled",
"created_at",
"updated_at",
)
advanced_filter_fields = {
"key": "text",
"description": "text",
"value_type": "select",
"is_secret": "select",
"enabled": "select",
"created_at": "date",
+6 -5
View File
@@ -85,12 +85,10 @@ v0.6.0 保持三个固定角色:
3. Playbook 执行可观测性和控制。
4. Custom Variables。
5. Worker Health。
6. SLA 管理,待详细讨论
7. AI 质量评估,待详细讨论
8. 抑制规则,待详细讨论。
9. Operations 页面整合,待详细讨论。
6. SLA 管理。
7. AI 质量评估。
SLAAI 质量评估和抑制规则都是 v0.6.0 正式发布的阻断项,但必须限制为最小可用闭环
SLAAI 质量评估是 v0.6.0 正式发布的阻断项。
## 10. Explicit exclusions
@@ -102,6 +100,9 @@ SLA、AI 质量评估和抑制规则都是 v0.6.0 正式发布的阻断项,但
- 可视化或表单式 Playbook 编排器。
- Playbook 中途人工审批。
- 通用 HTTP/Webhook Connector 或统一厂商动作抽象。
- UI/数据库驱动的 Suppression Rules;抑制逻辑由自定义 Module Python 代码负责。
- Integration Health 定时探测和统一状态页;保留各 Settings 页手动 Test。
- Worker/Integration 统一 Operations CenterWorker Health 使用独立实现。
- 全站 UI 国际化。
- 旧 CLI/插件兼容层。
- 自动 Unmerge。
-354
View File
@@ -1,354 +0,0 @@
# Worker Health
Status: Confirmed
## 1. Purpose
让 Admin 不依赖 Docker CLI 或日志文件,就能判断后台 Worker 是否存活、正在做什么、是否失败以及业务积压情况。
v0.6.0 最终监控五个逻辑 Worker
1. Agentic Module。
2. Case Analysis。
3. Playbook。
4. ELK Action。
5. Dashboard Cache。
监控粒度是逻辑进程类型,不是每个 Module/Playbook definition,也不是 Docker container ID。
## 2. Architecture
Worker Health 基础设施集成进 `apps.common.worker_runner.run_worker()`
每个 Worker
- 启动时获取 Redis singleton lease。
- 启动 daemon heartbeat thread。
- 主线程在 iteration 边界更新执行状态与计数。
- 优雅退出时释放 lease并留下安全退出原因。
Admin API 从 Redis 读取当前状态,并按 Worker 类型附加 backlog diagnostics。
## 3. Redis keys
建议:
```text
worker-health:v1:{worker_type}:lease
worker-health:v1:{worker_type}:state
worker-health:v1:{worker_type}:last-exit
```
### Lease
- value 至少包含随机 instance_id。
- TTL 30 秒。
- heartbeat 每 10 秒 compare-and-refresh,只有 owner instance_id 可续租。
- 第二个同类型 Worker 发现有效 lease 后拒绝启动。
- lease 过期后新实例可获取。
- 不允许 last-writer-wins 覆盖。
### State
state 可与 lease 同一个 JSON key,也可独立;必须保证 owner 才能更新。
建议字段:
| Field | Meaning |
| --- | --- |
| worker_type | stable logical identifier |
| instance_id | random UUID per process |
| hostname | safe host/container hostname |
| state | Starting/Idle/Running/Degraded |
| started_at | process start |
| heartbeat_at | last heartbeat |
| iteration_started_at | current/last iteration start |
| last_iteration_success_at | any successful iteration, including idle |
| last_processed_at | last iteration that processed work |
| last_failure_at | last failed/partial-failed iteration |
| last_duration_ms | last iteration duration |
| consecutive_failures | reset on successful iteration |
| iteration_count | process-lifetime count |
| processed_iteration_count | iterations with processed=true |
| success_count | successful iterations |
| failure_count | failed or partial-failed iterations |
| last_message | safe WorkerIterationResult message |
| last_error | normalized safe error |
| log_role | log file role |
PID、完整 command、环境变量和 Secret 不通过 API 暴露。
### Last exit
优雅退出时记录短 TTL 或非租约型安全信息:
- instance_id
- exited_at
- reason=`graceful`
意外退出没有 last-exit updateAPI 通过 lease expired 判定。
## 4. Heartbeat
- daemon thread 每 10 秒刷新 lease。
- 主线程运行长任务或 sleep 时仍持续刷新。
- heartbeat Redis 写失败应记录安全日志;如果长期失败 lease 会过期。
- heartbeat thread 不改变业务 iteration 状态。
- Running 不设最大时长,也没有 Stalled。
- 只要 heartbeat 存活,任意长任务保持 Running。
线程退出规则:
- Worker 正常 SIGTERM/KeyboardInterrupt 时停止 heartbeat。
- 释放仅属于自身 instance_id 的 lease。
- 不能删除新实例已取得的 lease。
## 5. Singleton startup
Worker 启动:
1. 生成 instance_id。
2. 原子 SET NX 获取 lease。
3. 失败则抛 CommandError 并退出,Compose 可记录重启失败。
4. 成功写 Starting state。
5. 启动 heartbeat thread。
6. 进入主循环。
这适用于所有五个正式 Worker。`--once` 是运维命令,不应长期占用 singleton lease;如果它会与正式 Worker 并行影响同一数据,需要显式决定是否短暂获取 lease,默认建议获取以避免并发。
## 6. States
API 状态:
| State | Definition |
| --- | --- |
| Starting | live lease exists, first iteration not completed |
| Idle | live lease, last iteration successful, currently no work |
| Running | live lease, iteration currently executing |
| Degraded | live lease, latest iteration has any failure |
| Down | expected Worker has no live lease |
### Never reported
系统知道六种 expected worker type。没有 lease 且没有 state/last-exit 时:
- state=Down
- reason=`never_reported`
- UI 显示 Never reported
### Graceful and expired
- 正常退出:Down / graceful。
- 意外崩溃或进程被杀:30 秒后 Down / heartbeat_expired。
- Redis 本身不可用不是 Unknown/Down;整个 Worker Health API 返回 503。
### Degraded recovery
- 一次完整失败立即 Degraded。
- `WorkerIterationResult.failure_count > 0` 的部分失败也立即 Degraded。
- consecutive_failures 增加。
- 下一次无失败的 iteration 清零并恢复 Idle 或完成后的健康状态。
## 7. WorkerIterationResult contract
扩展当前 dataclass
```python
@dataclass(frozen=True)
class WorkerIterationResult:
processed: bool = False
message: str = ""
failure_count: int = 0
```
语义:
- `processed` 表示至少完成一个业务工作项。
- `failure_count` 表示 iteration 内被捕获的业务失败数。
- 抛异常是完整 iteration failure。
- `message` 必须是安全摘要。
### Time semantics
- 正常 idle polling 更新 `last_iteration_success_at`
- 只有 `processed=True` 更新 `last_processed_at`
- partial failure 不更新 success timestamp;是否同时有 processed 由结果如实记录。
## 8. Backlog diagnostics
不得强制统一为 queue_depth。
### Playbook
- Pending Run count。
- oldest Pending age。
- Running count。
### Case Analysis
- Pending job count。
- oldest eligible scheduled job age。
- Running count。
### Module
复用 Redis Stream health
- stream length。
- consumer group pending。
- consumer count。
- last-delivered-id。
- 可计算的 lag。
- 每个 Module definition 仍在现有 Custom 页面展示;Worker Health 可给汇总和跳转。
### ELK Action
外部索引完整积压不可可靠得知,只显示:
- last poll time。
- last poll actions/sent/skipped。
- last successful processed time。
- current configured interval。
### Dashboard Cache
- 24h/7d/30d 各缓存 age。
- generated/refreshed time。
- configured interval。
- cache missing 标记。
Backlog 查询失败不应使 heartbeat 消失。API 行级 diagnostics 可返回 safe warning。
## 9. Error safety
`last_error` 只包含:
- exception type。
- 标准 reason code 或固定安全摘要。
- failure time。
- log role。
不包含:
- 原始 exception message。
- traceback。
- HTTP response。
- SIEM query/event。
- username、password、token、API key。
完整排查信息留在 Worker 日志。
## 10. API
建议:
`GET /api/settings/operations/workers/`
仅 Admin。
正常返回六行:
```json
{
"results": [
{
"worker_type": "playbook",
"display_name": "Playbook Worker",
"state": "Idle",
"reason": "",
"instance_id": "...",
"hostname": "asp-worker-playbook",
"started_at": "...",
"heartbeat_at": "...",
"last_iteration_success_at": "...",
"last_processed_at": "...",
"last_failure_at": null,
"last_duration_ms": 8,
"consecutive_failures": 0,
"counters": {},
"last_message": "",
"last_error": null,
"log_role": "agentic-playbook-worker",
"backlog": {
"pending_count": 0,
"oldest_pending_age_seconds": null,
"running_count": 0
}
}
]
}
```
Redis health storage 无法访问:
- HTTP 503。
- 固定信息:`Worker health monitoring is unavailable.`
- 不返回五个伪造 Down 状态。
API 不提供:
- Restart。
- Run Now。
- Stop。
- Tail/download logs。
- 历史趋势。
## 11. Permissions and audit
- 仅 Admin 可访问 API/UI。
- User/Viewer 403。
- Health read 不写 AuditLog。
- heartbeat/state change 不写 AuditLog。
- 没有 Worker health notification。
## 12. Frontend
最终位置:`System Settings → Operations → Worker Health`
行为:
- 每 10 秒自动刷新。
- 提供手动 Refresh。
- 503 显示页面级 monitoring unavailable,不显示全红 Down。
- 状态 TagStarting、Idle、Running、Degraded、Down。
- 每行显示心跳、最后处理、最后失败、错误摘要、积压和 log reference。
- Worker-specific backlog 使用不同 detail 展示,不强行同列 JSON。
- 不提供任何 mutation 按钮。
- 可展示运维命令文本,但 Operations Center TODO 尚未确认具体命令 UX。
## 13. History and retention
- Redis 只保存当前状态。
- 计数从进程启动开始。
- Worker 重启后计数重置。
- 不提供 uptime 百分比、趋势图或历史事件。
- 日志是历史排查来源。
## 14. Compose changes
- 五个 Worker 使用明确 command 和 log role。
- 不挂载 Docker socket。
- 不通过 Web app 重启容器。
- Worker singleton 冲突应在日志中明确显示。
## 15. Acceptance criteria
1. 五类 Worker 正常显示 Starting→Idle/Running。
2. 第二个同类型实例拒绝启动。
3. heartbeat 10 秒、30 秒过期行为正确。
4. 长 Running 任务保持 heartbeat,不被判 Stalled/Down。
5. 进程被强杀后约 30 秒显示 Down/heartbeat expired。
6. 优雅退出显示 Down/graceful。
7. 第一次 iteration/partial failure 立即 Degraded,后续成功恢复。
8. idle success 与 actual processed 时间分离。
9. Redis 不可用 API 503。
10. 错误 API 不泄露原始异常或 Secret。
11. 每类 backlog 数据符合专属 schema。
12. User/Viewer 403Admin 页面每 10 秒刷新。
## 16. Known tradeoffs
- Redis 故障时无法独立判断 Worker 健康。
- 当前状态和计数不会跨进程重启保留。
- daemon thread 增加少量线程复杂度,但解决了长任务 liveness。
- Running 永不按时长 Stalled,业务死循环只要 heartbeat thread 活着就仍显示 Running。
+2 -2
View File
@@ -247,7 +247,7 @@ Warning 和 Breached 分别通知。同一 recipient 最多各一次。Worker
新增单实例 `run_sla_worker`
- 每 60 秒扫描。
- 接入 Worker Health,成为第 7 个 Worker。
- 接入 Worker Health,成为第 6 个 Worker。
- 只负责发现需通知状态并去重发送。
- API 状态仍动态计算,不依赖 Worker 更新状态。
- 对适用且未完成的 TTA/TTR 使用 deadline 索引扫描。
@@ -375,7 +375,7 @@ TTD 没有当前 Warning/Breach count。
8. 80% Warning、100% Breached,完成后 late 仍 Breached。
9. 旧 Case 无 SLA,新 Case有 SLA。
10. merged source 排除,target 仅重算 TTD。
11. SLA Worker 每分钟运行并进入 Worker Health。
11. SLA Worker 每分钟运行并作为第 6 个 Worker 进入 Worker Health。
12. 仅当前 Assignee 强制接收去重 Warning/Breach。
13. 新 Assignee 可收到当前状态,未分配不通知。
14. Case list/detail、Dashboard 和 Settings 行为符合 Spec。
@@ -0,0 +1,360 @@
# AI Quality Evaluation
Status: Confirmed
## 1. Purpose
比较 Closed Case 的最终人工结构化判断与关闭前最后一次有效 AI 分析结果,形成可解释的 AIHuman Agreement 指标。
该功能不宣称人工标签是绝对真相,因此产品文案不用 Accuracy/Correctness。它不评价报告文字质量,也不自动优化 Prompt。
## 2. Evaluated fields
逐项比较:
- Verdict ↔ verdict_ai。
- Severity ↔ severity_ai。
- Impact ↔ impact_ai。
- Priority ↔ priority_ai。
- Confidence ↔ confidence_ai。
不合成总质量分。
## 3. Reference prediction
每个 Case 的主质量样本最多一个,使用:
1. status=Success 的 CaseAnalysisJob。
2. completed_at 不晚于当前 closed_time。
3. Job 原本针对该目标 Case 运行。
4. 满足以上条件的最后一次 Job。
复用 `CaseAnalysisJob.result_json`,不创建独立 Prediction 表。
### Merge origin
案件合并会把历史 Job 迁入目标,但源上下文生成的 Job 不能作为目标预测。Job 必须保留 immutable origin Case identityEvaluation 只选择 origin=target 的 Job。
## 4. Metadata exclusion
AI Quality 不新增、使用或展示:
- Provider。
- Model。
- Prompt ID/content/hash/language。
- Profile version。
- Trigger。
- base URL。
- Input payload。
即使 CaseAnalysisJob/AnalysisRecord 当前已有部分字段,全局质量页面也不按这些维度过滤。已接受的限制是无法比较模型或 Prompt 版本质量。
## 5. Evaluation trigger and lifecycle
### Create
Case 进入 Closed 时创建当前 Evaluation,快照:
- 参考 Job。
- 五个 AI 值。
- 五个人工值。
- Case category。
- Assignee。
- closed_time。
- evaluated_at。
### Closed field correction
Case 保持 Closed 时修改任一人工比较字段,提交 Case 后重建同一 Evaluation。Category/Assignee snapshot 也随重建刷新。
### Reopen
Case Reopen 时删除当前 Evaluation。开放 Case 不进入质量统计。再次 Closed 后重新创建,不保留第一次关闭的质量版本。
### Delete
Evaluation 不提供独立 mutation API,随 Case 删除级联。Reference CaseAnalysisJob 不允许独立删除。
### Failure isolation
Evaluation 构建失败不得阻止 Case Close、Reopen 或人工字段修改:
- Case transaction 先成功。
- commit 后执行重建。
- 失败写安全日志。
- 提供 `rebuild_ai_quality_evaluations` 管理命令用于回填和修复。
- 不新增专用 Worker。
实现不得返回“失败形状的成功”。Case API 可成功,但日志和后续 reconciliation 必须发现缺失 Evaluation。
## 6. Coverage states
每个 Closed Case Evaluation 状态:
- Evaluated:有 eligible Job 且 result_json 可解析。
- No prediction:没有 eligible Job。
- Invalid predictioneligible 成功 Job 的 result_json 无法解析所需结构。
Prediction Coverage
```text
Evaluated Cases / all filtered non-merged Closed Cases
```
No prediction 和 Invalid prediction 都在分母、不在分子。页面单独显示 Invalid count。
存在 Job 即使某个 AI 字段为空,Case 仍可为 Evaluated;该字段单独 Not evaluable。
## 7. Missing values
每个字段只有 AI 和人工值都存在时才进入该字段 agreement 分母。
- AI 空:Not evaluable。
- 人工空:Not evaluable。
- 双方空:Not evaluable。
- Unknown 是显式有效值,不等于空。
Close 不强制五个人工字段全部填写;现有 Verdict 关闭约束保持。
## 8. Comparison semantics
### Verdict
- 使用完整 CaseVerdict 枚举。
- exact agreement。
- 完整 confusion matrix。
- 不归并二分类或三分类。
### Ordinal fields
Severity、Impact、Priority、Confidence
- exact agreement。
- absolute ordinal distance。
- AI overestimate。
- AI underestimate。
等级顺序使用现有枚举的业务顺序。Unknown:
- 参与 exact agreement。
- 进入 confusion counts。
- 任一方 Unknown 时不计算 distance/direction。
### Naming
所有 UI/API 文案使用:
- AIHuman Agreement。
- Agreement rate。
- Mismatch。
- Overestimate/Underestimate。
不使用 AI Accuracy、Correctness 或 analyst accuracy。
## 9. Data model
每 Case 一条 OneToOne `AiQualityEvaluation`,显式字段而非 JSON。
建议字段:
- case OneToOne。
- reference_job nullable FK。
- coverage_state。
- ai_verdict / human_verdict / verdict_agrees。
- ai_severity / human_severity / severity_agrees / severity_distance / severity_direction。
- ai_impact / human_impact / impact_agrees / impact_distance / impact_direction。
- ai_priority / human_priority / priority_agrees / priority_distance / priority_direction。
- ai_confidence / human_confidence / confidence_agrees / confidence_distance / confidence_direction。
- category_snapshot。
- assignee_snapshot nullable FK。
- closed_at。
- evaluated_at。
派生字段保存为显式 nullable 列,方便 PostgreSQL 聚合与筛选;重建时一次计算。
建议索引:
- closed_at。
- coverage_state。
- category_snapshot。
- assignee_snapshot。
- human_severity。
- 各 agrees 字段按实际查询计划决定组合索引。
## 10. Case merge
- merged source Evaluation 保留在只读源 Case。
- merged source 从所有全局聚合和默认样本列表排除。
- target 不继承 source Evaluation。
- 迁入的 source-origin Jobs 不作为 target 参考。
- target 后续 Closed 时按自己的 eligible Job 创建 Evaluation。
## 11. Historical backfill
升级时/升级后管理命令回填:
- 当前 Closed。
- 未合并。
- 使用当前人工字段。
- 选择 closed_time 前最后 eligible 成功 Job。
- 没有 Job 创建 No prediction。
- 解析失败创建 Invalid prediction。
- 不调用 LLM。
- 不产生通知或 Evaluation AuditLog。
## 12. Permissions
| Surface | Admin | User | Viewer |
| --- | --- | --- | --- |
| Current Case comparison | Yes | Yes | Yes |
| Global summary | Yes | No | No |
| Global samples | Yes | No | No |
| Mutation | No | No | No |
Assignee 可用于 Admin 筛选,但不提供分析师排行榜、最好/最差排名或绩效分。
## 13. Global analytics
位置:`System Settings → AI Quality`
默认最近 30 天,时间维度为 Case closed_time。
筛选:
- closed time range。
- category snapshot。
- human severity。
- assignee snapshot。
- coverage state。
不按 model/provider/prompt/profile/trigger 过滤。
### Required metrics
- Prediction Coverage 和 total count。
- Evaluated/No prediction/Invalid counts。
- 五字段 exact agreement rate + sample count。
- Verdict confusion matrix。
- 四个 ordinal 字段 mean absolute distance。
- 四个 ordinal 字段 over/under/match counts。
- 五字段 agreement trend。
不提供:
- composite score。
- pass/fail threshold。
- 红黄绿目标。
- severity weighted score。
- analyst leaderboard。
### Trends
- ≤31 天:daily。
- 32180 天:weekly。
- >180 天:monthly。
- 每点包含 sample count。
- 空桶不返回,不显示为 0%。
## 14. Sample drilldown
Admin-only 分页表:
- Case ID/title link。
- closed_at。
- assignee snapshot。
- category/human severity。
- coverage state。
- 五组 AI/human values。
- agreement/direction/distance。
筛选:
- mismatch field。
- only mismatches。
- global filters。
不在表中返回完整 Investigation Report、Analysis input、知识上下文或原始 Job JSON。
## 15. Case UI
现有 Investigation Tab 顶部增加五行对比表:
- Field。
- AI value。
- Human value。
- Agreement。
- Direction/distance(适用时)。
显示:
- No prediction。
- Invalid prediction。
- Not evaluable。
不新增独立 Case Quality Tab。完整报告继续使用现有 Investigation view。
## 16. API
### Global summary
`GET /api/ai-quality/summary/`
- Admin-only。
- 接收已确认筛选。
- 返回 coverage、agreement、matrix、ordinal stats、trend。
- PostgreSQL 实时聚合。
### Samples
`GET /api/ai-quality/evaluations/`
- Admin-only。
- cursor/page pagination。
- 返回安全结构化快照。
### Case surface
Case Investigation 或 Case detail 的只读字段返回当前 Evaluation。User/Viewer 可读。
无 create/update/delete API,无 CSV/JSON export。
## 17. Aggregation
- 实时 PostgreSQL 查询。
- 默认 30 天。
- medium 基线最多约 10,000 Evaluation,不新增缓存/Worker/materialized daily table。
- closed_at 和筛选维度必须有适当索引。
- API 必须返回 sample counts,避免小样本误解。
## 18. Audit
- Evaluation create/rebuild/delete 不写 AuditLog。
- Admin 查看 summary/sample 不写 AuditLog。
- Case Close/Reopen/人工字段修改沿用现有 Case audit。
- reference_job 和 evaluated_at 用于技术追溯。
## 19. Acceptance criteria
1. 五个字段比较正确且不合成总分。
2. 最新 eligible pre-close Job 选择正确。
3. source-origin merged Job 被排除。
4. Unknown 和 empty 语义正确。
5. Verdict matrix 和 ordinal direction/distance 正确。
6. Close 创建,Closed edit 重建,Reopen 删除,Reclose 重建。
7. Evaluation 故障不阻止 Case 业务操作,并可管理命令修复。
8. 历史 Closed Case 正确回填,不调用 LLM。
9. merged source 保留但排除统计。
10. Coverage denominator/numerator 和 Invalid count 正确。
11. Admin-only 全局页面,所有角色单案可见。
12. 筛选使用 Evaluation snapshot 和 closed_time。
13. 自适应趋势和 sample count 正确。
14. API 不暴露 report/input/provider/model/prompt metadata。
15. medium 数据实时聚合满足最终验收阈值。
## 20. Known tradeoffs
- 无模型/Prompt 元数据,无法解释版本变化导致的趋势。
- 人工字段只是参考标签,不是绝对 ground truth。
- Closed 后重建覆盖旧质量结果,不保留 closure version。
- Evaluation 失败与 Case 关闭隔离,短时间内统计可能缺少样本,依赖 reconciliation。
- 无报告正文质量反馈、导出、阈值和自动学习。
+5 -5
View File
@@ -10,19 +10,19 @@
| [01-bulk-case-triage.md](01-bulk-case-triage.md) | Confirmed | Case 批量分诊、共享状态机、通知与审计 |
| [02-case-merge.md](02-case-merge.md) | Confirmed | 多源案件合并、数据迁移、只读源案件与幂等 |
| [03-playbook-execution.md](03-playbook-execution.md) | Confirmed | Playbook Run、结构化 Stage、取消、重试与 Worker 语义 |
| [05-worker-health.md](05-worker-health.md) | Confirmed | Redis 心跳、Worker 状态、积压指标与 Admin API |
| [07-sla-management.md](07-sla-management.md) | Confirmed | TTD/TTA/TTR 时限、Severity 策略、通知和 Dashboard 达标率 |
| [08-ai-quality-evaluation.md](08-ai-quality-evaluation.md) | Confirmed | AIHuman Agreement、Coverage、混淆矩阵和样本下钻 |
## 待讨论
[TODO-remaining-domains.md](TODO-remaining-domains.md) 记录 AI 质量评估、抑制规则、Operations 页面整合和版本验收。继续讨论时应逐项把决定写回独立 Spec
[TODO-remaining-domains.md](TODO-remaining-domains.md) 记录版本验收。所有 v0.6.0 功能域均已确认或明确排除
## 实施顺序
1. 先完成 Case 状态机,再实现批量分诊和案件合并。
2. 完成 Playbook Run/Stage 后实现 Custom Variables
3. 完成通用 Worker Health 基础设施并接入五类 Worker,再实现 Operations 页面
4. 待剩余三个业务域定稿后,统一补齐 v0.6.0 验收规范。
2. 完成 Playbook Run/Stage。
3. 完成 SLA 和 AI Quality
4. 最后统一补齐 v0.6.0 验收规范。
## Spec 使用规则
@@ -0,0 +1,33 @@
# v0.6.0 remaining domain TODO
Status: Acceptance only
所有功能域已经确认或明确排除。剩余工作只有版本验收规范。
## TODO 1: v0.6.0 acceptance
Release blocking: Yes
需要根据全部 Confirmed Spec 定义:
- 从 v0.5.2 生产备份副本升级的完整演练。
- 全新 Docker Compose 安装。
- medium 数据规模下的关键 API/页面性能阈值。
- Admin/User/Viewer 权限矩阵回归。
- Case 单条和批量状态机一致性。
- 部分成功批量分诊、原子合并、幂等重试和 merged source 路由。
- Playbook Pending/Running/终态、Cancel、Retry、Stage 和 Worker 崩溃恢复。
- Custom Variables 的 Secret masking、Reveal audit 和 Module/Playbook 读取。
- Worker Redis 故障、重复实例、优雅退出和心跳过期。
- SLA 的 TTD/TTA/TTR、通知、Reopen、merge 和 Dashboard 达标率。
- AI Quality 的回填、Coverage、Agreement、合并排除和权限。
- 已明确排除的 Integration Health、Suppression Rules、Operations Center 不得误入发布范围。
- 备份、恢复和回滚演练。
- 文档、release notes、已知限制和功能冻结条件。
- P0/P1 缺陷门槛和 RC 观察周期。
输出目标:`11-release-acceptance.md`
## Suggested continuation prompt
> 阅读 `docs/specs/v0.6.0/README.md` 和全部 Confirmed/Excluded 决策。不要重新讨论功能范围。根据这些 Spec 一次只确认一个发布验收决策,完成后输出 `11-release-acceptance.md`
+2
View File
@@ -13,9 +13,11 @@
"dependencies": {
"@ant-design/charts": "^2.6.7",
"@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@uiw/react-codemirror": "^4.25.11",
"@uiw/react-json-view": "2.0.0-alpha.43",
"@uiw/react-md-editor": "^4.1.1",
"antd": "^6.5.1",
+201
View File
@@ -14,6 +14,9 @@ importers:
'@ant-design/icons':
specifier: ^6.3.2
version: 6.3.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -23,6 +26,9 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.7)
'@uiw/react-codemirror':
specifier: ^4.25.11
version: 4.25.11(@babel/runtime@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.7)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@uiw/react-json-view':
specifier: 2.0.0-alpha.43
version: 2.0.0-alpha.43(@babel/runtime@8.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -314,6 +320,33 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
'@codemirror/commands@6.10.4':
resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/language@6.12.4':
resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
'@codemirror/lint@6.9.7':
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
'@codemirror/search@6.7.1':
resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==}
'@codemirror/state@6.7.1':
resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.43.7':
resolution: {integrity: sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@@ -432,6 +465,21 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lezer/common@1.5.2':
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.10':
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
'@marijn/find-cluster-break@1.0.3':
resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
'@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
@@ -982,9 +1030,31 @@ packages:
resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@uiw/codemirror-extensions-basic-setup@4.25.11':
resolution: {integrity: sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==}
peerDependencies:
'@codemirror/autocomplete': '>=6.0.0'
'@codemirror/commands': '>=6.0.0'
'@codemirror/language': '>=6.0.0'
'@codemirror/lint': '>=6.0.0'
'@codemirror/search': '>=6.0.0'
'@codemirror/state': '>=6.0.0'
'@codemirror/view': '>=6.0.0'
'@uiw/copy-to-clipboard@1.0.21':
resolution: {integrity: sha512-apdzZJyJC/IEj21N22ry1H022pgpSA+FwNKxmvOGQ9rUMdyRbHf1nZq2UwqnfGOuTr7iOKLzNgWsCJtAwyAZpw==}
'@uiw/react-codemirror@4.25.11':
resolution: {integrity: sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==}
peerDependencies:
'@babel/runtime': '>=7.11.0'
'@codemirror/state': '>=6.0.0'
'@codemirror/theme-one-dark': '>=6.0.0'
'@codemirror/view': '>=6.0.0'
codemirror: '>=6.0.0'
react: '>=17.0.0'
react-dom: '>=17.0.0'
'@uiw/react-json-view@2.0.0-alpha.43':
resolution: {integrity: sha512-PMj+6xRDCPbVGccfLUDhx7yGrmVBSeFx3py6bqDds4QIYScXo9GEgtTbWXn3gXpoehsmOu//6dveYTf872wu3Q==}
peerDependencies:
@@ -1116,6 +1186,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
@@ -1146,6 +1219,9 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2135,6 +2211,9 @@ packages:
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@@ -2299,6 +2378,9 @@ packages:
yaml:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
@@ -2756,6 +2838,64 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.7
'@lezer/common': 1.5.2
'@codemirror/commands@6.10.4':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.7
'@lezer/common': 1.5.2
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/json': 1.0.3
'@codemirror/language@6.12.4':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.7
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
style-mod: 4.1.3
'@codemirror/lint@6.9.7':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.7
crelt: 1.0.7
'@codemirror/search@6.7.1':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.7
crelt: 1.0.7
'@codemirror/state@6.7.1':
dependencies:
'@marijn/find-cluster-break': 1.0.3
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.7
'@lezer/highlight': 1.2.3
'@codemirror/view@6.43.7':
dependencies:
'@codemirror/state': 6.7.1
crelt: 1.0.7
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
dependencies:
react: 19.2.7
@@ -2876,6 +3016,24 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lezer/common@1.5.2': {}
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/lr@1.4.10':
dependencies:
'@lezer/common': 1.5.2
'@marijn/find-cluster-break@1.0.3': {}
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
'@emnapi/core': 1.11.1
@@ -3461,8 +3619,35 @@ snapshots:
'@typescript-eslint/types': 8.64.0
eslint-visitor-keys: 5.0.1
'@uiw/codemirror-extensions-basic-setup@4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/view@6.43.7)':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/commands': 6.10.4
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.1
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.7
'@uiw/copy-to-clipboard@1.0.21': {}
'@uiw/react-codemirror@4.25.11(@babel/runtime@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.7)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 8.0.0
'@codemirror/commands': 6.10.4
'@codemirror/state': 6.7.1
'@codemirror/theme-one-dark': 6.1.3
'@codemirror/view': 6.43.7
'@uiw/codemirror-extensions-basic-setup': 4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/view@6.43.7)
codemirror: 6.0.2
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
transitivePeerDependencies:
- '@codemirror/autocomplete'
- '@codemirror/language'
- '@codemirror/lint'
- '@codemirror/search'
'@uiw/react-json-view@2.0.0-alpha.43(@babel/runtime@8.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 8.0.0
@@ -3649,6 +3834,16 @@ snapshots:
clsx@2.1.1: {}
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/commands': 6.10.4
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.1
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.7
color-name@1.1.4: {}
color-string@1.9.1:
@@ -3672,6 +3867,8 @@ snapshots:
cookie@1.1.1: {}
crelt@1.0.7: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -4963,6 +5160,8 @@ snapshots:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
style-mod@4.1.3: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -5111,6 +5310,8 @@ snapshots:
'@types/node': 24.13.1
fsevents: 2.3.3
w3c-keyname@2.2.8: {}
web-namespaces@2.0.1: {}
which@2.0.2:
+5 -1
View File
@@ -1,4 +1,4 @@
import type {Key} from 'react'
import type {CSSProperties, Key} from 'react'
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'
import {Button, Checkbox, Divider, Input, Pagination, Popconfirm, Popover, Select, Space, Table, Tooltip} from 'antd'
import {message} from '../utils/appMessage'
@@ -775,6 +775,9 @@ export default function DataTable<RecordType extends Record<string, unknown> = R
const activeFilterCount = filterState.advanced.filter(isActiveAdvancedFilter).length
const filterSummary = savedFilterName || (activeFilterCount > 0 ? `${activeFilterCount} filter(s)` : '')
const renderedActions = typeof actions === 'function' ? actions({ params: tableParams }) : actions
const tableStyle: CSSProperties & {'--asp-table-body-height': string} = {
'--asp-table-body-height': `${tableBodyHeight}px`,
}
const searchControl = (
<Input.Search
placeholder={searchPlaceholder}
@@ -902,6 +905,7 @@ export default function DataTable<RecordType extends Record<string, unknown> = R
<Table<RecordType>
key={tableResetKey}
className="asp-data-table"
style={tableStyle}
columns={antColumns}
dataSource={data}
rowKey={rowKey}
+16
View File
@@ -106,6 +106,19 @@ const customVariableConfiguredTag = (v: unknown) => {
const configured = v === true || v === 'true'
return choiceTag(configured ? 'Configured' : 'Not configured', configured ? 'green' : 'red')
}
const customVariableTypeOptions = [
{label: 'String', value: 'string'},
{label: 'Integer', value: 'integer'},
{label: 'Float', value: 'float'},
{label: 'Boolean', value: 'boolean'},
{label: 'List', value: 'list'},
{label: 'Dictionary', value: 'dictionary'},
]
const customVariableTypeTag = (v: unknown) => {
const type = String(v || '')
const option = customVariableTypeOptions.find((item) => item.value === type)
return choiceTag(option?.label || type, 'blue')
}
const llmTagColors: Record<string, string> = {
fast: 'blue',
powerful: 'purple',
@@ -1027,12 +1040,14 @@ export const resourceConfigs: Record<string, ResourceConfig<RecordRow>> = {
rowKey: 'id',
searchPlaceholder: 'Key, Description',
filters: [
{key: 'value_type', label: 'Type', valueType: 'select', options: customVariableTypeOptions, width: L160},
{key: 'is_secret', label: 'Secret', valueType: 'select', options: [{label: 'Secret', value: 'true'}, {label: 'Plain', value: 'false'}], width: L132},
{key: 'enabled', label: 'Enabled', valueType: 'select', options: [{label: 'Enabled', value: 'true'}, {label: 'Disabled', value: 'false'}], width: L132},
],
advancedFilters: [
field('key', 'Key', 'text'),
field('description', 'Description', 'text'),
{key: 'value_type', label: 'Type', valueType: 'select', options: customVariableTypeOptions},
{key: 'is_secret', label: 'Secret', valueType: 'select', options: [{label: 'Secret', value: 'true'}, {label: 'Plain', value: 'false'}]},
{key: 'enabled', label: 'Enabled', valueType: 'select', options: [{label: 'Enabled', value: 'true'}, {label: 'Disabled', value: 'false'}]},
field('created_at', 'Created At', 'date'),
@@ -1041,6 +1056,7 @@ export const resourceConfigs: Record<string, ResourceConfig<RecordRow>> = {
columns: [
column('key', 'Key', L240, {required: true, defaultVisible: true, openRecord: true, sorter: true}),
column('description', 'Description', L360, {defaultVisible: true}),
column('value_type', 'Type', L132, {defaultVisible: true, sorter: true, render: customVariableTypeTag}),
column('is_secret', 'Secret', L96, {defaultVisible: true, sorter: true, render: customVariableSecretTag}),
column('enabled', 'Enabled', L96, {defaultVisible: true, sorter: true, render: llmProviderStatusTag}),
column('value_configured', 'Value', L132, {defaultVisible: true, render: customVariableConfiguredTag}),
+4
View File
@@ -113,6 +113,10 @@ input:-webkit-autofill:active,
background: transparent;
}
.asp-data-table .ant-table-body {
min-height: var(--asp-table-body-height);
}
.asp-table-floating-help {
position: absolute;
right: 12px;
+140 -18
View File
@@ -1,6 +1,8 @@
import {useMemo, useState} from 'react'
import {App as AntApp, Button, Form, Input, Modal, Popconfirm, Space, Switch, Typography} from 'antd'
import {App as AntApp, Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Typography} from 'antd'
import {DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined} from '@ant-design/icons'
import CodeMirror from '@uiw/react-codemirror'
import {json} from '@codemirror/lang-json'
import client from '../api/client'
import DataTable from '../components/DataTable'
import {getResourceConfig} from '../config/resources'
@@ -9,7 +11,8 @@ import {message} from '../utils/appMessage'
type CustomVariable = Record<string, unknown> & {
id: string
key: string
value: string
value_type: CustomVariableValueType
value: unknown
value_configured: boolean
is_secret: boolean
description: string
@@ -18,9 +21,12 @@ type CustomVariable = Record<string, unknown> & {
updated_at: string
}
type CustomVariableValueType = 'string' | 'integer' | 'float' | 'boolean' | 'list' | 'dictionary'
interface CustomVariableFormValues {
key: string
value?: string
value_type: CustomVariableValueType
value?: unknown
is_secret: boolean
description?: string
enabled: boolean
@@ -32,6 +38,16 @@ interface RevealedValue {
}
const MAX_VALUE_BYTES = 65_536
const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER
const JSON_EDITOR_EXTENSIONS = [json()]
const VALUE_TYPE_OPTIONS = [
{label: 'String', value: 'string'},
{label: 'Integer', value: 'integer'},
{label: 'Float', value: 'float'},
{label: 'Boolean', value: 'boolean'},
{label: 'List', value: 'list'},
{label: 'Dictionary', value: 'dictionary'},
]
function apiErrorMessage(error: unknown, fallback: string) {
const data = (error as { response?: { data?: unknown } }).response?.data
@@ -47,6 +63,7 @@ function apiErrorMessage(error: unknown, fallback: string) {
function initialValues(): CustomVariableFormValues {
return {
key: '',
value_type: 'string',
value: '',
is_secret: false,
description: '',
@@ -65,6 +82,7 @@ export default function CustomVariablesSettings() {
const [revealed, setRevealed] = useState<RevealedValue | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const isSecret = Form.useWatch('is_secret', form) ?? false
const valueType = Form.useWatch('value_type', form) ?? 'string'
const refresh = () => setRefreshKey((value) => value + 1)
@@ -78,7 +96,12 @@ export default function CustomVariablesSettings() {
try {
const {data} = await client.get<CustomVariable>(`/custom/variables/${record.id}/`)
setEditing(data)
form.setFieldsValue({...initialValues(), ...data})
const value = data.is_secret
? ''
: data.value_type === 'list' || data.value_type === 'dictionary'
? JSON.stringify(data.value, null, 2)
: data.value
form.setFieldsValue({...initialValues(), ...data, value})
setModalOpen(true)
} catch (error: unknown) {
message.error(apiErrorMessage(error, 'Failed to load custom variable'))
@@ -93,12 +116,18 @@ export default function CustomVariablesSettings() {
const persist = async (values: CustomVariableFormValues, confirmSecretExposure: boolean) => {
const payload: Record<string, unknown> = {
value_type: values.value_type,
is_secret: values.is_secret,
description: values.description || '',
enabled: values.enabled,
}
if (!editing) payload.key = values.key
if (values.value !== '' && values.value !== undefined) payload.value = values.value
const omitUnchangedSecret = Boolean(editing?.is_secret && values.value_type === 'string' && values.value === '')
if (!omitUnchangedSecret) {
payload.value = values.value_type === 'list' || values.value_type === 'dictionary'
? JSON.parse(String(values.value))
: values.value
}
if (confirmSecretExposure) payload.confirm_secret_exposure = true
if (editing) {
@@ -116,12 +145,18 @@ export default function CustomVariablesSettings() {
setSaving(true)
try {
const values = await form.validateFields()
if (editing?.is_secret && !values.is_secret) {
const exposesSecret = Boolean(editing?.is_secret && !values.is_secret)
const changesType = Boolean(editing && editing.value_type !== values.value_type)
if (exposesSecret || changesType) {
modal.confirm({
title: `Expose ${editing.key} as a non-secret variable?`,
content: 'This value will become visible in normal Admin API responses and UI.',
okText: 'Expose value',
okButtonProps: {danger: true},
title: exposesSecret
? `Expose ${editing?.key} as a non-secret variable?`
: `Change ${editing?.key} from ${editing?.value_type} to ${values.value_type}?`,
content: exposesSecret
? 'This value will become visible in normal Admin API responses and UI.'
: 'Playbooks and modules will receive a different Python value type.',
okText: exposesSecret ? 'Expose value' : 'Change type',
okButtonProps: {danger: exposesSecret},
onOk: async () => {
try {
await persist(values, true)
@@ -163,16 +198,53 @@ export default function CustomVariablesSettings() {
}
}
const validateValue = (_: unknown, value: string | undefined) => {
if (!value && !editing?.is_secret) {
const validateValue = (_: unknown, value: unknown) => {
const unchangedSecret = editing?.is_secret && valueType === 'string' && value === ''
if (unchangedSecret) return Promise.resolve()
if (value === undefined || value === null || (valueType === 'string' && value === '')) {
return Promise.reject(new Error('Value is required.'))
}
if (value !== undefined && new TextEncoder().encode(value).length > MAX_VALUE_BYTES) {
if (valueType === 'integer' && (typeof value !== 'number' || !Number.isSafeInteger(value))) {
return Promise.reject(new Error('Value must be a safe integer.'))
}
let serialized = String(value)
if (valueType === 'list' || valueType === 'dictionary') {
try {
const parsed = JSON.parse(serialized)
if (valueType === 'list' && !Array.isArray(parsed)) {
return Promise.reject(new Error('Value must be a JSON list.'))
}
if (valueType === 'dictionary' && (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object')) {
return Promise.reject(new Error('Value must be a JSON dictionary.'))
}
serialized = JSON.stringify(parsed)
} catch {
return Promise.reject(new Error('Value must be valid JSON.'))
}
}
if (new TextEncoder().encode(serialized).length > MAX_VALUE_BYTES) {
return Promise.reject(new Error(`Value cannot exceed ${MAX_VALUE_BYTES.toLocaleString()} UTF-8 bytes.`))
}
return Promise.resolve()
}
const changeValueType = (nextType: CustomVariableValueType) => {
form.setFieldsValue({
value_type: nextType,
value: nextType === 'boolean' ? false : undefined,
is_secret: nextType === 'string' ? form.getFieldValue('is_secret') : false,
})
}
const formatStructuredValue = () => {
try {
const parsed = JSON.parse(String(form.getFieldValue('value') ?? ''))
form.setFieldValue('value', JSON.stringify(parsed, null, 2))
} catch {
message.error('Value must be valid JSON before it can be formatted')
}
}
return (
<div style={{height: 'calc(100vh - 188px)', minHeight: 360}}>
<DataTable
@@ -241,6 +313,7 @@ export default function CustomVariablesSettings() {
<Modal
title={editing ? `Custom Variable: ${editing.key}` : 'Add Custom Variable'}
open={modalOpen}
width={valueType === 'list' || valueType === 'dictionary' ? 760 : undefined}
onCancel={closeEditor}
destroyOnHidden
footer={(
@@ -261,23 +334,72 @@ export default function CustomVariablesSettings() {
>
<Input disabled={editing !== null} placeholder="EDR_API_TOKEN" maxLength={128} />
</Form.Item>
<Form.Item
name="value_type"
label="Type"
rules={[{required: true}]}
>
<Select options={VALUE_TYPE_OPTIONS} onChange={changeValueType} />
</Form.Item>
<Form.Item
name="value"
label="Value"
valuePropName={valueType === 'boolean' ? 'checked' : 'value'}
label={(
<Space>
<span>Value</span>
{(valueType === 'list' || valueType === 'dictionary')
? <Button type="link" size="small" onClick={formatStructuredValue}>Format</Button>
: null}
</Space>
)}
rules={[{validator: validateValue}]}
extra={editing?.is_secret
? 'Leave blank to keep the current value. Maximum 65,536 UTF-8 bytes.'
: 'Maximum 65,536 UTF-8 bytes.'}
>
{isSecret
? <Input.Password autoComplete="new-password" />
: <Input.TextArea autoSize={{minRows: 3, maxRows: 10}} />}
{valueType === 'boolean' ? (
<Switch />
) : valueType === 'integer' ? (
<InputNumber
min={-MAX_SAFE_INTEGER}
max={MAX_SAFE_INTEGER}
precision={0}
style={{width: '100%'}}
/>
) : valueType === 'float' ? (
<InputNumber style={{width: '100%'}} />
) : isSecret ? (
<Input.Password autoComplete="new-password" />
) : valueType === 'list' || valueType === 'dictionary' ? (
<CodeMirror
height="320px"
theme="dark"
extensions={JSON_EDITOR_EXTENSIONS}
placeholder={valueType === 'list' ? '[\n \n]' : '{\n \n}'}
basicSetup={{
lineNumbers: true,
foldGutter: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
}}
style={{
border: '1px solid rgba(253, 253, 253, 0.12)',
borderRadius: 6,
overflow: 'hidden',
}}
/>
) : (
<Input.TextArea autoSize={{minRows: 3, maxRows: 10}} />
)}
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea autoSize={{minRows: 2, maxRows: 5}} />
</Form.Item>
<Form.Item name="is_secret" label="Secret" valuePropName="checked">
<Switch />
<Switch disabled={valueType !== 'string'} />
</Form.Item>
<Form.Item name="enabled" label="Enabled" valuePropName="checked">
<Switch />