Skip to content

Latest commit

 

History

History
50 lines (37 loc) · 13.4 KB

AGENTS.md

File metadata and controls

50 lines (37 loc) · 13.4 KB

agent-platform

Multi-module Java 21 / Spring Boot 3.4 AI agent platform: Ollama (local LLMs) is the only AI provider in active use, + in-memory H2 by default (PostgreSQL/pgvector opt-in). The other remote providers are present but switched off: groq, openrouter, cerebras and cloudflare-ai-gateway all carry enabled: false in application.yml, and Gemini has no YAML block at all (gemini.enabled=false by code default) — deferred decisions, not live dependencies. llm.default-provider is empty, so the highest-priority enabled provider (Ollama) is used, with a llm.failure-threshold/cooldown-duration circuit breaker for automatic failover.

Build & run

  • No Maven wrapper — use system mvn (Java 21 required).
  • From root: mvn clean install. All tests: mvn test.
  • Single module: mvn -pl <module> -am test — -am builds sibling deps in-reactor (required on a fresh clone; modules depend on each other).
  • Run the app: mvn -pl ui spring-boot:run, or launch com.agentplatform.ui.AgentPlatformApplication from an IDE. ui is the only bootable module (only it declares spring-boot-maven-plugin). No infrastructure is required to boot: the app defaults to in-memory H2 (jdbc:h2:mem:agentdb in application.yml). For PostgreSQL/pgvector run with SPRING_PROFILES_ACTIVE=postgres (after docker compose up -d).

