ADR-053: Build-Time Model Discovery for Chat API Tools

Status: Proposed Date: 2025-12-12 Context: Fix LLM hallucination of column names in searchRecords() tool Related: ADR-048 (iDempiere AI Hub Integration), ADR-009 (OpenAPI REST Client)

Context and Problem Statement

The Chat API's searchRecords() tool experiences LLM hallucinations when constructing OData filter expressions. The LLM invents column names like "Category eq 'Product'" instead of using actual database column names like "M_Product_Category_ID eq 103".

Root Cause:

Current Workaround (2025-12-12):

Decision Drivers

  1. Eliminate hallucinations - LLM must know actual column names
  2. Performance - Avoid runtime database queries for metadata
  3. Offline capability - Tools should work without live DB connection
  4. Standard formats - Use existing OpenAPI/REST standards
  5. Type safety - Generate validated types for tools
  6. Security - Respect iDempiere's security model (don't bypass REST API)

Considered Options

Option 1: Runtime Database Queries (Current Implementation)

Query AD_Table and AD_Column tables at runtime via RegistryToolLogic.listColumns().

Pros:

Cons:

Query iDempiere REST API at Maven build time to fetch OpenAPI schemas for all tables.

Pros:

Cons:

Option 3: Hybrid Approach

Use build-time discovery as baseline, fallback to runtime queries for missing/updated tables.

Pros:

Cons:

Use standard OData $metadata endpoint that returns complete EDMX/CSDL document with ALL service metadata.

Pros:

Cons:

OData EDMX Structure:

<?xml version="1.0" encoding="UTF-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
  <edmx:DataServices>
    <Schema Namespace="iDempiere" xmlns="http://docs.oasis-open.org/odata/ns/edm">

      <!-- Entity Types (Tables) -->
      <EntityType Name="M_Product">
        <Key>
          <PropertyRef Name="M_Product_ID"/>
        </Key>
        <Property Name="M_Product_ID" Type="Edm.Int32" Nullable="false"/>
        <Property Name="Value" Type="Edm.String" MaxLength="40"/>
        <Property Name="Name" Type="Edm.String" MaxLength="60"/>
        <Property Name="M_Product_Category_ID" Type="Edm.Int32"/>

        <!-- Navigation Properties (Foreign Keys) -->
        <NavigationProperty Name="ProductCategory"
                           Type="iDempiere.M_Product_Category"
                           Partner="Products"/>
      </EntityType>

      <EntityType Name="M_Product_Category">
        <Key>
          <PropertyRef Name="M_Product_Category_ID"/>
        </Key>
        <Property Name="M_Product_Category_ID" Type="Edm.Int32" Nullable="false"/>
        <Property Name="Value" Type="Edm.String"/>
        <Property Name="Name" Type="Edm.String"/>

        <NavigationProperty Name="Products"
                           Type="Collection(iDempiere.M_Product)"
                           Partner="ProductCategory"/>
      </EntityType>

      <!-- Entity Container (Collections) -->
      <EntityContainer Name="Container">
        <EntitySet Name="M_Products" EntityType="iDempiere.M_Product">
          <NavigationPropertyBinding Path="ProductCategory" Target="M_Product_Categories"/>
        </EntitySet>
        <EntitySet Name="M_Product_Categories" EntityType="iDempiere.M_Product_Category">
          <NavigationPropertyBinding Path="Products" Target="M_Products"/>
        </EntitySet>
      </EntityContainer>

    </Schema>
  </edmx:DataServices>
</edmx:Edmx>

Decision

UPDATED 2025-12-12: After analyzing overlap with ADR-008 (Application Dictionary Registry), we've identified a more powerful approach.

Selected Approach: Hybrid ADR-008 + ADR-053 Integration ⭐

Use ADR-008's RegistryToolLogic as the data source for build-time metadata generation instead of REST API.

Rationale:

  1. ADR-008 is already proven - RegistryToolLogic is in production (v1.23.0)
  2. Already integrated - ChatToolProvider uses registryLogic.listColumns() in the searchRecords fix
  3. More comprehensive - Includes relationships, patterns, translations, windows/tabs
  4. Simpler implementation - Reuse existing JDBC queries instead of REST API calls
  5. No REST API dependency - Direct database access at build time

Implementation Architecture

