{"id":48398450,"url":"https://github.com/embabel/embabel-air","last_synced_at":"2026-04-06T01:37:13.979Z","repository":{"id":339530717,"uuid":"1135354749","full_name":"embabel/embabel-air","owner":"embabel","description":"Airline chatbot demo for Embabel","archived":false,"fork":false,"pushed_at":"2026-03-03T03:16:25.000Z","size":2670,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-03T07:32:20.416Z","etag":null,"topics":["agents","chatbot","embabel","java","spring"],"latest_commit_sha":null,"homepage":"","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/embabel.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-01-16T01:33:06.000Z","updated_at":"2026-03-03T03:16:33.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/embabel/embabel-air","commit_stats":null,"previous_names":["embabel/embabel-air"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/embabel/embabel-air","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/embabel%2Fembabel-air","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/embabel%2Fembabel-air/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/embabel%2Fembabel-air/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/embabel%2Fembabel-air/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/embabel","download_url":"https://codeload.github.com/embabel/embabel-air/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/embabel%2Fembabel-air/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31456663,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-05T21:22:52.476Z","status":"ssl_error","status_checked_at":"2026-04-05T21:22:51.943Z","response_time":75,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["agents","chatbot","embabel","java","spring"],"created_at":"2026-04-06T01:37:12.562Z","updated_at":"2026-04-06T01:37:13.968Z","avatar_url":"https://github.com/embabel.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"![Java](https://img.shields.io/badge/java-%23ED8B00.svg?style=for-the-badge\u0026logo=openjdk\u0026logoColor=white)\n![Spring](https://img.shields.io/badge/spring-%236DB33F.svg?style=for-the-badge\u0026logo=spring\u0026logoColor=white)\n![Vaadin](https://img.shields.io/badge/Vaadin-00B4F0?style=for-the-badge\u0026logo=Vaadin\u0026logoColor=white)\n![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge\u0026logo=postgresql\u0026logoColor=white)\n\n\u003cimg align=\"left\" src=\"src/main/resources/META-INF/resources/images/embabel-air.jpg\" width=\"180\"\u003e\n\n\u0026nbsp;\u0026nbsp;\u0026nbsp;\u0026nbsp;\n\n\u0026nbsp;\u0026nbsp;\u0026nbsp;\u0026nbsp;\n\n# Embabel Air\n\nAI-powered airline assistant with chat interface built on [Embabel Agent](https://github.com/embabel/embabel-agent).\n\n## Layering GenAI on Existing Spring Applications\n\nThis application demonstrates how to add generative AI capabilities to an **existing Spring/Spring Data/JPA backend** using the Embabel Agent framework. Rather than rewriting your application, you layer AI on top:\n\n- **Existing domain model** - JPA entities (`Customer`, `Reservation`, `Flight`) remain unchanged\n- **Existing persistence** - Spring Data repositories continue to manage data access\n- **Existing business logic** - Services and transactions work as before\n- **AI layer added on top** - Entity Views expose your domain to LLMs with safe, transactional access\n\nThis approach aligns with the [DICE](https://medium.com/@springrod/context-engineering-needs-domain-understanding-b4387e8e4bf8) philosophy: AI agents need deep domain understanding to be effective. By connecting AI to your real domain model rather than building a separate abstraction, the LLM gains access to the same rich semantics your application already has.\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                    AI Layer (New)                           │\n│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │\n│  │ ChatActions │  │ EntityViews │  │ RAG (PgVectorStore) │  │\n│  │ (States)    │  │ (Tools)     │  │ (Policy Search)     │  │\n│  └─────────────┘  └─────────────┘  └─────────────────────┘  │\n├─────────────────────────────────────────────────────────────┤\n│                 Existing Spring Backend                     │\n│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │\n│  │ JPA Entities│  │ Spring Data │  │ Business Services   │  │\n│  │ (Domain)    │  │ Repositories│  │ (Transactions)      │  │\n│  └─────────────┘  └─────────────┘  └─────────────────────┘  │\n├─────────────────────────────────────────────────────────────┤\n│                    PostgreSQL + pgvector                    │\n└─────────────────────────────────────────────────────────────┘\n```\n\n## Prerequisites\n\n- Java 21\n- Docker (for PostgreSQL)\n- Maven\n\n## Database Setup\n\nThe application uses PostgreSQL with pgvector for persistence.\n\n### Start PostgreSQL\n\n```bash\ndocker compose up -d\n```\n\nThis starts a PostgreSQL 17 container with pgvector extension on port 5432.\n\n### Reset Database\n\nTo completely reset the database (wipe all data and re-run migrations):\n\n```bash\ndocker compose down -v \u0026\u0026 docker compose up -d\n```\n\nThe `-v` flag removes the Docker volume, deleting all data. On next startup, Flyway will re-run all migrations and the\ndev data seeder will recreate demo users and flights.\n\n### Database Configuration\n\nDefault connection settings in `application.yml`:\n\n```yaml\nspring:\n  datasource:\n    url: jdbc:postgresql://localhost:5432/embabel_air\n    username: embabel\n    password: embabel\n```\n\n## Database Migrations\n\nFlyway manages database schema migrations automatically on startup.\n\nMigrations are in `src/main/resources/db/migration/` with naming convention `V{version}__{description}.sql`.\n\n### Generate DDL\n\nTo regenerate DDL from JPA entities:\n\n```bash\nmvn test -Dtest=GenerateDdlTest\n```\n\nThis outputs to `src/main/resources/db/schema.sql`. Copy relevant changes to a new migration file.\n\n## Running the Application\n\n### Quick Start\n\n1. Start the database:\n   ```bash\n   docker compose up -d\n   ```\n\n2. Run the application:\n   ```bash\n   mvn spring-boot:run\n   ```\n\n3. Open http://localhost:8747 in your browser\n\n### Default Port\n\nThe application runs on port **8747** by default (configured in `application.yml`).\n\n### Demo Users\n\nThe dev data seeder creates demo users with different loyalty tiers:\n\n| Username | Status | Description |\n|----------|--------|-------------|\n| alex.novice | New | New customer, no flights yet |\n| sam.bronze | Bronze | Some travel history |\n| jamie.silver | Silver | Regular traveler |\n| taylor.gold | Gold | Frequent flyer |\n| morgan.platinum | Platinum | Elite traveler |\n\nAll demo users use password: `password`\n\n## Running Tests\n\n```bash\nmvn test\n```\n\nIntegration tests use Testcontainers to spin up a PostgreSQL instance automatically.\n\n## Entity Views: Solving the Disconnected Data Problem\n\nWhen building AI agents that interact with JPA entities, you face a fundamental challenge: **LLM calls are slow** (seconds), but **JPA transactions should be fast** (milliseconds). You can't hold a database transaction open while waiting for an LLM response.\n\n### The Problem\n\n```\n┌──────────────────────────────────────────────────────────────────┐\n│                    Traditional Approach (Broken)                  │\n├──────────────────────────────────────────────────────────────────┤\n│  1. Begin transaction                                            │\n│  2. Load Customer entity                                         │\n│  3. Call LLM with customer data...                               │\n│     ┌─────────────────────────────┐                              │\n│     │  🕐 2-5 seconds waiting     │  ← Transaction held open!    │\n│     │     for LLM response        │  ← Lazy collections fail     │\n│     └─────────────────────────────┘  ← Connection pool exhausted │\n│  4. LLM calls tool: customer.getReservations()                   │\n│     💥 LazyInitializationException - session closed!             │\n└──────────────────────────────────────────────────────────────────┘\n```\n\n### The Solution: Entity Views\n\nEntity Views provide a clean separation between your domain model and AI access:\n\n```java\n@LlmView\npublic interface CustomerView extends EntityView\u003cCustomer\u003e {\n\n    @LlmTool(description = \"Get customer's flight reservations\")\n    default List\u003cReservationView\u003e getReservations() {\n        return getEntity().getReservations().stream()\n            .map(r -\u003e entityViewService.viewOf(r))\n            .toList();\n    }\n}\n```\n\n**How it works:**\n\n1. **Views are interfaces** - They wrap entities without modifying them\n2. **Each tool call opens a fresh transaction** - The framework reloads the entity\n3. **Lazy loading works** - Collections are accessed within the transaction\n4. **No long-held connections** - Transactions are milliseconds, not seconds\n\n```\n┌──────────────────────────────────────────────────────────────────┐\n│                    Entity View Approach (Works)                   │\n├──────────────────────────────────────────────────────────────────┤\n│  1. Create CustomerView (no transaction yet)                     │\n│  2. Add view to LLM context (just metadata)                      │\n│  3. Call LLM...                                                  │\n│     ┌─────────────────────────────┐                              │\n│     │  🕐 2-5 seconds waiting     │  ← No transaction held!      │\n│     └─────────────────────────────┘                              │\n│  4. LLM calls tool: getReservations()                            │\n│     ┌─────────────────────────────┐                              │\n│     │  Begin transaction          │                              │\n│     │  Reload Customer by ID      │  ← Fresh session             │\n│     │  Access lazy collections    │  ← Works!                    │\n│     │  Commit transaction         │  ← Fast, ~10ms               │\n│     └─────────────────────────────┘                              │\n│  5. Return results to LLM                                        │\n└──────────────────────────────────────────────────────────────────┘\n```\n\n### Key Benefits\n\n| Aspect | Without Entity Views | With Entity Views |\n|--------|---------------------|-------------------|\n| Transaction duration | Seconds (LLM wait) | Milliseconds |\n| Lazy loading | Fails | Works |\n| Connection pool | Exhausted | Healthy |\n| Domain model | Modified for AI | Unchanged |\n| Tool safety | Manual | Automatic |\n\n### Usage in This Application\n\n```java\n// In ChatActions.respond()\nvar assistantMessage = context.ai()\n    .withReference(entityViewService.viewOf(customer))  // Customer context + tools\n    .withTool(Tool.replanAndAdd(\n        entityViewService.finderFor(Reservation.class), // Finder tool\n        ManageReservationState::new                     // State with ReservationView\n    ))\n    .respondWithSystemPrompt(conversation, ...);\n```\n\nThe `EntityViewService`:\n- **Auto-discovers** `@LlmView` interfaces at startup\n- **Creates dynamic proxies** that reload entities per tool call\n- **Generates finder tools** (`find_reservation`) that return views\n- **Handles transactions** transparently\n\nSee [`src/main/java/com/embabel/springdata/README.md`](src/main/java/com/embabel/springdata/README.md) for the complete Entity View documentation.\n\n## Agent State Machine\n\nThe chat agent uses a state machine pattern built on the Embabel Agent framework. States control the conversation flow and determine which tools and prompts are available.\n\n### States\n\n| State | Description |\n|-------|-------------|\n| `ChitchatState` | Default state for general conversation. Can search policies and find reservations. |\n| `ManageReservationState` | Focused state for managing a specific reservation. Has access to reservation-specific tools. |\n| `EscalationState` | Terminal state when the customer requests human assistance. |\n\n### State Diagram\n\n```mermaid\nstateDiagram-v2\n    [*] --\u003e ChitchatState : greetCustomer\n\n    ChitchatState --\u003e ChitchatState : respond (normal)\n    ChitchatState --\u003e ManageReservationState : find_reservation tool\n    ChitchatState --\u003e EscalationState : escalate tool\n\n    ManageReservationState --\u003e ManageReservationState : respond (on-topic)\n    ManageReservationState --\u003e ChitchatState : triage (off-topic)\n    ManageReservationState --\u003e EscalationState : escalate tool\n\n    EscalationState --\u003e [*] : handoff to human\n```\n\n### Tool-Triggered Transitions\n\nState transitions can be triggered by LLM tool calls using `Tool.replanAndAdd`. This pattern allows the LLM to dynamically navigate the state machine based on user intent.\n\n```java\n// When the LLM calls find_reservation, transition to ManageReservationState\nTool.replanAndAdd(\n    entityViewService.finderFor(Reservation.class),\n    ManageReservationState::new  // Creates state with the found reservation\n)\n```\n\n**How it works:**\n\n1. The LLM is given a tool (e.g., `find_reservation`, `escalate`)\n2. When the LLM calls the tool, `Tool.replanAndAdd` wraps the result\n3. The wrapper creates a new state object from the tool's artifact\n4. The framework interrupts the current action and replans with the new state\n5. The new state's actions become available\n\n**Important:** The tool must return `Tool.Result.withArtifact(content, artifact)` for the transition to trigger. Plain `Tool.Result.text()` has no artifact and won't trigger replanning.\n\n### SubState Triage Pattern\n\nSpecialized states like `ManageReservationState` implement `SubState`, which includes automatic triage to detect when the user goes off-topic:\n\n```java\ninterface SubState extends AirState {\n    String purpose();  // Describes what this state handles\n\n    @Action(pre = \"shouldRespond\", canRerun = true)\n    default AirState triage(Conversation conversation, ...) {\n        // Ask LLM: \"Are we still on topic with purpose X?\"\n        var onTopic = context.ai()\n            .creating(OnTopic.class)\n            .fromMessages(...);\n\n        if (onTopic.isOnTopic()) {\n            return respond(...);  // Delegate to state-specific response\n        }\n        return new ChitchatState();  // Route back to general chat\n    }\n}\n```\n\nThis ensures users can naturally switch topics without getting stuck in a specialized flow.\n\n### Condition-Based Actions\n\nActions use the `@Condition` annotation to determine when they can fire:\n\n```java\n@Condition\nstatic boolean shouldRespond(Conversation conversation) {\n    return conversation.lastMessageIfBeFromUser() != null;\n}\n\n@Action(pre = \"shouldRespond\", canRerun = true)\nAirState respond(...) { ... }\n```\n\nThe `shouldRespond` condition checks if the last message is from the user (meaning we need to respond). This allows actions to fire based on conversation state rather than just trigger events.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fembabel%2Fembabel-air","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fembabel%2Fembabel-air","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fembabel%2Fembabel-air/lists"}