Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ LLM_API_KEY=
LLM_BATCH= # true halves the price and makes scoring asynchronous
LLM_TIMEOUT= # ISO-8601, e.g. PT120S. One request to a model; empty is PT120S
LLM_CONCURRENCY= # adverts the model-bound stages work at once; empty is 1, at most the DB pool (10)
LLM_BILLED= # true | false; empty is billed for anthropic only
LLM_MODEL_EXTRACTION= # reads a document a source's rules could not; empty falls back to SCORING
LLM_MODEL_CONTENT= # labels an advert's blocks; empty means LLM_MODEL_SCORING
LLM_MODEL_FIELDS= # reads start, duration, apply-by; empty means LLM_MODEL_SCORING
Expand All @@ -42,6 +43,12 @@ LLM_MODEL_EMBEDDING= # >= 2000 dims, for deduplication's simi
LLM_MODEL_CHAT= # the chat in the drawer, must call tools; empty means LLM_MODEL_SCORING
CHAT_MAX_CALLS_PER_DAY= # the chat's own daily ceiling, beside the pipeline's; empty is 200
CHAT_MAX_TOOL_ROUNDS= # tool calls one chat turn may make before it answers; empty is 6
CHAT_LLM_PROVIDER= # the chat's optional second connection: anthropic | ollama | openai-compatible; empty is none
CHAT_LLM_BASE_URL= # required for a hosted kind
CHAT_LLM_API_KEY= # required for a hosted kind; Ollama needs none
CHAT_LLM_BILLED= # true | false; empty is billed for anthropic only
CHAT_LLM_TIMEOUT= # ISO-8601, one request on the second connection; empty is PT120S
CHAT_LLM_MAX_CALLS_PER_DAY= # the second connection's own daily ceiling, beside the chat's; empty is 50
CHAT_SUGGEST_NEW_OFFERS_MIN= # the empty chat suggests the last run's offers from this many; empty is 1
CHAT_SUGGEST_DEADLINE_DAYS= # ... deadlines within this many days; empty is 7
CHAT_SUGGEST_NO_REPLY_DAYS= # ... sent applications without a reply for this many days; empty is 14
Expand Down
34 changes: 33 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,37 @@ may change in any release. See the status note in the README.

## [Unreleased]

## [0.5.5] — 2026-10-12

The chat can answer with another model per question — a local one or, through an optional second
connection, a hosted one with a daily ceiling of its own — and an iPad gets its home-screen icon.
The configuration gains the optional `chat.connection` block and `llm.billed`; an installation without
them runs as before.

### Added

- **The chat's model is picked per question.** A select beside the composer lists the models each configured
connection serves and can call tools with, grouped by connection; billed ones are marked there and under every
answer they wrote. The next question may go to another model, which reads the conversation so far, and
regenerate may use another model. A new conversation always starts on the configured default.
- **An optional second connection for the chat**, `chat.connection` (`CHAT_LLM_*`), beside `llm`: another
provider kind, base URL and key, read by the chat alone, with a daily ceiling of its own
(`CHAT_LLM_MAX_CALLS_PER_DAY`, default 50) on top of the chat's. `LLM_BILLED` and `CHAT_LLM_BILLED` decide
the billed marker. New endpoint `GET /api/v1/chat/models`; `GET /api/v1/chat/status` names the second
connection's calls.

### Changed

- **The chat panel loads on its first open.** The drawer's content and its libraries left the initial bundle
(1.00 MB → 868 kB); the header button, its living mark and the minibar stay in the shell, and a reload with
`?chat=…` still opens the drawer with its content.

### Fixed

- **Every apple-touch-icon variant answers with the touch icon.** iOS asks for fixed root paths, an iPad's
152 and 167 sizes among them; nginx handed those to the SPA fallback, and an iPad's home-screen icon fell
back to a letter.

## [0.5.4] — 2026-10-11

The workflow screen's live run holds through a dropped heartbeat and a skewed clock, links in the
Expand Down Expand Up @@ -1702,7 +1733,8 @@ Found while building the demo, all of them in paths only a container exercises:
left six.
- The shortlist card printed the description's Markdown syntax in its teaser.