We will implement a Maven plugin goal that:

  1. Connect to iDempiere database via JDBC (same as RegistryToolLogic)
  2. Instantiate RegistryToolLogic to query Application Dictionary
  3. Extract comprehensive metadata:
    • listTables() - All tables with descriptions
    • describeTable(tableName) - Full table metadata + relationships
    • listColumns(tableName) - Column details with types, lengths, descriptions
    • Pattern detection (document, master-data, transaction-line)
    • Window/Tab usage information
    • Translation support (multi-language names/descriptions)
  4. Generate enhanced model-metadata.json with:
    • Tables, columns, types
    • Foreign key relationships
    • Pattern classifications
    • Window/Tab associations
    • Reference type mappings
  5. Embed in JAR resources at target/classes/model-metadata.json

Integration with ChatToolProvider:

See: docs/adr/053-build-time-model-discovery-ADR008-comparison.md for detailed comparison and integration strategy.

Implementation Architecture

UPDATED 2025-12-12: Using RegistryToolLogic (ADR-008) instead of REST API.

┌─────────────────────────────────────────────────────────────────┐
│ Maven Build Phase                                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. ModelDiscoveryMojo (custom Maven plugin)                    │
│     ├─ Connect to iDempiere database (JDBC)                     │
│     │  URL from ${idempiere.db.url} or config                   │
│     │                                                            │
│     ├─ Instantiate RegistryToolLogic (ADR-008)                  │
│     │  Uses existing production-tested code                     │
│     │                                                            │
│     ├─ Extract comprehensive metadata:                          │
│     │  ├─ registryLogic.listTables(null, false, null)           │
│     │  │  Returns: All active tables with descriptions          │
│     │  │                                                         │
│     │  └─ For each table:                                       │
│     │     ├─ registryLogic.describeTable(tableName)             │
│     │     │  Returns: Metadata + windows/tabs + relationships   │
│     │     │                                                      │
│     │     └─ registryLogic.listColumns(tableName)               │
│     │        Returns: Column names, types, refs, descriptions   │
│     │                                                            │
│     ├─ Detect patterns (document, master-data, transaction)     │
│     │  Based on column presence (DocStatus, Value, Parent_ID)   │
│     │                                                            │
│     ├─ Generate enhanced model-metadata.json                    │
│     │  Structure:                                               │
│     │  {                                                        │
│     │    "generatedAt": "2025-12-12T10:00:00Z",                 │
│     │    "idempiereVersion": "13.0.0",                          │
│     │    "tables": {                                            │
│     │      "M_Product": {                                       │
│     │        "id": 208,                                         │
│     │        "name": "Product",                                 │
│     │        "description": "Product, Service, Item",           │
│     │        "accessLevel": "Client+Organization",              │
│     │        "pattern": "master-data",                          │
│     │        "defaultWindowId": 140,                            │
│     │        "columns": {                                       │
│     │          "M_Product_ID": {                                │
│     │            "id": 3668,                                    │
│     │            "name": "Product",                             │
│     │            "type": "ID",                                  │
│     │            "referenceId": 13,                             │
│     │            "mandatory": true,                             │
│     │            "key": true                                    │
│     │          },                                               │
│     │          "M_Product_Category_ID": {                       │
│     │            "id": 3670,                                    │
│     │            "name": "Product Category",                    │
│     │            "type": "Table Direct",                        │
│     │            "referenceId": 19,                             │
│     │            "mandatory": true,                             │
│     │            "foreignTable": "M_Product_Category"           │
│     │          }                                                │
│     │        },                                                 │
│     │        "windows": [                                       │
│     │          {"id": 140, "name": "Product", "type": "M"}      │
│     │        ]                                                  │
│     │      }                                                    │
│     │    },                                                     │
│     │    "patterns": {                                          │
│     │      "document": ["C_Order", "C_Invoice", ...],           │
│     │      "master-data": ["M_Product", "C_BPartner", ...]      │
│     │    }                                                      │
│     │  }                                                        │
│     │                                                            │
│     └─ Write to target/classes/model-metadata.json              │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
                            │
                            ↓
┌─────────────────────────────────────────────────────────────────┐
│ Runtime - ChatToolProvider                                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  @Inject                                                        │
│  RegistryToolLogic registryLogic;  // For runtime fallback      │
│                                                                  │
│  private Map<String, TableMetadata> embeddedMetadata;           │
│                                                                  │
│  @PostConstruct                                                 │
│  void loadModelMetadata() {                                     │
│    InputStream is = getClass()                                  │
│      .getResourceAsStream("/model-metadata.json");              │
│    this.embeddedMetadata = parseMetadata(is);                   │
│    log.info("Loaded metadata for " + embeddedMetadata.size() + │
│                " tables");                                      │
│  }                                                              │
│                                                                  │
│  @Tool("Search records with OData filter. " +                   │
│        "Available columns for M_Product: " +                    │
│        "M_Product_ID, Value, Name, M_Product_Category_ID, ...")  │
│  String searchRecords(String tableName, String filter) {        │
│    // LLM gets column names directly in tool description!      │
│    // Embedded metadata used for validation                    │
│    return apiFactory.models().modelsTableNameGet(...);          │
│  }                                                              │
│                                                                  │
│  @Tool("Get columns for a table (with embedded metadata)")      │
│  String getTableColumns(String tableName) {                     │
│    // HYBRID APPROACH:                                          │
│    // 1. Try embedded metadata first (fast, offline)           │
│    if (embeddedMetadata.containsKey(tableName)) {              │
│      return toJson(embeddedMetadata.get(tableName));            │
│    }                                                            │
│    // 2. Fallback to runtime query (handles new tables)        │
│    return registryLogic.listColumns(tableName).toJson();        │
│  }                                                              │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Configuration

