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.
- 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—-ambuilds sibling deps in-reactor (required on a fresh clone; modules depend on each other). - Run the app:
mvn -pl ui spring-boot:run, or launchcom.agentplatform.ui.AgentPlatformApplicationfrom an IDE.uiis the only bootable module (only it declaresspring-boot-maven-plugin). No infrastructure is required to boot: the app defaults to in-memory H2 (jdbc:h2:mem:agentdbinapplication.yml). For PostgreSQL/pgvector run withSPRING_PROFILES_ACTIVE=postgres(afterdocker compose up -d).
- postgres:
pgvector/pgvector:pg16, dbagentdb,agent/agentpasswordon 5432.init.sqlcreates thevectorextension plus the conversation/embedding tables (conversation_messages,documents,document_chunks) only on first container init. It no longer creates the obsoletememorytable. - ollama on 11434 powers chat, custom/process, resume parsing, and the
ai/statusprobe, with dynamically selected local models (ollama.chat-model, defaultllama3.2:3b). Gemini is disabled (gemini.enabled=falseby 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 fromGEMINI_API_KEYbyGeminiProperties— never log or expose it. - Gotcha: no datasource overrides in
main()anymore — the database is defined entirely inapplication.yml: H2 in-memory by default (spring.h2.consoleat/h2-console), and the second---document activates apostgresprofile (SPRING_PROFILES_ACTIVE=postgres, dbagentdb,agent/agentpassword) that matches docker-compose +init.sql. Tables are created byddl-auto: update. For local experiments the app also talks to a nativeollamaserver onlocalhost:11434(enabled-then-crashed Docker isn't required for it).
- 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.loggingandrag-serviceare real modules (LoggingContext/PiiSanitizer;TextChunker, document entities,PgVectorRagService), not empty placeholders —rag-serviceis simply dormant (rag.enabled=false).memory-serviceis real:ConversationStoreinterface + thread-safeInMemoryConversationStore(@Service, caps 60 msgs/conversation and 200 conversations, oldest evicted) plusPersistentConversationStore(JPA) andPgVectorConversationStorebehind the same interface. uiscans components, JPA repositories, and entities across the wholecom.agentplatformroot — new@Entity/@Repository/@Serviceanywhere is auto-discovered.- Package naming is
com.agentplatform.<module>except tool-service, which usescom.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 callsPOST /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}→AgentProcessResponsewith per-taskTypeLangChain4j system prompts producing aStructuredAgentResult, plusexecutionTimeMs). - Chat persistence:
POST /api/v1/agent/chattakesChatRequest{query, conversationId?, model}and returnsChatResponse{response, conversationId}(id generated when missing → always echoed). The orchestratorAgentChatService.chatWithHistory(conversationId, systemPrompt, userMessage, model)loads the stored transcript into the LangChain4jChatRequest, appends the new turn, saves both toConversationStore(nothing persisted on failure), returnsAgentChatResult{conversationId, response}. The old statelesschat(query)/chat(systemPrompt, query)overloads are kept forcustom/processand backward compatibility. No page mirrors the thread in localStorage:agentplatform:conversationId/agentplatform:chatTranscriptandapp.jsno longer exist, so a conversation id is caller-supplied only. - Dynamic Ollama model switching:
OllamaChatModelFactory(agent-core/config) builds anOllamaChatModelper request from an optionalmodelfield — null/blank falls back toollama.chat-model(llama3.2:3b, configurable viaollama.base-url/ollama.chat-modelinapplication.yml; base URL defaulthttp://localhost:11434). Chat andcustom/processroute through it (no more hardcoded Gemini chat model); Gemini remains wired only as theGeminiChatModelbean, disabled. Thecustompage exposes a model<select id="modelSelector">with the exact locally-pulled tags —llama3.2:3b(Llama 3.2 3B Fast Chat & Tools) andqwen2.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 keyagentplatform:modelwith a#modelBadgeTextbadge 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@Toolbeans —ImageTools.generateImage(prompt)(deterministic picsum URL, returns a raw<div class="tool-image-result">…<img…>HTML fragment),EmailTools.sendEmail(recipient, subject, body)(JavaMailSenderbean fromMailConfig, lazily creates aJavaMailSenderImplwith no host — sending without a configured SMTP just returns a failure string;@Valuesettertools.email.from),WhatsAppTools.sendWhatsAppMessage(phone, message)(stub confirmation, phone\+?\d{7,15}).AgentChatServiceregisters these on every model call viaToolSpecifications.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 byImageTools.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/MailConfigare 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/copyTextToClipboardare gone). - Gemini diagnostics & health check:
AgentChatServiceandResumeProfileServiceclassify failures viaAiErrorClassifier(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) returnsAiStatusResponse{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 ifgemini.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 viaagent.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 existingRecommendationLevelenum; each match carriesmatchScore,strengths,concerns,educationScore. POST /api/v1/jobs/matchtakesJobMatchRequestDto(candidateProfileIdor inlineprofile;jobsoptional else the deterministic search catalog, max 100; pluskeywords/location/experience/employmentType/limit/minScore/careerTrack). It returnsJobMatchResponseDto(candidateProfileId,candidateName,totalJobs, rankedmatches,source,live,message). Missing stored profile → RFC 7807 404 (CandidateProfileNotFoundException).resume/uploadpersists the extracted profile (CandidateProfilePersistenceService) and returns its id in the JSON body —{candidateId, profile, aiModelUsed, notice?}; there is noX-Candidate-Idheader, and on persist failure it logs and still returns 200 with a null id. The Matches page reads the id from the upload response vialocalStoragekeysagentplatform:candidateId/agentplatform:candidateName(matches.jswill only auto-fire when a candidate id is stored; candidate id = DB id, e.g. 1 after the first upload).
- LangChain4j versions are intentionally mixed: core
1.0.0, Google AI Gemini integration1.0.0-beta5, and the Ollama integration1.0.0-beta5(all pinned in rootdependencyManagement; the Gemini and Ollama modules have no1.0.0GA and their GA series pull a newer core). Don't "fix" them to a single version.
- No full
@SpringBootTest. Tests are@WebMvcTestslice tests with mocked services, or plain JUnit (real in-memory PDFBox PDFs). No DB or external LLM required — preserve this somvn teststays hermetic and fast. Current totals: 1,304 Java tests (15 skipped Testcontainers PostgreSQL/pgvector tests) plus 24 JS tests inui/src/test/jsvianode --test. - JPA repositories/entities are wired by
ui/PersistenceConfig(@EnableJpaRepositories+@EntityScanovercom.agentplatform), NOT by annotations onAgentPlatformApplication— putting them on the bootstrap class makes every@WebMvcTestfail to load (Spring Data registers a sharedEntityManagerFactorythe 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 bySampleResumeFixtureand pinned bySampleResumeFixtureTest. 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 editingSampleResumeFixture.LINES(regenerate + rerun the test). - Browser E2E scripts live in
scripts/e2e/(tracked, with a README) and importplaywright, which is a declared root dependency — unusedpatchrightwas removed in Cleanup Batch 5. Never author scripts insideui/target/;mvn cleandeletes it. Evidence goes toos.tmpdir()/agent-platform-e2e, not the repo.npm run e2e:browseris the full 8-check regression; it needs the app onhttp://127.0.0.1:8080. PersistentConversationStoreTest.tearDowncan flake withTransientObjectExceptionwhen itsdeleteAll()/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.
- Errors go through
GlobalExceptionHandleras RFC 7807ProblemDetail(AgentChatException→503,IllegalArgumentException→400). Exception to the convention:JobMatchControllerreturns a plain500string its own catch block. - Chat,
custom/process, resume parsing, andai/status?probe=truehit Ollama (local models,ollama.<base-url|chat-model>); Gemini is disabled and would only be reached ifgemini.enabled=true. Job search/matching are fully deterministic (orchestrator/matchingengines);MockJobSourceis 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.gitignoreremains —ui/.gitignoreand its siblings were removed in Batch 2 (620749c), which also stopped ignoringapplication.ymland committed the modernize hook scripts under.github/.