[Unreleased]: https://github.com/codeministry/leadgen/compare/v0.5.4...HEAD
[Unreleased]: https://github.com/codeministry/leadgen/compare/v0.5.5...HEAD
[0.5.5]: https://github.com/codeministry/leadgen/releases/tag/v0.5.5
[0.5.4]: https://github.com/codeministry/leadgen/releases/tag/v0.5.4
[0.5.3]: https://github.com/codeministry/leadgen/releases/tag/v0.5.3
[0.5.2]: https://github.com/codeministry/leadgen/releases/tag/v0.5.2
Expand Down
2 changes: 1 addition & 1 deletion backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ plugins {
}

group = "de.codeministry"
version = "0.5.4"
version = "0.5.5"

/**
* Java 25 through the toolchain rather than the ambient JDK: Gradle runs on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import de.codeministry.leadgen.config.DotEnv;
import de.codeministry.leadgen.config.Secrets;
import de.codeministry.leadgen.config.model.PipelineConfig;
import de.codeministry.leadgen.llm.Connections;
import de.codeministry.leadgen.llm.ModelChoice;
import java.nio.file.Files;
import java.nio.file.Path;
Expand Down Expand Up @@ -170,6 +171,7 @@ String describe(DotEnv dotenv) {
+ (Files.isDirectory(properties.configDirectory()) ? "" : " (missing, defaults apply)"),
Origin.RESOLVED));
entries.addAll(modelChoices());
entries.addAll(secondConnection());

return render(entries, dotenv, ignored);
}
Expand Down Expand Up @@ -199,6 +201,36 @@ private List<Entry> modelChoices() {
chosen("llm.models.chat", ModelChoice.chat(models), models == null ? null : models.chat()));
}

/**
* Three rows for the chat's second connection when {@code chat.connection} names a kind, and
* none otherwise: the kind and whether it is billed as {@link Connections} resolves it, where
* the requests go, and whether a key is set. The base URL loses any {@code user:password@} and the
* key is only ever {@link Secrets#mask masked} — a hosted connection's key is exactly what this
* box must not print, and "is one set" is the question it answers instead.
*/
private List<Entry> secondConnection() {
Optional<Connections.Connection> second;
try {
second = Connections.second(config.snapshot().application());
} catch (RuntimeException e) {
return List.of();
}
return second.map(connection -> List.of(
new Entry(
"chat.connection → llm",
connection.kind() + " · " + (connection.billed() ? "billed" : "not billed"),
Origin.CHOSEN),
new Entry(
"chat.connection → llm url",
Secrets.mask("base_url", connection.llm().baseUrl()),
Origin.CHOSEN),
new Entry(
"chat.connection → llm key",
Secrets.mask("api_key", connection.llm().apiKey()),
Origin.CHOSEN)))
.orElse(List.of());
}

