feat(trace): add session run isolation schema

This commit is contained in:
zhuyongxin
2026-07-10 17:47:56 +08:00
parent 841437fa06
commit 52bf0302c6
21 changed files with 1718 additions and 1 deletions
@@ -15,6 +15,7 @@ import java.time.LocalDateTime;
@Entity
@Table(name = "agent_step", indexes = {
@Index(name = "idx_session_step", columnList = "session_id, step_index"),
@Index(name = "idx_agent_step_run_step", columnList = "run_id, step_index"),
@Index(name = "idx_agent_name", columnList = "agent_name")
})
@Data
@@ -30,6 +31,9 @@ public class AgentStep {
@Column(name = "session_id", nullable = false, length = 64)
private String sessionId;
@Column(name = "run_id", length = 64)
private String runId;
@Column(name = "step_index", nullable = false)
private Integer stepIndex;
@@ -0,0 +1,64 @@
package com.superbiz.agent.domain.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Chat session metadata entity.
* Full message history remains in Redis SessionContext.
*/
@Entity
@Table(name = "chat_session", indexes = {
@Index(name = "idx_chat_session_last_active", columnList = "last_active_at"),
@Index(name = "idx_chat_session_status", columnList = "status"),
@Index(name = "idx_chat_session_expires_at", columnList = "expires_at")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatSession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "session_id", unique = true, nullable = false, length = 64)
private String sessionId;
@Column(name = "status", length = 16)
private String status = "ACTIVE";
@Column(name = "message_pair_count")
private Integer messagePairCount = 0;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "last_active_at")
private LocalDateTime lastActiveAt;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
if (lastActiveAt == null) {
lastActiveAt = now;
}
if (status == null) {
status = "ACTIVE";
}
if (messagePairCount == null) {
messagePairCount = 0;
}
}
}
@@ -0,0 +1,91 @@
package com.superbiz.agent.domain.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.LocalDateTime;
/**
* One diagnosis execution run.
*/
@Entity
@Table(name = "diagnosis_run", indexes = {
@Index(name = "idx_diagnosis_run_session_created", columnList = "session_id, created_at, id"),
@Index(name = "idx_diagnosis_run_session_run", columnList = "session_id, run_id"),
@Index(name = "idx_diagnosis_run_status", columnList = "status"),
@Index(name = "idx_diagnosis_run_agent_flow", columnList = "agent_flow")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DiagnosisRun {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "run_id", unique = true, nullable = false, length = 64)
private String runId;
@Column(name = "session_id", nullable = false, length = 64)
private String sessionId;
@Column(name = "query", nullable = false, columnDefinition = "TEXT")
private String query;
@Column(name = "status", length = 16)
private String status = "PENDING";
@Column(name = "agent_flow", length = 32)
private String agentFlow;
@Column(name = "answer", columnDefinition = "LONGTEXT")
private String answer;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "self_evaluation", columnDefinition = "JSON")
private String selfEvaluation;
@Column(name = "feedback", length = 16)
private String feedback;
@Column(name = "total_duration_ms")
private Integer totalDurationMs;
@Column(name = "total_token_count")
private Integer totalTokenCount;
@Column(name = "step_count")
private Integer stepCount;
@Column(name = "tool_call_count")
private Integer toolCallCount;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
if (status == null) {
status = "PENDING";
}
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -17,6 +17,7 @@ import java.time.LocalDateTime;
@Entity
@Table(name = "tool_invocation", indexes = {
@Index(name = "idx_session_id", columnList = "session_id"),
@Index(name = "idx_tool_invocation_run_id", columnList = "run_id, id"),
@Index(name = "idx_tool_name", columnList = "tool_name"),
@Index(name = "idx_retrieval_layer", columnList = "retrieval_layer")
})
@@ -33,6 +34,9 @@ public class ToolInvocation {
@Column(name = "session_id", nullable = false, length = 64)
private String sessionId;
@Column(name = "run_id", length = 64)
private String runId;
@Column(name = "step_id")
private Long stepId;
@@ -22,8 +22,18 @@ public interface AgentStepRepository extends JpaRepository<AgentStep, Long> {
*/
List<AgentStep> findBySessionId(String sessionId);
/**
* 根据运行ID查询所有步骤(按步骤号排序)。
*/
List<AgentStep> findByRunIdOrderByStepIndex(String runId);
/**
* 统计某个会话的步骤数
*/
int countBySessionId(String sessionId);
/**
* 统计某个运行的步骤数。
*/
int countByRunId(String runId);
}
@@ -0,0 +1,14 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.entity.ChatSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface ChatSessionRepository extends JpaRepository<ChatSession, Long> {
Optional<ChatSession> findBySessionId(String sessionId);
}
@@ -0,0 +1,21 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.entity.DiagnosisRun;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface DiagnosisRunRepository extends JpaRepository<DiagnosisRun, Long> {
Optional<DiagnosisRun> findByRunId(String runId);
Optional<DiagnosisRun> findBySessionIdAndRunId(String sessionId, String runId);
Optional<DiagnosisRun> findFirstBySessionIdOrderByCreatedAtDescIdDesc(String sessionId);
List<DiagnosisRun> findBySessionIdOrderByCreatedAtDescIdDesc(String sessionId);
}
@@ -22,11 +22,21 @@ public interface ToolInvocationRepository extends JpaRepository<ToolInvocation,
*/
List<ToolInvocation> findBySessionIdOrderByIdAsc(String sessionId);
/**
* 根据运行ID按创建顺序查询所有工具调用
*/
List<ToolInvocation> findByRunIdOrderByIdAsc(String runId);
/**
* 根据会话ID统计真实工具调用次数
*/
long countBySessionId(String sessionId);
/**
* 根据运行ID统计真实工具调用次数
*/
long countByRunId(String runId);
/**
* 根据工具名查询所有调用
*/
@@ -0,0 +1,111 @@
-- V011: split conversation metadata from diagnosis execution runs.
CREATE TABLE chat_session (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
session_id VARCHAR(64) UNIQUE NOT NULL COMMENT 'Chat session id / conversation context id',
status VARCHAR(16) DEFAULT 'ACTIVE' COMMENT 'ACTIVE/EXPIRED/CLOSED',
message_pair_count INT DEFAULT 0 COMMENT 'Cached Redis message pair count snapshot',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_active_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME COMMENT 'Directory metadata only; Redis message history may expire independently',
INDEX idx_chat_session_last_active (last_active_at),
INDEX idx_chat_session_status (status),
INDEX idx_chat_session_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Chat session metadata table';
CREATE TABLE diagnosis_run (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
run_id VARCHAR(64) UNIQUE NOT NULL COMMENT 'Diagnosis run id, format run-uuid',
session_id VARCHAR(64) NOT NULL COMMENT 'Owner chat_session.session_id',
query TEXT NOT NULL COMMENT 'User question or AIOps alert summary for this run',
status VARCHAR(16) DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCESS/FAILED',
agent_flow VARCHAR(32) COMMENT 'CHAT / AI_OPS',
answer LONGTEXT COMMENT 'Final answer/report for this run',
self_evaluation JSON COMMENT 'Run-scoped self evaluation payload',
feedback VARCHAR(16) COMMENT 'User feedback for this run',
total_duration_ms INT COMMENT 'Run duration in milliseconds',
total_token_count INT COMMENT 'Run token count',
step_count INT COMMENT 'Run agent step count',
tool_call_count INT COMMENT 'Run tool invocation count',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_diagnosis_run_session_created (session_id, created_at, id),
INDEX idx_diagnosis_run_session_run (session_id, run_id),
INDEX idx_diagnosis_run_status (status),
INDEX idx_diagnosis_run_agent_flow (agent_flow)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Diagnosis execution run table';
ALTER TABLE agent_step
ADD COLUMN run_id VARCHAR(64) NULL COMMENT '关联 diagnosis_run.run_id' AFTER session_id,
ADD INDEX idx_agent_step_run_step (run_id, step_index);
ALTER TABLE tool_invocation
ADD COLUMN run_id VARCHAR(64) NULL COMMENT '关联 diagnosis_run.run_id' AFTER session_id,
ADD INDEX idx_tool_invocation_run_id (run_id, id);
INSERT INTO chat_session (
session_id,
status,
message_pair_count,
created_at,
last_active_at,
expires_at
)
SELECT
ds.session_id,
'ACTIVE',
0,
ds.created_at,
COALESCE(ds.updated_at, ds.created_at),
NULL
FROM diagnosis_session ds;
INSERT INTO diagnosis_run (
run_id,
session_id,
query,
status,
agent_flow,
answer,
self_evaluation,
feedback,
total_duration_ms,
total_token_count,
step_count,
tool_call_count,
created_at,
updated_at
)
SELECT
CONCAT('run-', UUID()),
ds.session_id,
ds.query,
ds.status,
ds.agent_flow,
ds.answer,
ds.self_evaluation,
ds.feedback,
ds.total_duration_ms,
ds.total_token_count,
ds.step_count,
ds.tool_call_count,
ds.created_at,
ds.updated_at
FROM diagnosis_session ds;
UPDATE agent_step ast
JOIN diagnosis_run dr ON dr.session_id = ast.session_id
SET ast.run_id = dr.run_id
WHERE ast.run_id IS NULL;
UPDATE tool_invocation ti
JOIN diagnosis_run dr ON dr.session_id = ti.session_id
SET ti.run_id = dr.run_id
WHERE ti.run_id IS NULL;
@@ -0,0 +1,112 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.entity.AgentStep;
import com.superbiz.agent.domain.entity.ChatSession;
import com.superbiz.agent.domain.entity.DiagnosisRun;
import com.superbiz.agent.domain.entity.ToolInvocation;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.show-sql=true"
})
class DiagnosisRunRepositoryTest {
@Autowired
private ChatSessionRepository chatSessionRepository;
@Autowired
private DiagnosisRunRepository diagnosisRunRepository;
@Autowired
private AgentStepRepository agentStepRepository;
@Autowired
private ToolInvocationRepository toolInvocationRepository;
@Test
void saveAndFindLatestRunBySessionId() {
String sessionId = "test-session-" + UUID.randomUUID();
DiagnosisRun firstRun = saveRun(sessionId, "run-" + UUID.randomUUID(), "first question");
DiagnosisRun secondRun = saveRun(sessionId, "run-" + UUID.randomUUID(), "second question");
DiagnosisRun latest = diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc(sessionId)
.orElseThrow();
assertEquals(secondRun.getRunId(), latest.getRunId());
assertEquals(firstRun.getRunId(), diagnosisRunRepository.findByRunId(firstRun.getRunId()).orElseThrow().getRunId());
assertTrue(diagnosisRunRepository.findBySessionIdAndRunId(sessionId, secondRun.getRunId()).isPresent());
assertEquals(2, diagnosisRunRepository.findBySessionIdOrderByCreatedAtDescIdDesc(sessionId).size());
}
@Test
void stepAndToolCanBeQueriedByRunId() {
String sessionId = "test-session-" + UUID.randomUUID();
String runId = "run-" + UUID.randomUUID();
saveRun(sessionId, runId, "run scoped trace");
agentStepRepository.save(AgentStep.builder()
.sessionId(sessionId)
.runId(runId)
.stepIndex(1)
.agentName("executor")
.hasToolCall(true)
.build());
agentStepRepository.save(AgentStep.builder()
.sessionId(sessionId)
.runId(runId)
.stepIndex(0)
.agentName("planner")
.hasToolCall(false)
.build());
toolInvocationRepository.save(ToolInvocation.builder()
.sessionId(sessionId)
.runId(runId)
.toolName("lookup_knowledge")
.inputParams("{\"query\":\"payment timeout\"}")
.success(true)
.build());
List<AgentStep> steps = agentStepRepository.findByRunIdOrderByStepIndex(runId);
List<ToolInvocation> tools = toolInvocationRepository.findByRunIdOrderByIdAsc(runId);
assertEquals(2, steps.size());
assertEquals("planner", steps.get(0).getAgentName());
assertEquals(1, tools.size());
assertEquals(runId, tools.get(0).getRunId());
assertEquals(2, agentStepRepository.countByRunId(runId));
assertEquals(1, toolInvocationRepository.countByRunId(runId));
}
private DiagnosisRun saveRun(String sessionId, String runId, String query) {
chatSessionRepository.findBySessionId(sessionId)
.orElseGet(() -> chatSessionRepository.save(ChatSession.builder()
.sessionId(sessionId)
.status("ACTIVE")
.messagePairCount(0)
.build()));
return diagnosisRunRepository.save(DiagnosisRun.builder()
.sessionId(sessionId)
.runId(runId)
.query(query)
.status("SUCCESS")
.agentFlow("CHAT")
.answer("answer for " + query)
.build());
}
}