Infra (docker compose)

  • postgres: pgvector/pgvector:pg16, db agentdb, agent/agentpassword on 5432. init.sql creates the vector extension plus the conversation/embedding tables (conversation_messages, documents, document_chunks) only on first container init. It no longer creates the obsolete memory table.
  • ollama on 11434 powers chat, custom/process, resume parsing, and the ai/status probe, with dynamically selected local models (ollama.chat-model, default llama3.2:3b). Gemini is disabled (gemini.enabled=false by code default) and has no YAML block, so there is no ${GEMINI_API_KEY:} placeholder to set and no key to commit. If Gemini is ever enabled, the key is read from GEMINI_API_KEY by GeminiProperties — never log or expose it.
  • Gotcha: no datasource overrides in main() anymore — the database is defined entirely in application.yml: H2 in-memory by default (spring.h2.console at /h2-console), and the second --- document activates a postgres profile (SPRING_PROFILES_ACTIVE=postgres, db agentdb, agent/agentpassword) that matches docker-compose + init.sql. Tables are created by ddl-auto: update. For local experiments the app also talks to a native ollama server on localhost:11434 (enabled-then-crashed Docker isn't required for it).

Architecture

  • Module dependency edges (verified from the poms): orchestrator → agent-core, logging, memory-service, tool-service; ui → orchestrator, logging; rag-service → agent-core, memory-service. Maven reactor order: agent-core → logging → memory-service → tool-service → orchestrator → ui → rag-service. logging and rag-service are real modules (LoggingContext/PiiSanitizer; TextChunker, document entities, PgVectorRagService), not empty placeholders — rag-service is simply dormant (rag.enabled=false). memory-service is real: ConversationStore interface + thread-safe InMemoryConversationStore (@Service, caps 60 msgs/conversation and 200 conversations, oldest evicted) plus PersistentConversationStore (JPA) and PgVectorConversationStore behind the same interface.
  • ui scans components, JPA repositories, and entities across the whole com.agentplatform root — new @Entity / @Repository / @Service anywhere is auto-discovered.
  • Package naming is com.agentplatform.<module> except tool-service, which uses com.agentplatform.tools.
  • Static web UI lives in ui/src/main/resources/static (plain HTML/JS: 6 HTML pages, 15 JS modules, 1 CSS file). There is no chat page, so nothing in the browser calls POST /api/v1/agent/chat — the backend endpoint is still live.
  • API (all under /api/v1): agent/chat, ai/status, resume/upload (multipart PDF or DOCX → LLM-extracted profile), jobs/search, jobs/match, jobs/{id}, candidate/{id}, jobs/advisor, resume/tailor{,/pdf,/docx}, applications/*, custom/status, custom/process (structured agent endpoint — AgentRequest{prompt, taskType, model} → AgentProcessResponse with per-taskType LangChain4j system prompts producing a StructuredAgentResult, plus executionTimeMs).
  • Chat persistence: POST /api/v1/agent/chat takes ChatRequest{query, conversationId?, model} and returns ChatResponse{response, conversationId} (id generated when missing → always echoed). The orchestrator AgentChatService.chatWithHistory(conversationId, systemPrompt, userMessage, model) loads the stored transcript into the LangChain4j ChatRequest, appends the new turn, saves both to ConversationStore (nothing persisted on failure), returns AgentChatResult{conversationId, response}. The old stateless chat(query) / chat(systemPrompt, query) overloads are kept for custom/process and backward compatibility. No page mirrors the thread in localStorage: agentplatform:conversationId / agentplatform:chatTranscript and app.js no longer exist, so a conversation id is caller-supplied only.
  • Dynamic Ollama model switching: OllamaChatModelFactory (agent-core/config) builds an OllamaChatModel per request from an optional model field — null/blank falls back to ollama.chat-model (llama3.2:3b, configurable via ollama.base-url / ollama.chat-model in application.yml; base URL default http://localhost:11434). Chat and custom/process route through it (no more hardcoded Gemini chat model); Gemini remains wired only as the GeminiChatModel bean, disabled. The custom page exposes a model <select id="modelSelector"> with the exact locally-pulled tags — llama3.2:3b (Llama 3.2 3B Fast Chat & Tools) and qwen2.5:1.5b (Qwen 2.5 1.5B Lightweight Code & Tools); free-text model names are not supported by the UI. Selection persists in localStorage key agentplatform:model with a #modelBadgeText badge beside it, and a stale saved value silently falls back to the first option. First inference on a freshly pulled model is slow (cold CPU load) — and if a model isn't pulled, chat returns a classified 503 saying so (ollama pull <model>).
  • Agent tools (no code-highlighting surface): tool-service (com.agentplatform.tools) provides @Tool beans — ImageTools.generateImage(prompt) (deterministic picsum URL, returns a raw <div class="tool-image-result">…<img…> HTML fragment), EmailTools.sendEmail(recipient, subject, body) (JavaMailSender bean from MailConfig, lazily creates a JavaMailSenderImpl with no host — sending without a configured SMTP just returns a failure string; @Value setter tools.email.from), WhatsAppTools.sendWhatsAppMessage(phone, message) (stub confirmation, phone \+?\d{7,15}). AgentChatService registers these on every model call via ToolSpecifications.toolSpecificationsFrom, executes tool calls in a bounded loop (MAX_TOOL_ROUNDS=6, DefaultToolExecutor, unknown-tool guard), and "draw an image"-style prompts are short-circuited before any LLM call by ImageTools.isImageRequest() (guarantees the <img> renders; prevents a model that never calls tools from producing no image). The image fragment is the only trusted raw-HTML the browser renders (whitelisted in the chat renderer); all other assistant text stays escaped. EmailTools/WhatsAppTools/MailConfig are compiled into the app but any real send is call-driven (no scheduled background mail). There is no chat page and no Highlight.js in the static UI, so there is currently no code-block highlighting surface (app.js enhanceCodeBlocks/copyTextToClipboard are gone).
  • Gemini diagnostics & health check: AgentChatService and ResumeProfileService classify failures via AiErrorClassifier (agent-core/ai) — quota (429/RESOURCE_EXHAUSTED), auth (401/403, never leaks the key), model unavailable (404/NOT_FOUND), network — so error messages say what actually happened (e.g. free-tier quota hit) instead of "check your API key". It is provider-aware (classify(error, provider)); chat/custom failures are labeled "Ollama". GET /api/v1/ai/status (AiStatusService + AiStatusController) returns AiStatusResponse{provider, configured, available, model, message, checkedAt}: cheap on the plain GET (never calls the provider), live one-shot probe only on ?probe=true (30s cap for Ollama, 8s for Gemini; 60s result cache). The provider defaults to Ollama and only switches to Google Gemini if gemini.enabled=true. Don't auto-probe the remote provider: the Gemini free tier is tiny (~20 content requests/day/model) and a status ping can exhaust it, which is the exact failure it explains.
  • Resume matching is fully deterministic (orchestrator/matching: JobMatchingService + EducationMatchingEngine, weighted score, tie-break comparator, zero LLM calls). Weights are configurable via agent.matching.* (JobMatchingConfig @ConfigurationProperties; defaults 0.40 skills | 0.20 role | 0.15 experience | 0.10 track | 0.10 location | 0.05 education). Recommendation bands reuse the existing RecommendationLevel enum; each match carries matchScore, strengths, concerns, educationScore.
  • POST /api/v1/jobs/match takes JobMatchRequestDto (candidateProfileId or inline profile; jobs optional else the deterministic search catalog, max 100; plus keywords/location/experience/employmentType/limit/minScore/careerTrack). It returns JobMatchResponseDto (candidateProfileId, candidateName, totalJobs, ranked matches, source, live, message). Missing stored profile → RFC 7807 404 (CandidateProfileNotFoundException).
  • resume/upload persists the extracted profile (CandidateProfilePersistenceService) and returns its id in the JSON body — {candidateId, profile, aiModelUsed, notice?}; there is no X-Candidate-Id header, and on persist failure it logs and still returns 200 with a null id. The Matches page reads the id from the upload response via localStorage keys agentplatform:candidateId / agentplatform:candidateName (matches.js will only auto-fire when a candidate id is stored; candidate id = DB id, e.g. 1 after the first upload).

Dependencies

  • LangChain4j versions are intentionally mixed: core 1.0.0, Google AI Gemini integration 1.0.0-beta5, and the Ollama integration 1.0.0-beta5 (all pinned in root dependencyManagement; the Gemini and Ollama modules have no 1.0.0 GA and their GA series pull a newer core). Don't "fix" them to a single version.

Testing

  • No full @SpringBootTest. Tests are @WebMvcTest slice tests with mocked services, or plain JUnit (real in-memory PDFBox PDFs). No DB or external LLM required — preserve this so mvn test stays hermetic and fast. Current totals: 1,304 Java tests (15 skipped Testcontainers PostgreSQL/pgvector tests) plus 24 JS tests in ui/src/test/js via node --test.
  • JPA repositories/entities are wired by ui/PersistenceConfig (@EnableJpaRepositories + @EntityScan over com.agentplatform), NOT by annotations on AgentPlatformApplication — putting them on the bootstrap class makes every @WebMvcTest fail to load (Spring Data registers a shared EntityManagerFactory the web slice can't satisfy).
  • The Gemini chat model has a 60-second read timeout (agent-core/GeminiConfig); it's a remote cloud API, not local inference — don't remove the timeout.
  • Resume fixtures are synthetic only: orchestrator/src/test/resources/fixtures/sample-resume.docx (fictional candidate) is generated by SampleResumeFixture and pinned by SampleResumeFixtureTest. The personal CV that used to sit at the repo root is gone — never commit real resumes, and keep the fixture's fictional data intact when editing SampleResumeFixture.LINES (regenerate + rerun the test).
  • Browser E2E scripts live in scripts/e2e/ (tracked, with a README) and import playwright, which is a declared root dependency — unused patchright was removed in Cleanup Batch 5. Never author scripts inside ui/target/; mvn clean deletes it. Evidence goes to os.tmpdir()/agent-platform-e2e, not the repo. npm run e2e:browser is the full 8-check regression; it needs the app on http://127.0.0.1:8080.
  • PersistentConversationStoreTest.tearDown can flake with TransientObjectException when its deleteAll()/flush() ordering lands badly. It is pre-existing and unrelated to application logic — re-run before investigating, and never silence it by weakening the test.

Conventions & gotchas

  • Errors go through GlobalExceptionHandler as RFC 7807 ProblemDetail (AgentChatException→503, IllegalArgumentException→400). Exception to the convention: JobMatchController returns a plain 500 string its own catch block.
  • Chat, custom/process, resume parsing, and ai/status?probe=true hit Ollama (local models, ollama.<base-url|chat-model>); Gemini is disabled and would only be reached if gemini.enabled=true. Job search/matching are fully deterministic (orchestrator/matching engines); MockJobSource is disabled by default and only used when it is the sole wired source.
  • No CI. One tracked root .gitignore (.env, build output, IDE files, runtime logs); no module-level .gitignore remains — ui/.gitignore and its siblings were removed in Batch 2 (620749c), which also stopped ignoring application.yml and committed the modernize hook scripts under .github/.