private static Entry chosen(String key, Optional<String> model, String configured) {
return new Entry(
key + " → used",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,19 @@
import de.codeministry.leadgen.chat.ChatTurnStarted;
import de.codeministry.leadgen.chat.ConversationSummary;
import de.codeministry.leadgen.chat.ConversationView;
import de.codeministry.leadgen.chat.ModelPick;
import de.codeministry.leadgen.chat.NewContext;
import de.codeministry.leadgen.chat.NewConversation;
import de.codeministry.leadgen.chat.NewTurn;
import de.codeministry.leadgen.chat.PastTurn;
import de.codeministry.leadgen.chat.RegenerateTurn;
import de.codeministry.leadgen.chat.RenameConversation;
import de.codeministry.leadgen.chat.StatisticsSource;
import de.codeministry.leadgen.chat.TurnLedger;
import de.codeministry.leadgen.chat.TurnView;
import de.codeministry.leadgen.chat.catalog.ChatModelsView;
import de.codeministry.leadgen.chat.catalog.ConnectionView;
import de.codeministry.leadgen.chat.catalog.ModelView;
import de.codeministry.leadgen.chat.suggest.FollowUp;
import de.codeministry.leadgen.chat.suggest.Suggestion;
import de.codeministry.leadgen.chat.suggest.SuggestionCandidate;
Expand Down Expand Up @@ -126,6 +131,9 @@ private static void registerChatRecords(RuntimeHints hints) {
BulkDeleted.class,
ChatCapabilityView.class,
ChatContextItem.class,
ChatModelsView.class,
ConnectionView.class,
ModelView.class,
ChatDone.class,
ChatError.class,
ChatSource.class,
Expand All @@ -143,8 +151,10 @@ private static void registerChatRecords(RuntimeHints hints) {
SuggestionScope.class,
SuggestionThresholds.class,
NewConversation.class,
ModelPick.class,
NewTurn.class,
PastTurn.class,
RegenerateTurn.class,
RenameConversation.class,
StatisticsSource.class,
StatisticsSource.Row.class,
Expand Down
111 changes: 107 additions & 4 deletions backend/src/main/java/de/codeministry/leadgen/chat/ChatBudget.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@

import de.codeministry.leadgen.config.ConfigRegistry;
import de.codeministry.leadgen.config.model.PipelineConfig;
import de.codeministry.leadgen.llm.Connections;
import java.time.LocalDate;
import java.util.OptionalInt;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

/**
* The chat's own ceilings: how many requests a day it may send, and how many tool rounds one turn
Expand All @@ -36,6 +40,12 @@
* by default wherever a scoring model exists. Without a round bound, a model that keeps asking for
* tools looped for ever on a pool thread; without a daily ceiling, nothing metered it.
*
* <p><b>The second connection has a ceiling of its own, beside the chat's</b> (spec 025, ISC-510).
* A call on {@code chat.connection} takes one from {@code chat.max_calls_per_day} and one from
* {@code chat.connection.max_calls_per_day}, in one transaction: when either refuses, the other is
* rolled back, so a refused call counts on neither. A call on the primary takes only the chat's. The
* second ceiling reads {@link #DEFAULT_SECOND_CALLS_PER_DAY} where it is absent, never no ceiling.
*
* <p><b>What "no" means is the turn's.</b> A refused call or a spent round count ends the turn with
* its reason ({@link ChatErrorReason#BUDGET}, {@link ChatErrorReason#ROUNDS}) rather than with an
* answer the model never finished; this class only answers the question.
Expand All @@ -51,6 +61,12 @@ public class ChatBudget {
/** {@code chat.max_tool_rounds} when the key or the whole {@code chat:} block is absent. */
public static final int DEFAULT_TOOL_ROUNDS = 6;

/**
* {@code chat.connection.max_calls_per_day} when the key is absent; the shipped
* {@code pipeline.yaml} names the same default for its placeholder.
*/
public static final int DEFAULT_SECOND_CALLS_PER_DAY = 50;

/**
* Check and increment in one statement, as in {@code LlmBudget}: between a {@code SELECT} and
* an {@code UPDATE} two turns could both take the last call.
Expand All @@ -62,11 +78,23 @@ ON CONFLICT (day) DO UPDATE SET calls = chat_call_budget.calls + 1
RETURNING calls
""";

/** The same one-statement take, per connection, for the second connection's own ceiling. */
private static final String TAKE_CONNECTION = """
INSERT INTO chat_connection_call_budget (day, connection, calls) VALUES (current_date, :connection, 1)
ON CONFLICT (day, connection) DO UPDATE SET calls = chat_connection_call_budget.calls + 1
WHERE chat_connection_call_budget.calls < :limit
RETURNING calls
""";

/** The day a spent budget was logged, so it is one line and not one per refused call. */
private final AtomicReference<LocalDate> announced = new AtomicReference<>();

/** The same for the second connection's ceiling, so neither spent day hides the other's line. */
private final AtomicReference<LocalDate> announcedSecond = new AtomicReference<>();

private final ConfigRegistry config;
private final JdbcClient jdbc;
private final PlatformTransactionManager transactionManager;

/**
* Takes one call from today's chat allowance. Asked before every model call of a turn — the
Expand All @@ -75,9 +103,53 @@ ON CONFLICT (day) DO UPDATE SET calls = chat_call_budget.calls + 1
* @return true when the call may be sent; false ends the turn with {@link ChatErrorReason#BUDGET}.
*/
public boolean take() {
return take(Connections.Name.PRIMARY);
}

/**
* Takes one call on {@code connection}: on the primary from the chat's allowance alone, on the
* second from the chat's and the connection's own, both or neither (ISC-510).
*
* @return true when the call may be sent; false ends the turn with {@link ChatErrorReason#BUDGET}.
*/
public boolean take(Connections.Name connection) {
if (connection != Connections.Name.SECOND) {
return takeChat();
}
int secondLimit = secondLimitOrDefault();
if (secondLimit <= 0) {
announceOnce(
announcedSecond,
"chat.connection.max_calls_per_day is {}, so the chat asks nothing of it today",
secondLimit);
return false;
}
Boolean taken = new TransactionTemplate(transactionManager).execute(status -> {
if (!takeChat()) {
return false;
}
boolean second = jdbc.sql(TAKE_CONNECTION)
.param("connection", connection.name())
.param("limit", secondLimit)
.query(Integer.class)
.optional()
.isPresent();
if (!second) {
// The chat's call taken above is given back: a refused call counts on neither.
status.setRollbackOnly();
announceOnce(
announcedSecond, "chat.connection.max_calls_per_day of {} is spent for today", secondLimit);
}
return second;
});
return Boolean.TRUE.equals(taken);
}

/** Takes one call from the chat's own counter, as every call on any connection does. */
private boolean takeChat() {
int limit = limit();
if (limit <= 0) {
announceOnce("chat.max_calls_per_day is {}, so the chat asks nothing of a model today", limit);
announceOnce(announced, "chat.max_calls_per_day is {}, so the chat asks nothing of a model today", limit);
return false;
}
boolean taken = jdbc.sql(TAKE)
Expand All @@ -86,7 +158,7 @@ public boolean take() {
.optional()
.isPresent();
if (!taken) {
announceOnce("chat.max_calls_per_day of {} is spent for today", limit);
announceOnce(announced, "chat.max_calls_per_day of {} is spent for today", limit);
}
return taken;
}
Expand All @@ -98,6 +170,37 @@ public int used() {
.single();
}

/** Today's count on {@code connection}: the chat's own for the primary, the connection's own for the second. */
public int used(Connections.Name connection) {
if (connection != Connections.Name.SECOND) {
return used();
}
return jdbc.sql("""
SELECT coalesce((SELECT calls FROM chat_connection_call_budget
WHERE day = current_date AND connection = :connection), 0)
""")
.param("connection", connection.name())
.query(Integer.class)
.single();
}

/**
* {@code chat.connection.max_calls_per_day} as it resolves now; empty when no second connection
* is configured, the shipped default where the key is absent.
*/
public OptionalInt secondLimit() {
PipelineConfig.ChatConnection connection = chat().connection();
return connection == null || !connection.configured()
? OptionalInt.empty()
: OptionalInt.of(secondLimitOrDefault());
}

private int secondLimitOrDefault() {
PipelineConfig.ChatConnection connection = chat().connection();
Integer configured = connection == null ? null : connection.maxCallsPerDay();
return configured == null ? DEFAULT_SECOND_CALLS_PER_DAY : configured;
}

/** {@code chat.max_calls_per_day} as it resolves now, the shipped default where it is absent. */
public int limit() {
Integer configured = chat().maxCallsPerDay();
Expand All @@ -124,9 +227,9 @@ private PipelineConfig.Chat chat() {
return chat == null ? new PipelineConfig.Chat(null, null) : chat;
}

private void announceOnce(String message, Object argument) {
private static void announceOnce(AtomicReference<LocalDate> day, String message, Object argument) {
LocalDate today = LocalDate.now();
if (!today.equals(announced.getAndSet(today))) {
if (!today.equals(day.getAndSet(today))) {
log.warn(message, argument);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,16 @@
* @param callsUsed today's model calls, against {@code callsLimit}.
* @param callsLimit {@code chat.max_calls_per_day} as it resolves; {@code 0} asks nothing of a model.
* @param toolRounds {@code chat.max_tool_rounds} as it resolves: how often one turn may call tools.
* @param secondCalls today's calls on the second connection, against {@code secondLimit}; null
* without a second connection (spec 025, ISC-510).
* @param secondLimit {@code chat.connection.max_calls_per_day} as it resolves; null without a second
* connection.
*/
public record ChatStatusView(String model, int callsUsed, int callsLimit, int toolRounds) {}
public record ChatStatusView(
String model, int callsUsed, int callsLimit, int toolRounds, Integer secondCalls, Integer secondLimit) {

/** The status of a chat with no second connection. */
public ChatStatusView(String model, int callsUsed, int callsLimit, int toolRounds) {
this(model, callsUsed, callsLimit, toolRounds, null, null);
}
}
Loading
Loading