feat(agent): audit orchestrated runs

This commit is contained in:
2026-07-24 22:13:04 +08:00
parent 434bab6fed
commit 6ba4e06a2f
12 changed files with 286 additions and 13 deletions
@@ -0,0 +1,59 @@
package org.dromara.aihr.agent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.agent.AihrAgentDto.AgentPlan;
import org.dromara.aihr.agent.AihrAgentDto.AgentResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrAgentAuditService {
private final JdbcTemplate jdbcTemplate;
public void record(AihrKnowledgePrincipal principal, AgentPlan plan, AgentResponse response,
long durationMs, String errorCode) {
if (principal == null || plan == null || response == null) {
return;
}
try {
String sourceType = response.sourceSummary().isEmpty()
? null
: clean(response.sourceSummary().get(0).type(), 32);
String resultRef = response.actionDraft() == null
? null
: clean("DRAFT:" + response.actionDraft().type(), 100);
jdbcTemplate.update("""
insert into aihr_agent_run
(run_id, tenant_id, client_key, user_id, conversation_id, context_version,
intent, tool, status, source_type, duration_ms, error_code, result_ref, create_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
""",
clean(response.runId(), 64), clean(principal.tenantId(), 20),
clean(principal.clientKey(), 64), principal.userId(),
clean(response.conversationId(), 64), response.contextVersion(),
plan.intent().name(), plan.tool().name(), response.status().name(), sourceType,
Math.max(0, durationMs), safeCode(errorCode), resultRef
);
} catch (RuntimeException ignored) {
log.warn("agent audit write failed (details hidden)");
}
}
private static String safeCode(String value) {
String code = clean(value, 32);
return code != null && code.matches("[A-Za-z0-9_-]+") ? code : null;
}
private static String clean(String value, int maxLength) {
if (value == null || value.isBlank()) {
return null;
}
String cleaned = value.trim();
return cleaned.length() <= maxLength ? cleaned : cleaned.substring(0, maxLength);
}
}
@@ -9,6 +9,7 @@ import org.dromara.aihr.agent.AihrAgentDto.ActionDraft;
import org.dromara.aihr.agent.AihrAgentDto.Clarification;
import org.dromara.aihr.agent.AihrAgentDto.Intent;
import org.dromara.aihr.agent.AihrAgentDto.SourceSummary;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary;
@@ -35,11 +36,51 @@ public class AihrAgentOrchestrator {
private final AihrKnowledgeQueryService queryService;
private final AihrAgentActionService actionService;
private final AihrWebAiService webAiService;
private final AihrAgentAuditService auditService;
public AgentResponse handle(AgentRequest request) {
requireQuestion(request);
AgentPlan plan = planner.plan(request.question(), false);
var principal = principalResolver.current();
return execute(request, null, false);
}
public AgentResponse handleMedia(AgentRequest request, MultipartFile file) {
return execute(request, file, true);
}
private AgentResponse execute(AgentRequest request, MultipartFile file, boolean media) {
long started = System.nanoTime();
AgentPlan plan = null;
AihrKnowledgePrincipal principal = null;
AgentResponse response = null;
String errorCode = null;
try {
requireQuestion(request);
if (media && (file == null || file.isEmpty())) {
throw new ServiceException("附件不能为空", 400);
}
plan = planner.plan(request.question(), media);
principal = principalResolver.current();
response = media
? executeMedia(plan, request, file, principal)
: executeText(plan, request, principal);
return response;
} catch (ServiceException ex) {
errorCode = ex.getCode() == null ? "REJECTED" : "HTTP_" + ex.getCode();
throw ex;
} catch (RuntimeException ex) {
errorCode = "INTERNAL";
throw ex;
} finally {
if (auditService != null && plan != null && principal != null) {
AgentResponse audited = response == null
? failed(plan, request, errorCode)
: response;
auditService.record(principal, plan, audited,
(System.nanoTime() - started) / 1_000_000L, errorCode);
}
}
}
private AgentResponse executeText(AgentPlan plan, AgentRequest request, AihrKnowledgePrincipal principal) {
if (plan.requiresExternalConsent() && !request.externalConsent()) {
return simple(plan, AgentStatus.NEEDS_INPUT,
"全网查询会将脱敏后的问题发送给外部公开检索服务。",
@@ -58,13 +99,9 @@ public class AihrAgentOrchestrator {
};
}
public AgentResponse handleMedia(AgentRequest request, MultipartFile file) {
requireQuestion(request);
if (file == null || file.isEmpty()) {
throw new ServiceException("附件不能为空", 400);
}
AgentPlan plan = planner.plan(request.question(), true);
policy.authorize(principalResolver.current(), plan, request.externalConsent());
private AgentResponse executeMedia(AgentPlan plan, AgentRequest request, MultipartFile file,
AihrKnowledgePrincipal principal) {
policy.authorize(principal, plan, request.externalConsent());
MediaMode mode = needsKnowledge(request.question())
? MediaMode.MEDIA_WITH_KNOWLEDGE
: MediaMode.MEDIA_ONLY;
@@ -157,6 +194,15 @@ public class AihrAgentOrchestrator {
);
}
private static AgentResponse failed(AgentPlan plan, AgentRequest request, String errorCode) {
AgentStatus status = "HTTP_403".equals(errorCode) ? AgentStatus.FORBIDDEN : AgentStatus.FAILED;
return new AgentResponse(
runId(), request == null ? null : request.conversationId(),
request == null ? null : request.contextVersion(), plan.intent(), status, "",
List.of(), List.of(), List.of(), null, null, null, List.of()
);
}
private static String runId() {
return "agent_run_" + UUID.randomUUID().toString().replace("-", "");
}
@@ -0,0 +1,83 @@
package org.dromara.aihr.agent;
import org.dromara.aihr.agent.AihrAgentDto.AgentPlan;
import org.dromara.aihr.agent.AihrAgentDto.AgentResponse;
import org.dromara.aihr.agent.AihrAgentDto.AgentStatus;
import org.dromara.aihr.agent.AihrAgentDto.Intent;
import org.dromara.aihr.agent.AihrAgentDto.ResponseStyle;
import org.dromara.aihr.agent.AihrAgentDto.SourceSummary;
import org.dromara.aihr.agent.AihrAgentDto.Tool;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class AihrAgentAuditServiceTest {
@Test
void auditStoresOnlyMinimalMetadataWithoutQuestionOrAnswer() {
var jdbc = new CapturingJdbcTemplate(false);
var service = new AihrAgentAuditService(jdbc);
service.record(principal(), plan(), response(), 23L, null);
String stored = jdbc.sql + Arrays.toString(jdbc.args);
assertTrue(stored.contains("agent_run_test"));
assertTrue(stored.contains("MY_CURRENT_TASKS"));
assertFalse(stored.contains("敏感问题"));
assertFalse(stored.contains("敏感答案"));
}
@Test
void auditFailureNeverBreaksAValidResponse() {
var service = new AihrAgentAuditService(new CapturingJdbcTemplate(true));
assertDoesNotThrow(() -> service.record(principal(), plan(), response(), 23L, "INTERNAL"));
}
private static AihrKnowledgePrincipal principal() {
return new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1",
Set.of("employee"), Set.of("P1"), "mobile");
}
private static AgentPlan plan() {
return new AgentPlan(Intent.LIVE_MY_WORK, "敏感问题", Tool.MY_CURRENT_TASKS,
false, false, ResponseStyle.FACT);
}
private static AgentResponse response() {
return new AgentResponse("agent_run_test", "conversation_1", 1L, Intent.LIVE_MY_WORK,
AgentStatus.COMPLETED, "敏感答案",
List.of(new SourceSummary("LIVE_DATA", "我的待办", "2026-07-24T20:00:00+08:00")),
List.of(), List.of(), null, null, null, List.of());
}
private static final class CapturingJdbcTemplate extends JdbcTemplate {
private final boolean fail;
private String sql;
private Object[] args;
private CapturingJdbcTemplate(boolean fail) {
this.fail = fail;
}
@Override
public int update(String sql, Object... args) {
if (fail) {
throw new IllegalStateException("database unavailable");
}
this.sql = sql;
this.args = args;
return 1;
}
}
}
@@ -55,6 +55,7 @@ class AihrAgentMediaTest {
},
query,
null,
null,
null
);
}
@@ -111,6 +111,7 @@ class AihrAgentOrchestratorTest {
},
query,
actionService,
null,
null
);
}
@@ -72,7 +72,8 @@ class AihrAgentWebResearchTest {
}
},
null,
web
web,
null
);
}
@@ -313,3 +313,25 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_admin_audit` (
PRIMARY KEY (`id`),
KEY `idx_aihr_admin_audit_tenant` (`tenant_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识平台管理变更审计';
CREATE TABLE IF NOT EXISTS `aihr_agent_run` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`run_id` varchar(64) NOT NULL COMMENT 'Agent 运行号',
`tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '租户编号',
`client_key` varchar(64) DEFAULT NULL COMMENT '调用客户端',
`user_id` bigint NOT NULL COMMENT '当前登录用户',
`conversation_id` varchar(64) DEFAULT NULL COMMENT '短会话ID',
`context_version` bigint DEFAULT NULL COMMENT '短会话版本',
`intent` varchar(40) NOT NULL COMMENT '受校验意图',
`tool` varchar(50) NOT NULL COMMENT '登记工具',
`status` varchar(30) NOT NULL COMMENT '最终状态',
`source_type` varchar(32) DEFAULT NULL COMMENT '主要事实来源',
`duration_ms` bigint NOT NULL DEFAULT 0 COMMENT '处理耗时毫秒',
`error_code` varchar(32) DEFAULT NULL COMMENT '脱敏错误码',
`result_ref` varchar(100) DEFAULT NULL COMMENT '领域结果类型引用',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_agent_run` (`run_id`),
KEY `idx_aihr_agent_run_user` (`tenant_id`, `user_id`, `create_time`),
KEY `idx_aihr_agent_run_status` (`tenant_id`, `status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数字师傅 Agent 最小运行审计';
@@ -0,0 +1,23 @@
-- 数字师傅 Agent 最小运行审计。只保存路由和状态元数据,不保存问题、答案或模型推理。
CREATE TABLE IF NOT EXISTS `aihr_agent_run` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`run_id` varchar(64) NOT NULL COMMENT 'Agent 运行号',
`tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '租户编号',
`client_key` varchar(64) DEFAULT NULL COMMENT '调用客户端',
`user_id` bigint NOT NULL COMMENT '当前登录用户',
`conversation_id` varchar(64) DEFAULT NULL COMMENT '短会话ID',
`context_version` bigint DEFAULT NULL COMMENT '短会话版本',
`intent` varchar(40) NOT NULL COMMENT '受校验意图',
`tool` varchar(50) NOT NULL COMMENT '登记工具',
`status` varchar(30) NOT NULL COMMENT '最终状态',
`source_type` varchar(32) DEFAULT NULL COMMENT '主要事实来源',
`duration_ms` bigint NOT NULL DEFAULT 0 COMMENT '处理耗时毫秒',
`error_code` varchar(32) DEFAULT NULL COMMENT '脱敏错误码',
`result_ref` varchar(100) DEFAULT NULL COMMENT '领域结果类型引用',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_agent_run` (`run_id`),
KEY `idx_aihr_agent_run_user` (`tenant_id`, `user_id`, `create_time`),
KEY `idx_aihr_agent_run_status` (`tenant_id`, `status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数字师傅 Agent 最小运行审计';
+2 -1
View File
@@ -83,10 +83,11 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql
```
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
## 迁移后只读核验
+24
View File
@@ -135,6 +135,7 @@ FROM (
UNION ALL SELECT 'aihr_knowledge_query_log'
UNION ALL SELECT 'aihr_knowledge_conversation'
UNION ALL SELECT 'aihr_knowledge_admin_audit'
UNION ALL SELECT 'aihr_agent_run'
UNION ALL SELECT 'aihr_learning_question'
UNION ALL SELECT 'aihr_practice_question_feedback'
UNION ALL SELECT 'aihr_onboard_exam'
@@ -229,6 +230,29 @@ REMOTE
[[ -z "$missing_work_report_columns" ]] \
|| fail "remote work-report schema missing required columns: $(printf '%s' "$missing_work_report_columns" | tr '\n' ', ' | sed 's/, $//')"
local agent_schema_contract
agent_schema_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
db="$1"
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
SELECT CONCAT(
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_agent_run'), '|',
(SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_agent_run'
AND index_name = 'uk_aihr_agent_run'), '|',
(SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_agent_run'
AND index_name = 'idx_aihr_agent_run_user')
);
SQL
REMOTE
)" || fail "remote Agent audit schema check failed: $remote_ssh:$remote_db"
[[ "$agent_schema_contract" = "15|run_id|tenant_id,user_id,create_time" ]] \
|| fail "remote Agent audit schema is invalid: got ${agent_schema_contract:-missing}"
local missing_knowledge_category_columns
missing_knowledge_category_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
+1
View File
@@ -34,6 +34,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260722_broadcast_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql"
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
-e "select count(*) as tables_count from information_schema.tables where table_schema = 'ry-vue';"
+12 -1
View File
@@ -383,4 +383,15 @@ fi
broadcast_failed_publish_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_message' AND index_name='uk_aihr_broadcast_message_publish_request';")"
test "$broadcast_failed_publish_index" = '1|tenant_id,publish_request_key,published_by|1'
echo "PASS: AIHR legacy schema migrations are idempotent, including knowledge-space categories, practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, assistant capture, work-report idempotency, broadcast publish/withdraw audit with M1 targeting defaults and target-table contracts, v1 snapshots, and trusted broadcast question context"
agent_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql"
test -f "$agent_migration"
mysql "$DB" < "$agent_migration"
mysql "$DB" < "$agent_migration"
agent_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_agent_run';")"
test "$agent_columns" = 'id,run_id,tenant_id,client_key,user_id,conversation_id,context_version,intent,tool,status,source_type,duration_ms,error_code,result_ref,create_time'
agent_run_unique="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_agent_run' AND index_name='uk_aihr_agent_run';")"
test "$agent_run_unique" = '0|run_id'
agent_user_index="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_agent_run' AND index_name='idx_aihr_agent_run_user';")"
test "$agent_user_index" = 'tenant_id,user_id,create_time'
echo "PASS: AIHR legacy schema migrations are idempotent, including Agent run audit, knowledge-space categories, practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, assistant capture, work-report idempotency, broadcast publish/withdraw audit with M1 targeting defaults and target-table contracts, v1 snapshots, and trusted broadcast question context"