pom.xml

<plugin>
  <groupId>org.idempiere.cli</groupId>
  <artifactId>model-discovery-maven-plugin</artifactId>
  <version>${project.version}</version>
  <executions>
    <execution>
      <id>discover-models</id>
      <phase>generate-resources</phase>
      <goals>
        <goal>discover</goal>
      </goals>
      <configuration>
        <apiUrl>${idempiere.api.url}</apiUrl>
        <apiToken>${idempiere.api.token}</apiToken>
        <outputFile>src/main/resources/model-metadata.json</outputFile>
        <includeTables>
          <!-- Optional: only include specific tables -->
          <table>C_BPartner</table>
          <table>M_Product</table>
        </includeTables>
        <excludeTables>
          <!-- Optional: exclude system tables -->
          <table>AD_*</table>
        </excludeTables>
      </configuration>
    </execution>
  </executions>
</plugin>

Maven Properties

# In ~/.m2/settings.xml or build command
-Didempiere.api.url=http://localhost:8080/api/v1
-Didempiere.api.token=<your-token>

Implementation Steps

Phase 1: Basic Discovery (MVP)

  1. Create ModelDiscoveryMojo Maven plugin
  2. Implement table listing via /models
  3. Fetch YAML schemas via /models/{tableName}/yaml
  4. Generate model-metadata.json
  5. Load metadata in ChatToolProvider at startup
  6. Update @Tool descriptions with column hints

Phase 2: Enhanced Tool Generation

  1. Generate dynamic @Tool methods per table
  2. Create type-safe parameter classes
  3. Add validation based on schema constraints
  4. Generate OData filter builders

Phase 3: Incremental Updates

  1. Cache schemas with timestamps
  2. Only fetch changed tables (compare ETags)
  3. Merge runtime discoveries with build-time baseline

Consequences

Positive

Negative

Neutral

Alternatives Considered and Rejected

Direct Database Access at Build Time

Query AD_Table/AD_Column directly from Maven plugin.

Rejected because:

Hardcoded Schema Definitions

Manually write table/column definitions in Java.

Rejected because:

GraphQL Introspection

Use GraphQL introspection if available.

Rejected because:

References

OData EDMX Standard

CloudEmpiere Implementation

Standard iDempiere Implementation

Integration Documentation

Libraries

Notes

Implementation Example

// ModelDiscoveryMojo.java
@Mojo(name = "discover", defaultPhase = LifecyclePhase.GENERATE_RESOURCES)
public class ModelDiscoveryMojo extends AbstractMojo {

    @Parameter(property = "idempiere.api.url", required = true)
    private String apiUrl;

    @Parameter(defaultValue = "${project.build.directory}/generated-resources/model-metadata.json")
    private File outputFile;

    public void execute() throws MojoExecutionException {
        GeneratedOpenApiFactory apiFactory = createApiFactory();

        // 1. List all tables
        String tablesJson = apiFactory.models().modelsGet(...);
        List<String> tables = parseTableNames(tablesJson);

        Map<String, TableMetadata> metadata = new HashMap<>();

        // 2. Fetch schema for each table
        for (String table : tables) {
            String yaml = apiFactory.models().modelsTableNameYamlGet(table);
            TableMetadata tableMeta = parseYamlSchema(yaml);
            metadata.put(table, tableMeta);
        }

        // 3. Write JSON
        objectMapper.writeValue(outputFile, metadata);

        getLog().info("Discovered " + tables.size() + " tables");
    }
}

Success Criteria

  1. LLM can query any table without hallucinating column names
  2. No runtime database queries for metadata
  3. Build completes successfully with valid metadata
  4. JAR size increase < 2MB
  5. Tool descriptions include actual column names
  6. Works offline (no API required at runtime)

Approval Status: Pending Review Reviewers: @norbertbede Implementation Target: v1.65.0

Path: /docs/developers/architecture/idempiere-hub/053-build-time-model-discovery