Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,19 @@
import io.github.malonetalk.dto.datasource.PhysicalTableInfo;
import io.github.malonetalk.entity.Datasource;
import io.github.malonetalk.exception.BusinessException;
import io.github.malonetalk.utils.SemanticUtils;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
Expand Down Expand Up @@ -63,8 +64,10 @@ public List<PhysicalColumnInfo> getTableSchema(Datasource datasource, String tab
javax.sql.DataSource ds = dynamicDataSourceManager.getOrCreateDataSource(datasource);

try (Connection conn = ds.getConnection()) {
Set<String> primaryKeys = getPrimaryKeys(conn, tableName);
return getColumns(conn, tableName, primaryKeys);
List<String> primaryKeys = getPrimaryKeys(conn, tableName);
Map<String, List<String>> indexesByColumn =
getIndexesByColumn(conn, tableName, primaryKeys);
return getColumns(conn, tableName, primaryKeys, indexesByColumn);
} catch (SQLException e) {
log.error("Failed to read schema for table {}: {}", tableName, e.getMessage(), e);
throw BusinessException.of(
Expand Down Expand Up @@ -123,22 +126,66 @@ private List<PhysicalTableInfo> getTables(Connection conn) throws SQLException {
return tables;
}

private Set<String> getPrimaryKeys(Connection conn, String tableName) throws SQLException {
Set<String> pkColumns = new HashSet<>();
private List<String> getPrimaryKeys(Connection conn, String tableName) throws SQLException {
Map<Short, String> pkColumns = new TreeMap<>();
DatabaseMetaData metaData = conn.getMetaData();

try (ResultSet rs =
metaData.getPrimaryKeys(conn.getCatalog(), conn.getSchema(), tableName)) {
while (rs.next()) {
pkColumns.add(rs.getString("COLUMN_NAME"));
String columnName = rs.getString("COLUMN_NAME");
if (columnName != null) {
pkColumns.put(rs.getShort("KEY_SEQ"), normalizeColumnName(columnName));
}
}
}

return List.copyOf(pkColumns.values());
}

private Map<String, List<String>> getIndexesByColumn(
Connection conn, String tableName, List<String> primaryKeys) throws SQLException {
Map<String, IndexParts> indexes = new LinkedHashMap<>();
DatabaseMetaData metaData = conn.getMetaData();

try (ResultSet rs =
metaData.getIndexInfo(
conn.getCatalog(), conn.getSchema(), tableName, false, false)) {
while (rs.next()) {
String indexName = rs.getString("INDEX_NAME");
String columnName = rs.getString("COLUMN_NAME");
if (indexName == null || columnName == null) {
continue;
}
boolean unique = !rs.getBoolean("NON_UNIQUE");
short position = rs.getShort("ORDINAL_POSITION");
indexes.computeIfAbsent(indexName, key -> new IndexParts(indexName, unique))
.columns()
.put(position, columnName);
}
}

return pkColumns;
Map<String, List<String>> indexesByColumn = new LinkedHashMap<>();
for (IndexParts index : indexes.values()) {
if (!primaryKeys.isEmpty() && index.normalizedColumnNames().equals(primaryKeys)) {
continue;
}
String description = index.description();
for (String column : index.columns().values()) {
indexesByColumn
.computeIfAbsent(normalizeColumnName(column), key -> new ArrayList<>())
.add(description);
}
}
return indexesByColumn;
}

private List<PhysicalColumnInfo> getColumns(
Connection conn, String tableName, Set<String> primaryKeys) throws SQLException {
Connection conn,
String tableName,
List<String> primaryKeys,
Map<String, List<String>> indexesByColumn)
throws SQLException {
List<PhysicalColumnInfo> columns = new ArrayList<>();
DatabaseMetaData metaData = conn.getMetaData();

Expand All @@ -148,24 +195,52 @@ private List<PhysicalColumnInfo> getColumns(
String columnName = rs.getString("COLUMN_NAME");
String typeName = rs.getString("TYPE_NAME");
int columnSize = rs.getInt("COLUMN_SIZE");
int decimalDigits = rs.getInt("DECIMAL_DIGITS");
String nullableStr = rs.getString("IS_NULLABLE");
boolean nullable = "YES".equalsIgnoreCase(nullableStr);
String defaultValue = rs.getString("COLUMN_DEF");
String remarks = rs.getString("REMARKS");
boolean isPk = primaryKeys.contains(columnName);
String normalizedColumnName = normalizeColumnName(columnName);
boolean isPk = primaryKeys.contains(normalizedColumnName);

columns.add(
new PhysicalColumnInfo(
columnName,
typeName,
columnSize,
decimalDigits,
nullable,
defaultValue,
isPk,
remarks));
remarks,
indexesByColumn.getOrDefault(normalizedColumnName, List.of())));
}
}

return columns;
}

private static String normalizeColumnName(String columnName) {
return SemanticUtils.normalizeObjectName(
columnName, "Missing column name while reading schema.");
}

private record IndexParts(String name, boolean unique, Map<Short, String> columns) {

private IndexParts(String name, boolean unique) {
this(name, unique, new TreeMap<>());
}

private String description() {
return (unique ? "UNIQUE " : "")
+ name
+ "("
+ String.join(", ", columns.values())
+ ")";
}

private List<String> normalizedColumnNames() {
return columns.values().stream().map(SchemaReader::normalizeColumnName).toList();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,9 @@ public class GetTableSchemaTool implements MarkAgentTool {
name = "get_table_schema",
description =
"""
Get the schema information of the specified table, including column name, data \
type, whether it is primary key, whether it allows null, default value \
and column comments. Returns semantic-first merged column information \
(uses semantic layer if available, falls back to physical layer \
otherwise). This tool should be called to understand the table \
structure before generating SQL.\
Get synced semantic-layer schema information for the specified table, \
including column name, data type, primary key flag, index hints and column \
descriptions. Call this tool before generating SQL.\
""")
public ToolResultBlock getTableSchema(
@ToolParam(name = "table_name", description = "The table name to query schema for")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,8 @@ public class GetTablesTool implements MarkAgentTool {
name = "get_tables",
description =
"""
Get table information from the database, including table name, domain, \
description and relations. Returns semantic-first merged table \
information (uses semantic layer if available, falls back to physical \
layer otherwise).\
Get synced semantic-layer table information, including table name, domain, \
description and enabled relations.\
""")
public ToolResultBlock getTables(
@ToolParam(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@
*/
package io.github.malonetalk.convertor;

import io.github.malonetalk.common.SemanticConstants;
import io.github.malonetalk.dto.datasource.PhysicalColumnInfo;
import io.github.malonetalk.dto.datasource.PhysicalTableInfo;
import io.github.malonetalk.dto.prompt.ColumnPromptResponse;
import io.github.malonetalk.dto.prompt.TablePromptResponse;
import io.github.malonetalk.dto.prompt.TableRelationPromptResponse;
Expand All @@ -29,85 +26,40 @@
import io.github.malonetalk.service.semantic.enums.UsageLevelEnum;
import io.github.malonetalk.utils.SemanticUtils;
import java.util.List;
import java.util.Map;

/** 物理层/语义层 → Agent Prompt DTO 的统一转换器,集中管理所有面向 LLM 的 DTO 映射逻辑。 */
/** Converts synced semantic-layer snapshots into Agent-facing prompt DTOs. */
public final class PromptConverter {

private PromptConverter() {}

/** 将物理列信息与语义列信息合并,转换为面向 Agent 的列响应 DTO */
public static ColumnPromptResponse mapColumnPrompt(
PhysicalColumnInfo physicalColumn, Map<String, ColumnInfo> semanticByName) {
ColumnInfo semanticColumn =
semanticByName.get(
SemanticUtils.normalizeObjectName(
physicalColumn.columnName(),
"Missing physical column name for prompt conversion."));
// null = 没有语义列记录,视为纯物理列、纳入 prompt;
// 仅在确有语义记录且不可用时才跳过
if (semanticColumn != null
&& !SemanticAvailabilityHelper.isColumnAvailable(
semanticColumn, UsageLevelEnum.AI_PROMPT)) {
public static ColumnPromptResponse mapColumnPrompt(ColumnInfo column) {
if (!SemanticAvailabilityHelper.isColumnAvailable(column, UsageLevelEnum.AI_PROMPT)) {
return null;
}

String description =
SemanticUtils.firstNonBlank(
semanticColumn == null ? null : semanticColumn.getColumnDescription(),
physicalColumn.remarks());

StringBuilder typeBuilder = new StringBuilder(physicalColumn.typeName());
if (physicalColumn.columnSize() > 0) {
typeBuilder.append("(").append(physicalColumn.columnSize()).append(")");
}

return ColumnPromptResponse.builder()
.name(physicalColumn.columnName())
.type(typeBuilder.toString())
.primaryKey(physicalColumn.primaryKey())
.nullable(physicalColumn.nullable())
.defaultValue(SemanticUtils.trimToNull(physicalColumn.defaultValue()))
.description(description)
.name(column.getColumnName())
.type(SemanticUtils.trimToNull(column.getTypeName()))
.primaryKey(column.getPrimaryKey())
.description(
SemanticUtils.firstNonBlank(
column.getColumnDescription(),
column.getPhysicalColumnDescription()))
.indexInfo(SemanticUtils.trimToNull(column.getIndexInfo()))
.build();
}

/** 将物理表信息与语义表信息合并,转换为面向 Agent 的表响应 DTO */
public static TablePromptResponse mapTablePrompt(
PhysicalTableInfo physicalTable,
Map<String, TableInfo> semanticByName,
List<TableRelationPromptResponse> resolvedRelations) {
TableInfo semanticTable =
semanticByName.get(
SemanticUtils.normalizeObjectName(
physicalTable.tableName(),
"Missing physical table name for prompt conversion."));
// ponytail: null = 没有语义表记录,视为纯物理表、纳入 prompt;
// 仅在确有语义记录且不可用时才跳过
if (semanticTable != null
&& !SemanticAvailabilityHelper.isTableAvailable(
semanticTable, UsageLevelEnum.AI_PROMPT)) {
TableInfo table, List<TableRelationPromptResponse> resolvedRelations) {
if (!SemanticAvailabilityHelper.isTableAvailable(table, UsageLevelEnum.AI_PROMPT)) {
return null;
}

return TablePromptResponse.builder()
.name(physicalTable.tableName())
.domain(resolveDomain(semanticTable))
.description(resolveDescription(physicalTable, semanticTable))
.name(table.getTableName())
.domain(SemanticUtils.normalizeDomain(table.getDomain()))
.description(
SemanticUtils.firstNonBlank(
table.getTableDescription(), table.getPhysicalTableDescription()))
.relations(resolvedRelations)
.build();
}

private static String resolveDomain(TableInfo semanticTable) {
return semanticTable == null
? SemanticConstants.DEFAULT_DOMAIN
: SemanticUtils.normalizeDomain(semanticTable.getDomain());
}

private static String resolveDescription(
PhysicalTableInfo physicalTable, TableInfo semanticTable) {
return SemanticUtils.firstNonBlank(
semanticTable == null ? null : semanticTable.getTableDescription(),
physicalTable.remarks());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,53 @@
*/
package io.github.malonetalk.dto.datasource;

import java.util.List;
import java.util.Locale;

/** 物理数据源列信息,由 SchemaReader 从 JDBC 元数据读取 */
public record PhysicalColumnInfo(
String columnName,
String typeName,
int columnSize,
int decimalDigits,
boolean nullable,
String defaultValue,
boolean primaryKey,
String remarks) {
String remarks,
List<String> indexes) {

public String formattedTypeName() {
if (typeName == null || typeName.isBlank()) {
return null;
}
String trimmedTypeName = typeName.trim();
if (columnSize <= 0) {
return trimmedTypeName;
}
return switch (trimmedTypeName.toUpperCase(Locale.ROOT)) {
case "CHAR", "VARCHAR" -> trimmedTypeName + "(" + columnSize + ")";
case "DECIMAL", "NUMERIC" ->
trimmedTypeName
+ "("
+ columnSize
+ (decimalDigits > 0 ? "," + decimalDigits : "")
+ ")";
default -> trimmedTypeName;
};
}

public String formattedIndexInfo() {
if (indexes == null || indexes.isEmpty()) {
return null;
}
String indexInfo = String.join(", ", indexes).trim();
return indexInfo.isEmpty() ? null : indexInfo;
}

@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(columnName).append(" ").append(typeName);
if (columnSize > 0) {
sb.append("(").append(columnSize).append(")");
}
sb.append(columnName).append(" ").append(formattedTypeName());
if (primaryKey) {
sb.append(" PRIMARY KEY");
}
Expand All @@ -46,6 +76,10 @@ public String toString() {
if (remarks != null && !remarks.isEmpty()) {
sb.append(" COMMENT '").append(remarks).append("'");
}
String indexInfo = formattedIndexInfo();
if (indexInfo != null) {
sb.append(" INDEX ").append(indexInfo);
}
return sb.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,4 @@
/** Agent-facing DTO for LLM prompt formatting. */
@Builder
public record ColumnPromptResponse(
String name,
String type,
Boolean primaryKey,
Boolean nullable,
String defaultValue,
String description) {}
String name, String type, Boolean primaryKey, String description, String indexInfo) {}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class ColumnInfo {
private String physicalColumnDescription;
private String typeName;
private Boolean primaryKey;
private String indexInfo;
private String columnDescription;
private Boolean isVisible;
private Boolean physicalStatus;
Expand Down
Loading
Loading