diff --git a/asp-doc b/asp-doc index c87ef70..528ff15 160000 --- a/asp-doc +++ b/asp-doc @@ -1 +1 @@ -Subproject commit c87ef70df283965d4f7e4f5f6fe068710175fff5 +Subproject commit 528ff15c2ea1c2805e86d7607d5b9b67b12c4266 diff --git a/backend/apps/settings/migrations/0006_customvariable_typed_value.py b/backend/apps/settings/migrations/0006_customvariable_typed_value.py new file mode 100644 index 0000000..de11dce --- /dev/null +++ b/backend/apps/settings/migrations/0006_customvariable_typed_value.py @@ -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(), + ), + ] diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 2903f8c..4f3501e 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -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) diff --git a/backend/apps/settings/serializers.py b/backend/apps/settings/serializers.py index ab201df..468b7e8 100644 --- a/backend/apps/settings/serializers.py +++ b/backend/apps/settings/serializers.py @@ -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 diff --git a/backend/apps/settings/views.py b/backend/apps/settings/views.py index 308f9a2..a62d504 100644 --- a/backend/apps/settings/views.py +++ b/backend/apps/settings/views.py @@ -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", diff --git a/docs/specs/v0.6.0/00-release-scope.md b/docs/specs/v0.6.0/00-release-scope.md index 8cf787b..a368988 100644 --- a/docs/specs/v0.6.0/00-release-scope.md +++ b/docs/specs/v0.6.0/00-release-scope.md @@ -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 质量评估。 -SLA、AI 质量评估和抑制规则都是 v0.6.0 正式发布的阻断项,但必须限制为最小可用闭环。 +SLA 和 AI 质量评估是 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 Center;Worker Health 使用独立实现。 - 全站 UI 国际化。 - 旧 CLI/插件兼容层。 - 自动 Unmerge。 diff --git a/docs/specs/v0.6.0/05-worker-health.md b/docs/specs/v0.6.0/05-worker-health.md deleted file mode 100644 index ec402bb..0000000 --- a/docs/specs/v0.6.0/05-worker-health.md +++ /dev/null @@ -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 update,API 通过 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。 -- 状态 Tag:Starting、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 403,Admin 页面每 10 秒刷新。 - -## 16. Known tradeoffs - -- Redis 故障时无法独立判断 Worker 健康。 -- 当前状态和计数不会跨进程重启保留。 -- daemon thread 增加少量线程复杂度,但解决了长任务 liveness。 -- Running 永不按时长 Stalled,业务死循环只要 heartbeat thread 活着就仍显示 Running。 diff --git a/docs/specs/v0.6.0/07-sla-management.md b/docs/specs/v0.6.0/07-sla-management.md index b51c0c6..4930d05 100644 --- a/docs/specs/v0.6.0/07-sla-management.md +++ b/docs/specs/v0.6.0/07-sla-management.md @@ -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。 diff --git a/docs/specs/v0.6.0/08-ai-quality-evaluation.md b/docs/specs/v0.6.0/08-ai-quality-evaluation.md new file mode 100644 index 0000000..9618997 --- /dev/null +++ b/docs/specs/v0.6.0/08-ai-quality-evaluation.md @@ -0,0 +1,360 @@ +# AI Quality Evaluation + +Status: Confirmed + +## 1. Purpose + +比较 Closed Case 的最终人工结构化判断与关闭前最后一次有效 AI 分析结果,形成可解释的 AI–Human 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 identity;Evaluation 只选择 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 prediction:eligible 成功 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 文案使用: + +- AI–Human 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。 +- 32–180 天: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。 +- 无报告正文质量反馈、导出、阈值和自动学习。 diff --git a/docs/specs/v0.6.0/README.md b/docs/specs/v0.6.0/README.md index 6dcccbc..3d6138d 100644 --- a/docs/specs/v0.6.0/README.md +++ b/docs/specs/v0.6.0/README.md @@ -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 | AI–Human 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 使用规则 diff --git a/docs/specs/v0.6.0/TODO-remaining-domains.md b/docs/specs/v0.6.0/TODO-remaining-domains.md new file mode 100644 index 0000000..a00c99e --- /dev/null +++ b/docs/specs/v0.6.0/TODO-remaining-domains.md @@ -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`。 diff --git a/frontend/package.json b/frontend/package.json index 26eee2a..a9970fc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1eb4ffa..8d0c2a2 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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: diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index 9ffd508..7f7d2c4 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -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 = 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 = ( = R key={tableResetKey} className="asp-data-table" + style={tableStyle} columns={antColumns} dataSource={data} rowKey={rowKey} diff --git a/frontend/src/config/resources.tsx b/frontend/src/config/resources.tsx index 2f0d0ca..aa81075 100644 --- a/frontend/src/config/resources.tsx +++ b/frontend/src/config/resources.tsx @@ -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 = { fast: 'blue', powerful: 'purple', @@ -1027,12 +1040,14 @@ export const resourceConfigs: Record> = { 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> = { 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}), diff --git a/frontend/src/index.css b/frontend/src/index.css index 946b12e..0e86e57 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/pages/CustomVariablesSettings.tsx b/frontend/src/pages/CustomVariablesSettings.tsx index 974afbb..c11640d 100644 --- a/frontend/src/pages/CustomVariablesSettings.tsx +++ b/frontend/src/pages/CustomVariablesSettings.tsx @@ -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 & { 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 & { 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(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(`/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 = { + 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 (
+ +