diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentAuditService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentAuditService.java new file mode 100644 index 00000000..2e2743e1 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentAuditService.java @@ -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); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java index ac164036..c483e431 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java @@ -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("-", ""); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentAuditServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentAuditServiceTest.java new file mode 100644 index 00000000..c8a0f0f4 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentAuditServiceTest.java @@ -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; + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java index c6745726..c5fbce91 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java @@ -55,6 +55,7 @@ class AihrAgentMediaTest { }, query, null, + null, null ); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java index bbfa9c99..ec86f052 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java @@ -111,6 +111,7 @@ class AihrAgentOrchestratorTest { }, query, actionService, + null, null ); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java index 6748a639..edb743df 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java @@ -72,7 +72,8 @@ class AihrAgentWebResearchTest { } }, null, - web + web, + null ); } diff --git a/backend/script/sql/aihr_knowledge_mysql8.sql b/backend/script/sql/aihr_knowledge_mysql8.sql index bbaa573a..a1f1a999 100644 --- a/backend/script/sql/aihr_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_knowledge_mysql8.sql @@ -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 最小运行审计'; diff --git a/backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql b/backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql new file mode 100644 index 00000000..e8fe75f8 --- /dev/null +++ b/backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql @@ -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 最小运行审计'; diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 3deef1b3..f29a9f34 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -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 数据库。 ## 迁移后只读核验 diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 3f21b2e6..36abc192 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -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 diff --git a/scripts/reset-dev-db.sh b/scripts/reset-dev-db.sh index f07d1fd3..d06809cf 100755 --- a/scripts/reset-dev-db.sh +++ b/scripts/reset-dev-db.sh @@ -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';" diff --git a/scripts/tests/aihr-schema-migrations.test.sh b/scripts/tests/aihr-schema-migrations.test.sh index 06d2da5e..2f9bc0a4 100755 --- a/scripts/tests/aihr-schema-migrations.test.sh +++ b/scripts/tests/aihr-schema-migrations.test.sh @@ -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"