From 4cd7064274fabe15e785e7c98f957f66edd385b1 Mon Sep 17 00:00:00 2001 From: Alex Today Date: Tue, 9 Jun 2026 10:03:14 +0300 Subject: [PATCH 01/10] feat(memory): add agent memory contract --- .gitignore | 8 +- .../atlas/agent/AgentExecutionContext.java | 15 ++++ .../atlas/agent/AgentFailureReason.java | 9 +++ .../com/example/atlas/agent/AgentRequest.java | 10 +++ .../example/atlas/agent/AgentResponse.java | 21 ++++++ .../com/example/atlas/agent/AgentResult.java | 23 +++++- .../com/example/atlas/agent/AgentType.java | 9 +++ .../atlas/agent/question/QuestionAgent.java | 73 +++++++++++++++++++ .../atlas/memory/AgentMemoryRecord.java | 28 +++++++ .../atlas/memory/AgentMemoryService.java | 17 +++++ .../atlas/memory/MemoryConfidence.java | 7 ++ .../com/example/atlas/memory/MemoryScope.java | 10 +++ .../example/atlas/memory/MemorySource.java | 9 +++ .../com/example/atlas/memory/MemoryTag.java | 7 ++ .../com/example/atlas/memory/MemoryType.java | 13 ++++ .../atlas/memory/MemoryValidationResult.java | 11 +++ .../com/example/atlas/memory/MemoryWrite.java | 28 +++++++ .../atlas/memory/MemoryWritePolicy.java | 45 ++++++++++++ .../atlas/memory/MemoryWriteResult.java | 13 ++++ .../atlas/memory/MemoryWritePolicyTest.java | 43 +++++++++++ 20 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/example/atlas/agent/AgentExecutionContext.java create mode 100644 src/main/java/com/example/atlas/agent/AgentFailureReason.java create mode 100644 src/main/java/com/example/atlas/agent/AgentRequest.java create mode 100644 src/main/java/com/example/atlas/agent/AgentResponse.java create mode 100644 src/main/java/com/example/atlas/agent/AgentType.java create mode 100644 src/main/java/com/example/atlas/agent/question/QuestionAgent.java create mode 100644 src/main/java/com/example/atlas/memory/AgentMemoryRecord.java create mode 100644 src/main/java/com/example/atlas/memory/AgentMemoryService.java create mode 100644 src/main/java/com/example/atlas/memory/MemoryConfidence.java create mode 100644 src/main/java/com/example/atlas/memory/MemoryScope.java create mode 100644 src/main/java/com/example/atlas/memory/MemorySource.java create mode 100644 src/main/java/com/example/atlas/memory/MemoryTag.java create mode 100644 src/main/java/com/example/atlas/memory/MemoryType.java create mode 100644 src/main/java/com/example/atlas/memory/MemoryValidationResult.java create mode 100644 src/main/java/com/example/atlas/memory/MemoryWrite.java create mode 100644 src/main/java/com/example/atlas/memory/MemoryWritePolicy.java create mode 100644 src/main/java/com/example/atlas/memory/MemoryWriteResult.java create mode 100644 src/test/java/com/example/atlas/memory/MemoryWritePolicyTest.java diff --git a/.gitignore b/.gitignore index 6a24bbc..38f5363 100644 --- a/.gitignore +++ b/.gitignore @@ -10,10 +10,10 @@ target/ *.iml logs/ -data/ -memory/ -runtime/ -storage/ +/data/ +/memory/ +/runtime/ +/storage/ *.log *.pid *.pid.lock diff --git a/src/main/java/com/example/atlas/agent/AgentExecutionContext.java b/src/main/java/com/example/atlas/agent/AgentExecutionContext.java new file mode 100644 index 0000000..512afdf --- /dev/null +++ b/src/main/java/com/example/atlas/agent/AgentExecutionContext.java @@ -0,0 +1,15 @@ +package com.example.atlas.agent; + +import java.time.Instant; +import java.util.List; + +public record AgentExecutionContext( + AgentRequest request, + List contextLines, + Instant receivedAt +) { + public AgentExecutionContext { + contextLines = contextLines == null ? List.of() : List.copyOf(contextLines); + receivedAt = receivedAt == null ? Instant.now() : receivedAt; + } +} diff --git a/src/main/java/com/example/atlas/agent/AgentFailureReason.java b/src/main/java/com/example/atlas/agent/AgentFailureReason.java new file mode 100644 index 0000000..f50fc0f --- /dev/null +++ b/src/main/java/com/example/atlas/agent/AgentFailureReason.java @@ -0,0 +1,9 @@ +package com.example.atlas.agent; + +public enum AgentFailureReason { + NONE, + LLM_DISABLED, + LLM_UNAVAILABLE, + OUT_OF_SCOPE, + SAFETY_BLOCKED +} diff --git a/src/main/java/com/example/atlas/agent/AgentRequest.java b/src/main/java/com/example/atlas/agent/AgentRequest.java new file mode 100644 index 0000000..2bfa1d5 --- /dev/null +++ b/src/main/java/com/example/atlas/agent/AgentRequest.java @@ -0,0 +1,10 @@ +package com.example.atlas.agent; + +import com.example.atlas.orchestrator.RequestType; + +public record AgentRequest( + Long userId, + String message, + RequestType requestType +) { +} diff --git a/src/main/java/com/example/atlas/agent/AgentResponse.java b/src/main/java/com/example/atlas/agent/AgentResponse.java new file mode 100644 index 0000000..38d38d7 --- /dev/null +++ b/src/main/java/com/example/atlas/agent/AgentResponse.java @@ -0,0 +1,21 @@ +package com.example.atlas.agent; + +import com.example.atlas.memory.MemoryWrite; + +import java.util.List; +import java.util.Map; + +public record AgentResponse( + String text, + Map metadata, + boolean fallback, + boolean safety, + AgentFailureReason failureReason, + List memoryWrites +) { + public AgentResponse { + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + memoryWrites = memoryWrites == null ? List.of() : List.copyOf(memoryWrites); + failureReason = failureReason == null ? AgentFailureReason.NONE : failureReason; + } +} diff --git a/src/main/java/com/example/atlas/agent/AgentResult.java b/src/main/java/com/example/atlas/agent/AgentResult.java index 0a3fb7c..179f600 100644 --- a/src/main/java/com/example/atlas/agent/AgentResult.java +++ b/src/main/java/com/example/atlas/agent/AgentResult.java @@ -1,17 +1,38 @@ package com.example.atlas.agent; +import com.example.atlas.memory.MemoryWrite; + import java.util.List; +import java.util.Map; public record AgentResult( String content, - List handledBy + List handledBy, + Map metadata, + boolean fallback, + boolean safety, + List memoryWrites ) { public AgentResult { handledBy = List.copyOf(handledBy); + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + memoryWrites = memoryWrites == null ? List.of() : List.copyOf(memoryWrites); + } + + public AgentResult(String content, List handledBy) { + this(content, handledBy, Map.of(), false, false, List.of()); } public static AgentResult reply(String content, String agentName) { return new AgentResult(content, List.of(agentName)); } + + public static AgentResult fallback(String content, String agentName) { + return new AgentResult(content, List.of(agentName), Map.of(), true, false, List.of()); + } + + public AgentResult withMemoryWrites(List writes) { + return new AgentResult(content, handledBy, metadata, fallback, safety, writes); + } } diff --git a/src/main/java/com/example/atlas/agent/AgentType.java b/src/main/java/com/example/atlas/agent/AgentType.java new file mode 100644 index 0000000..8b49c5e --- /dev/null +++ b/src/main/java/com/example/atlas/agent/AgentType.java @@ -0,0 +1,9 @@ +package com.example.atlas.agent; + +public enum AgentType { + PLANNER, + REPORT, + QUESTION, + HABITS, + STATE +} diff --git a/src/main/java/com/example/atlas/agent/question/QuestionAgent.java b/src/main/java/com/example/atlas/agent/question/QuestionAgent.java new file mode 100644 index 0000000..d687f04 --- /dev/null +++ b/src/main/java/com/example/atlas/agent/question/QuestionAgent.java @@ -0,0 +1,73 @@ +package com.example.atlas.agent.question; + +import com.example.atlas.agent.Agent; +import com.example.atlas.agent.AgentContext; +import com.example.atlas.agent.AgentResult; +import com.example.atlas.agent.AgentType; +import com.example.atlas.memory.MemoryConfidence; +import com.example.atlas.memory.MemoryScope; +import com.example.atlas.memory.MemorySource; +import com.example.atlas.memory.MemoryTag; +import com.example.atlas.memory.MemoryType; +import com.example.atlas.memory.MemoryWrite; +import com.example.atlas.orchestrator.RequestType; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class QuestionAgent implements Agent { + + @Override + public String name() { + return "ATLAS Question"; + } + + @Override + public boolean supports(RequestType requestType) { + return requestType == RequestType.GENERAL; + } + + @Override + public AgentResult handle(AgentContext context) { + String message = context.message() == null ? "" : context.message().toLowerCase(); + if (outOfScope(message)) { + return AgentResult.fallback( + "ATLAS отвечает только про планирование дня, привычки, check-ins, рефлексию, отчеты, состояние, фокус и ритм. Можно начать с /checkin, /day, /habits, /evening или /report.", + name() + ); + } + + AgentResult result = AgentResult.reply( + "В рамках ATLAS лучше сузить вопрос до одного шага: состояние сейчас, главный фокус, минимальная привычка или вечерний вывод. Начни с /checkin или попроси план через /day.", + name() + ); + if (context.userId() == null || message.isBlank()) { + return result; + } + MemoryWrite write = new MemoryWrite( + null, + AgentType.QUESTION, + MemoryType.PREFERENCE, + MemoryScope.AGENT_PRIVATE, + "Question topic", + "User asks ATLAS-scoped questions about planning, habits or state.", + MemoryConfidence.LOW, + List.of(new MemoryTag("question")), + MemorySource.QUESTION_AGENT, + null + ); + return result.withMemoryWrites(List.of(write)); + } + + private boolean outOfScope(String message) { + return message.contains("stock") + || message.contains("crypto") + || message.contains("legal") + || message.contains("diagnos") + || message.contains("prescribe") + || message.contains("курс валют") + || message.contains("диагноз") + || message.contains("юрид"); + } +} diff --git a/src/main/java/com/example/atlas/memory/AgentMemoryRecord.java b/src/main/java/com/example/atlas/memory/AgentMemoryRecord.java new file mode 100644 index 0000000..54eb764 --- /dev/null +++ b/src/main/java/com/example/atlas/memory/AgentMemoryRecord.java @@ -0,0 +1,28 @@ +package com.example.atlas.memory; + +import com.example.atlas.agent.AgentType; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record AgentMemoryRecord( + UUID id, + UUID userId, + AgentType agentType, + MemoryType type, + MemoryScope scope, + String title, + String content, + MemoryConfidence confidence, + List tags, + MemorySource source, + Instant createdAt, + Instant updatedAt, + Instant expiresAt, + boolean archived +) { + public AgentMemoryRecord { + tags = tags == null ? List.of() : List.copyOf(tags); + } +} diff --git a/src/main/java/com/example/atlas/memory/AgentMemoryService.java b/src/main/java/com/example/atlas/memory/AgentMemoryService.java new file mode 100644 index 0000000..6028cd3 --- /dev/null +++ b/src/main/java/com/example/atlas/memory/AgentMemoryService.java @@ -0,0 +1,17 @@ +package com.example.atlas.memory; + +import com.example.atlas.agent.AgentType; + +import java.util.List; +import java.util.UUID; + +public interface AgentMemoryService { + + MemoryWriteResult write(MemoryWrite write); + + List findForAgent(UUID userId, AgentType agentType, int limit); + + List findSharedContext(UUID userId, int limit); + + void archiveForUser(UUID userId); +} diff --git a/src/main/java/com/example/atlas/memory/MemoryConfidence.java b/src/main/java/com/example/atlas/memory/MemoryConfidence.java new file mode 100644 index 0000000..1ea237f --- /dev/null +++ b/src/main/java/com/example/atlas/memory/MemoryConfidence.java @@ -0,0 +1,7 @@ +package com.example.atlas.memory; + +public enum MemoryConfidence { + LOW, + MEDIUM, + HIGH +} diff --git a/src/main/java/com/example/atlas/memory/MemoryScope.java b/src/main/java/com/example/atlas/memory/MemoryScope.java new file mode 100644 index 0000000..33302db --- /dev/null +++ b/src/main/java/com/example/atlas/memory/MemoryScope.java @@ -0,0 +1,10 @@ +package com.example.atlas.memory; + +public enum MemoryScope { + USER_PROFILE, + DAILY, + WEEKLY, + LONG_TERM, + AGENT_PRIVATE, + SHARED_CONTEXT +} diff --git a/src/main/java/com/example/atlas/memory/MemorySource.java b/src/main/java/com/example/atlas/memory/MemorySource.java new file mode 100644 index 0000000..dd86aa0 --- /dev/null +++ b/src/main/java/com/example/atlas/memory/MemorySource.java @@ -0,0 +1,9 @@ +package com.example.atlas.memory; + +public enum MemorySource { + PLANNER_AGENT, + REPORT_AGENT, + QUESTION_AGENT, + USER_ACTION, + SYSTEM +} diff --git a/src/main/java/com/example/atlas/memory/MemoryTag.java b/src/main/java/com/example/atlas/memory/MemoryTag.java new file mode 100644 index 0000000..d158a5e --- /dev/null +++ b/src/main/java/com/example/atlas/memory/MemoryTag.java @@ -0,0 +1,7 @@ +package com.example.atlas.memory; + +public record MemoryTag(String value) { + public MemoryTag { + value = value == null ? "" : value.strip().toLowerCase(); + } +} diff --git a/src/main/java/com/example/atlas/memory/MemoryType.java b/src/main/java/com/example/atlas/memory/MemoryType.java new file mode 100644 index 0000000..9ad51bb --- /dev/null +++ b/src/main/java/com/example/atlas/memory/MemoryType.java @@ -0,0 +1,13 @@ +package com.example.atlas.memory; + +public enum MemoryType { + FACT, + PREFERENCE, + PATTERN, + GOAL, + CONSTRAINT, + SUMMARY, + WARNING, + PLAN, + REFLECTION +} diff --git a/src/main/java/com/example/atlas/memory/MemoryValidationResult.java b/src/main/java/com/example/atlas/memory/MemoryValidationResult.java new file mode 100644 index 0000000..adde18b --- /dev/null +++ b/src/main/java/com/example/atlas/memory/MemoryValidationResult.java @@ -0,0 +1,11 @@ +package com.example.atlas.memory; + +public record MemoryValidationResult(boolean valid, String reason) { + public static MemoryValidationResult accepted() { + return new MemoryValidationResult(true, "accepted"); + } + + public static MemoryValidationResult rejected(String reason) { + return new MemoryValidationResult(false, reason); + } +} diff --git a/src/main/java/com/example/atlas/memory/MemoryWrite.java b/src/main/java/com/example/atlas/memory/MemoryWrite.java new file mode 100644 index 0000000..b155f6d --- /dev/null +++ b/src/main/java/com/example/atlas/memory/MemoryWrite.java @@ -0,0 +1,28 @@ +package com.example.atlas.memory; + +import com.example.atlas.agent.AgentType; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record MemoryWrite( + UUID userId, + AgentType ownerAgent, + MemoryType type, + MemoryScope scope, + String title, + String content, + MemoryConfidence confidence, + List tags, + MemorySource source, + Instant expiresAt +) { + public MemoryWrite { + title = title == null ? "" : title.strip(); + content = content == null ? "" : content.strip(); + confidence = confidence == null ? MemoryConfidence.MEDIUM : confidence; + tags = tags == null ? List.of() : List.copyOf(tags); + source = source == null ? MemorySource.SYSTEM : source; + } +} diff --git a/src/main/java/com/example/atlas/memory/MemoryWritePolicy.java b/src/main/java/com/example/atlas/memory/MemoryWritePolicy.java new file mode 100644 index 0000000..1614557 --- /dev/null +++ b/src/main/java/com/example/atlas/memory/MemoryWritePolicy.java @@ -0,0 +1,45 @@ +package com.example.atlas.memory; + +import java.util.Locale; +import java.util.Set; + +public class MemoryWritePolicy { + + private static final Set SECRET_MARKERS = Set.of( + "token", "api key", "apikey", "secret", "password", "bearer ", "telegram_bot_token" + ); + private static final Set UNSAFE_MEDICAL_MARKERS = Set.of( + "diagnosis", "diagnose", "prescribe", "treatment plan", "blood pressure medicine", + "диагноз", "назначить лечение", "давление лекарство" + ); + + public MemoryValidationResult validate(MemoryWrite write) { + if (write == null) { + return MemoryValidationResult.rejected("missing_memory"); + } + if (write.userId() == null) { + return MemoryValidationResult.rejected("missing_user_scope"); + } + if (write.ownerAgent() == null || write.scope() == null || write.type() == null) { + return MemoryValidationResult.rejected("missing_owner_or_scope"); + } + if (write.content().isBlank() || write.content().length() < 8) { + return MemoryValidationResult.rejected("not_useful"); + } + String lowered = (write.title() + " " + write.content()).toLowerCase(Locale.ROOT); + if (SECRET_MARKERS.stream().anyMatch(lowered::contains)) { + return MemoryValidationResult.rejected("secret_like_content"); + } + if (UNSAFE_MEDICAL_MARKERS.stream().anyMatch(lowered::contains)) { + return MemoryValidationResult.rejected("unsafe_medical_claim"); + } + return MemoryValidationResult.accepted(); + } + + public String deduplicationKey(MemoryWrite write) { + String normalized = write.content().toLowerCase(Locale.ROOT) + .replaceAll("[^\\p{IsAlphabetic}\\p{IsDigit}]+", " ") + .strip(); + return write.userId() + ":" + write.ownerAgent() + ":" + write.scope() + ":" + normalized; + } +} diff --git a/src/main/java/com/example/atlas/memory/MemoryWriteResult.java b/src/main/java/com/example/atlas/memory/MemoryWriteResult.java new file mode 100644 index 0000000..c527411 --- /dev/null +++ b/src/main/java/com/example/atlas/memory/MemoryWriteResult.java @@ -0,0 +1,13 @@ +package com.example.atlas.memory; + +import java.util.UUID; + +public record MemoryWriteResult(boolean stored, UUID memoryId, String reason) { + public static MemoryWriteResult stored(UUID memoryId) { + return new MemoryWriteResult(true, memoryId, "stored"); + } + + public static MemoryWriteResult rejected(String reason) { + return new MemoryWriteResult(false, null, reason); + } +} diff --git a/src/test/java/com/example/atlas/memory/MemoryWritePolicyTest.java b/src/test/java/com/example/atlas/memory/MemoryWritePolicyTest.java new file mode 100644 index 0000000..3ae90ff --- /dev/null +++ b/src/test/java/com/example/atlas/memory/MemoryWritePolicyTest.java @@ -0,0 +1,43 @@ +package com.example.atlas.memory; + +import com.example.atlas.agent.AgentType; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class MemoryWritePolicyTest { + + private final MemoryWritePolicy policy = new MemoryWritePolicy(); + + @Test + void acceptsUsefulUserScopedPreference() { + MemoryWrite write = write("User prefers one minimal plan for overloaded days."); + + assertThat(policy.validate(write).valid()).isTrue(); + } + + @Test + void rejectsSecretsUnsafeMedicalClaimsAndMissingUserScope() { + assertThat(policy.validate(write("api key: secret-value")).valid()).isFalse(); + assertThat(policy.validate(write("Prescribe a treatment plan for blood pressure medicine.")).valid()).isFalse(); + assertThat(policy.validate(new MemoryWrite(null, AgentType.PLANNER, MemoryType.PREFERENCE, MemoryScope.LONG_TERM, "x", "Useful preference", MemoryConfidence.HIGH, List.of(), MemorySource.PLANNER_AGENT, null)).valid()).isFalse(); + } + + private MemoryWrite write(String content) { + return new MemoryWrite( + UUID.randomUUID(), + AgentType.PLANNER, + MemoryType.PREFERENCE, + MemoryScope.LONG_TERM, + "Preference", + content, + MemoryConfidence.HIGH, + List.of(new MemoryTag("planning")), + MemorySource.PLANNER_AGENT, + null + ); + } +} From 6ec49097c86c7cb19dd2ac063e5af2bd40993e62 Mon Sep 17 00:00:00 2001 From: Alex Today Date: Tue, 9 Jun 2026 10:05:05 +0300 Subject: [PATCH 02/10] feat(memory): add persistent memory context --- .../atlas/llm/LlmContextAssembler.java | 63 +++++- .../atlas/memory/MemorySnapshotWriter.java | 62 ++++++ .../memory/PersistentAgentMemoryService.java | 144 ++++++++++++++ .../entity/AgentMemoryRecordEntity.java | 181 ++++++++++++++++++ .../AgentMemoryRecordRepository.java | 24 +++ .../db/migration/V5__v063_agent_memory.sql | 25 +++ 6 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/atlas/memory/MemorySnapshotWriter.java create mode 100644 src/main/java/com/example/atlas/memory/PersistentAgentMemoryService.java create mode 100644 src/main/java/com/example/atlas/memory/entity/AgentMemoryRecordEntity.java create mode 100644 src/main/java/com/example/atlas/memory/repository/AgentMemoryRecordRepository.java create mode 100644 src/main/resources/db/migration/V5__v063_agent_memory.sql diff --git a/src/main/java/com/example/atlas/llm/LlmContextAssembler.java b/src/main/java/com/example/atlas/llm/LlmContextAssembler.java index 75f5a3e..38c1cce 100644 --- a/src/main/java/com/example/atlas/llm/LlmContextAssembler.java +++ b/src/main/java/com/example/atlas/llm/LlmContextAssembler.java @@ -1,5 +1,6 @@ package com.example.atlas.llm; +import com.example.atlas.agent.AgentType; import com.example.atlas.checkin.entity.CheckInEntity; import com.example.atlas.checkin.repository.CheckInRepository; import com.example.atlas.habit.entity.HabitCheckEntity; @@ -8,12 +9,16 @@ import com.example.atlas.life.entity.LifeProfileEntity; import com.example.atlas.life.repository.LifeProfileRepository; import com.example.atlas.life.service.LifeProfileService; +import com.example.atlas.memory.AgentMemoryRecord; +import com.example.atlas.memory.AgentMemoryService; import com.example.atlas.reflection.entity.EveningReflectionEntity; import com.example.atlas.reflection.repository.EveningReflectionRepository; import com.example.atlas.reflection.service.EveningReflectionService; import com.example.atlas.safety.SafetyGuard; import com.example.atlas.user.UserLanguage; import com.example.atlas.user.entity.TelegramUserEntity; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,6 +43,7 @@ public class LlmContextAssembler { private final HabitService habitService; private final EveningReflectionService reflectionService; private final SafetyGuard safetyGuard; + private final ObjectProvider memoryService; private final Clock clock; public LlmContextAssembler( @@ -47,7 +53,30 @@ public LlmContextAssembler( EveningReflectionService reflectionService, SafetyGuard safetyGuard ) { - this(lifeProfileService, checkInRepository, habitService, reflectionService, safetyGuard, Clock.systemUTC()); + this(lifeProfileService, checkInRepository, habitService, reflectionService, safetyGuard, null, Clock.systemUTC()); + } + + @Autowired + public LlmContextAssembler( + LifeProfileService lifeProfileService, + CheckInRepository checkInRepository, + HabitService habitService, + EveningReflectionService reflectionService, + SafetyGuard safetyGuard, + ObjectProvider memoryService + ) { + this(lifeProfileService, checkInRepository, habitService, reflectionService, safetyGuard, memoryService, Clock.systemUTC()); + } + + LlmContextAssembler( + LifeProfileService lifeProfileService, + CheckInRepository checkInRepository, + HabitService habitService, + EveningReflectionService reflectionService, + SafetyGuard safetyGuard, + Clock clock + ) { + this(lifeProfileService, checkInRepository, habitService, reflectionService, safetyGuard, null, clock); } LlmContextAssembler( @@ -56,6 +85,7 @@ public LlmContextAssembler( HabitService habitService, EveningReflectionService reflectionService, SafetyGuard safetyGuard, + ObjectProvider memoryService, Clock clock ) { this.lifeProfileService = lifeProfileService; @@ -63,6 +93,7 @@ public LlmContextAssembler( this.habitService = habitService; this.reflectionService = reflectionService; this.safetyGuard = safetyGuard; + this.memoryService = memoryService; this.clock = clock; } @@ -96,6 +127,9 @@ public PromptContext assemble(TelegramUserEntity user, PromptPurpose purpose, St Reflections %s + Memory + %s + Current request %s @@ -107,6 +141,7 @@ public PromptContext assemble(TelegramUserEntity user, PromptPurpose purpose, St recentPatternText(checkIns), habitText(habits), reflectionText(reflections), + memoryText(user, purpose), blank(currentRequest) ? "not provided" : currentRequest.strip(), safetyRisk ? "Risk words or saved risk flags are present." : "No explicit safety risk detected." ).strip(); @@ -114,6 +149,32 @@ public PromptContext assemble(TelegramUserEntity user, PromptPurpose purpose, St return new PromptContext(purpose, user.getId(), language, context, currentRequest, safetyRisk); } + private String memoryText(TelegramUserEntity user, PromptPurpose purpose) { + AgentMemoryService service = memoryService == null ? null : memoryService.getIfAvailable(); + if (service == null) { + return "not available"; + } + AgentType agentType = switch (purpose) { + case DAY_PLAN -> AgentType.PLANNER; + case WEEKLY_REPORT -> AgentType.REPORT; + case QUESTION_ROUTING -> AgentType.QUESTION; + }; + List shared = service.findSharedContext(user.getId(), 4); + List agent = service.findForAgent(user.getId(), agentType, 4); + String text = "shared=%s; agent=%s".formatted(memoryRecords(shared), memoryRecords(agent)); + return text.length() > 1200 ? text.substring(0, 1200) : text; + } + + private String memoryRecords(List records) { + if (records.isEmpty()) { + return "none"; + } + return records.stream() + .map(record -> "%s:%s".formatted(record.type(), value(record.content()))) + .reduce((left, right) -> left + " | " + right) + .orElse("none"); + } + private String profileText(LifeProfileEntity profile, UserLanguage language) { if (profile == null) { return "not available"; diff --git a/src/main/java/com/example/atlas/memory/MemorySnapshotWriter.java b/src/main/java/com/example/atlas/memory/MemorySnapshotWriter.java new file mode 100644 index 0000000..05dfdc0 --- /dev/null +++ b/src/main/java/com/example/atlas/memory/MemorySnapshotWriter.java @@ -0,0 +1,62 @@ +package com.example.atlas.memory; + +import com.example.atlas.agent.AgentType; +import com.example.atlas.config.AtlasProperties; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +@Component +public class MemorySnapshotWriter { + + private final AtlasProperties properties; + + public MemorySnapshotWriter(AtlasProperties properties) { + this.properties = properties; + } + + public void writeSnapshots(UUID userId, List records) { + if (!properties.memory().snapshotsEnabled()) { + return; + } + Path root = Path.of(properties.memory().snapshotPath(), "users", userId.toString()); + try { + Files.createDirectories(root.resolve("agents")); + Files.writeString(root.resolve("shared-context.md"), render(records.stream() + .filter(record -> record.scope() == MemoryScope.SHARED_CONTEXT) + .toList())); + for (AgentType agentType : AgentType.values()) { + Files.writeString(root.resolve("agents").resolve(agentType.name().toLowerCase() + ".md"), render(records.stream() + .filter(record -> record.agentType() == agentType) + .toList())); + } + } catch (IOException exception) { + throw new IllegalStateException("Unable to write memory snapshot", exception); + } + } + + private String render(List records) { + if (records.isEmpty()) { + return "# ATLAS memory\n\nNo records.\n"; + } + StringBuilder builder = new StringBuilder("# ATLAS memory\n\n"); + records.stream() + .sorted(Comparator.comparing(AgentMemoryRecord::updatedAt).reversed()) + .forEach(record -> builder + .append("## ").append(safe(record.title())).append("\n\n") + .append("- Type: ").append(record.type()).append("\n") + .append("- Scope: ").append(record.scope()).append("\n") + .append("- Confidence: ").append(record.confidence()).append("\n\n") + .append(safe(record.content())).append("\n\n")); + return builder.toString(); + } + + private String safe(String value) { + return value == null ? "" : value.replaceAll("(?i)(token|api key|secret|password)\\s*[:=]\\s*\\S+", "$1="); + } +} diff --git a/src/main/java/com/example/atlas/memory/PersistentAgentMemoryService.java b/src/main/java/com/example/atlas/memory/PersistentAgentMemoryService.java new file mode 100644 index 0000000..1b8fca2 --- /dev/null +++ b/src/main/java/com/example/atlas/memory/PersistentAgentMemoryService.java @@ -0,0 +1,144 @@ +package com.example.atlas.memory; + +import com.example.atlas.agent.AgentType; +import com.example.atlas.memory.entity.AgentMemoryRecordEntity; +import com.example.atlas.memory.repository.AgentMemoryRecordRepository; +import com.example.atlas.user.entity.TelegramUserEntity; +import com.example.atlas.user.repository.TelegramUserRepository; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Service +@ConditionalOnBean({AgentMemoryRecordRepository.class, TelegramUserRepository.class}) +public class PersistentAgentMemoryService implements AgentMemoryService { + + private final AgentMemoryRecordRepository repository; + private final TelegramUserRepository userRepository; + private final MemoryWritePolicy policy; + private final MemorySnapshotWriter snapshotWriter; + + public PersistentAgentMemoryService( + AgentMemoryRecordRepository repository, + TelegramUserRepository userRepository, + MemorySnapshotWriter snapshotWriter + ) { + this.repository = repository; + this.userRepository = userRepository; + this.snapshotWriter = snapshotWriter; + this.policy = new MemoryWritePolicy(); + } + + @Override + @Transactional + public MemoryWriteResult write(MemoryWrite write) { + MemoryValidationResult validation = policy.validate(write); + if (!validation.valid()) { + return MemoryWriteResult.rejected(validation.reason()); + } + TelegramUserEntity user = userRepository.findById(write.userId()).orElse(null); + if (user == null) { + return MemoryWriteResult.rejected("unknown_user"); + } + String deduplicationKey = policy.deduplicationKey(write); + if (repository.findByInternalUserIdAndDeduplicationKeyAndArchivedFalse(user.getId(), deduplicationKey).isPresent()) { + return MemoryWriteResult.rejected("duplicate"); + } + Instant now = Instant.now(); + AgentMemoryRecordEntity entity = new AgentMemoryRecordEntity( + UUID.randomUUID(), + user, + write.ownerAgent(), + write.type(), + write.scope(), + write.title(), + write.content(), + write.confidence(), + tags(write.tags()), + write.source(), + deduplicationKey, + now, + now, + write.expiresAt(), + false + ); + repository.save(entity); + snapshotWriter.writeSnapshots(user.getId(), findRecent(user.getId(), 50)); + return MemoryWriteResult.stored(entity.getId()); + } + + @Override + @Transactional(readOnly = true) + public List findForAgent(UUID userId, AgentType agentType, int limit) { + return repository.findByInternalUserIdAndAgentTypeAndArchivedFalseOrderByUpdatedAtDesc(userId, agentType, PageRequest.of(0, Math.max(1, limit))) + .stream() + .map(this::toRecord) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public List findSharedContext(UUID userId, int limit) { + return repository.findByInternalUserIdAndMemoryScopeAndArchivedFalseOrderByUpdatedAtDesc(userId, MemoryScope.SHARED_CONTEXT, PageRequest.of(0, Math.max(1, limit))) + .stream() + .map(this::toRecord) + .toList(); + } + + @Override + @Transactional + public void archiveForUser(UUID userId) { + Instant now = Instant.now(); + repository.findByInternalUserIdAndArchivedFalseOrderByUpdatedAtDesc(userId, PageRequest.of(0, 1000)) + .forEach(record -> record.archive(now)); + } + + @Transactional(readOnly = true) + public long countActive(UUID userId) { + return repository.countByInternalUserIdAndArchivedFalse(userId); + } + + @Transactional(readOnly = true) + public List findRecent(UUID userId, int limit) { + return repository.findByInternalUserIdAndArchivedFalseOrderByUpdatedAtDesc(userId, PageRequest.of(0, Math.max(1, limit))) + .stream() + .map(this::toRecord) + .toList(); + } + + private AgentMemoryRecord toRecord(AgentMemoryRecordEntity entity) { + return new AgentMemoryRecord( + entity.getId(), + entity.getInternalUserId(), + entity.getAgentType(), + entity.getMemoryType(), + entity.getMemoryScope(), + entity.getTitle(), + entity.getContent(), + entity.getConfidence(), + parseTags(entity.getTags()), + entity.getSource(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getExpiresAt(), + entity.isArchived() + ); + } + + private String tags(List tags) { + return tags.stream().map(MemoryTag::value).distinct().sorted().reduce((left, right) -> left + "," + right).orElse(""); + } + + private List parseTags(String tags) { + if (tags == null || tags.isBlank()) { + return List.of(); + } + return Arrays.stream(tags.split(",")).map(MemoryTag::new).toList(); + } +} diff --git a/src/main/java/com/example/atlas/memory/entity/AgentMemoryRecordEntity.java b/src/main/java/com/example/atlas/memory/entity/AgentMemoryRecordEntity.java new file mode 100644 index 0000000..b90ce13 --- /dev/null +++ b/src/main/java/com/example/atlas/memory/entity/AgentMemoryRecordEntity.java @@ -0,0 +1,181 @@ +package com.example.atlas.memory.entity; + +import com.example.atlas.agent.AgentType; +import com.example.atlas.memory.MemoryConfidence; +import com.example.atlas.memory.MemoryScope; +import com.example.atlas.memory.MemorySource; +import com.example.atlas.memory.MemoryType; +import com.example.atlas.user.entity.TelegramUserEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "agent_memory_records") +public class AgentMemoryRecordEntity { + + @Id + private UUID id; + + @ManyToOne + @JoinColumn(name = "telegram_user_id", nullable = false) + private TelegramUserEntity telegramUser; + + @Column(name = "internal_user_id", nullable = false) + private UUID internalUserId; + + @Enumerated(EnumType.STRING) + @Column(name = "agent_type", nullable = false) + private AgentType agentType; + + @Enumerated(EnumType.STRING) + @Column(name = "memory_type", nullable = false) + private MemoryType memoryType; + + @Enumerated(EnumType.STRING) + @Column(name = "memory_scope", nullable = false) + private MemoryScope memoryScope; + + @Column(columnDefinition = "text") + private String title; + + @Column(columnDefinition = "text", nullable = false) + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MemoryConfidence confidence; + + @Column(columnDefinition = "text") + private String tags; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MemorySource source; + + @Column(name = "deduplication_key", nullable = false) + private String deduplicationKey; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "expires_at") + private Instant expiresAt; + + @Column(nullable = false) + private boolean archived; + + protected AgentMemoryRecordEntity() { + } + + public AgentMemoryRecordEntity( + UUID id, + TelegramUserEntity telegramUser, + AgentType agentType, + MemoryType memoryType, + MemoryScope memoryScope, + String title, + String content, + MemoryConfidence confidence, + String tags, + MemorySource source, + String deduplicationKey, + Instant createdAt, + Instant updatedAt, + Instant expiresAt, + boolean archived + ) { + this.id = id; + this.telegramUser = telegramUser; + this.internalUserId = telegramUser.getId(); + this.agentType = agentType; + this.memoryType = memoryType; + this.memoryScope = memoryScope; + this.title = title; + this.content = content; + this.confidence = confidence; + this.tags = tags; + this.source = source; + this.deduplicationKey = deduplicationKey; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.expiresAt = expiresAt; + this.archived = archived; + } + + public void archive(Instant now) { + this.archived = true; + this.updatedAt = now; + } + + public UUID getId() { + return id; + } + + public TelegramUserEntity getTelegramUser() { + return telegramUser; + } + + public UUID getInternalUserId() { + return internalUserId; + } + + public AgentType getAgentType() { + return agentType; + } + + public MemoryType getMemoryType() { + return memoryType; + } + + public MemoryScope getMemoryScope() { + return memoryScope; + } + + public String getTitle() { + return title; + } + + public String getContent() { + return content; + } + + public MemoryConfidence getConfidence() { + return confidence; + } + + public String getTags() { + return tags; + } + + public MemorySource getSource() { + return source; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public boolean isArchived() { + return archived; + } +} diff --git a/src/main/java/com/example/atlas/memory/repository/AgentMemoryRecordRepository.java b/src/main/java/com/example/atlas/memory/repository/AgentMemoryRecordRepository.java new file mode 100644 index 0000000..095345f --- /dev/null +++ b/src/main/java/com/example/atlas/memory/repository/AgentMemoryRecordRepository.java @@ -0,0 +1,24 @@ +package com.example.atlas.memory.repository; + +import com.example.atlas.agent.AgentType; +import com.example.atlas.memory.MemoryScope; +import com.example.atlas.memory.entity.AgentMemoryRecordEntity; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface AgentMemoryRecordRepository extends JpaRepository { + + Optional findByInternalUserIdAndDeduplicationKeyAndArchivedFalse(UUID internalUserId, String deduplicationKey); + + List findByInternalUserIdAndAgentTypeAndArchivedFalseOrderByUpdatedAtDesc(UUID internalUserId, AgentType agentType, Pageable pageable); + + List findByInternalUserIdAndMemoryScopeAndArchivedFalseOrderByUpdatedAtDesc(UUID internalUserId, MemoryScope memoryScope, Pageable pageable); + + List findByInternalUserIdAndArchivedFalseOrderByUpdatedAtDesc(UUID internalUserId, Pageable pageable); + + long countByInternalUserIdAndArchivedFalse(UUID internalUserId); +} diff --git a/src/main/resources/db/migration/V5__v063_agent_memory.sql b/src/main/resources/db/migration/V5__v063_agent_memory.sql new file mode 100644 index 0000000..6efab91 --- /dev/null +++ b/src/main/resources/db/migration/V5__v063_agent_memory.sql @@ -0,0 +1,25 @@ +create table agent_memory_records ( + id uuid primary key, + telegram_user_id uuid not null references telegram_users(id), + internal_user_id uuid not null, + agent_type varchar(64) not null, + memory_type varchar(64) not null, + memory_scope varchar(64) not null, + title text, + content text not null, + confidence varchar(32) not null, + tags text, + source varchar(64) not null, + deduplication_key text not null, + created_at timestamptz not null, + updated_at timestamptz not null, + expires_at timestamptz, + archived boolean not null default false +); + +create index idx_agent_memory_user_updated on agent_memory_records(internal_user_id, updated_at desc); +create index idx_agent_memory_user_agent on agent_memory_records(internal_user_id, agent_type, updated_at desc); +create index idx_agent_memory_user_scope on agent_memory_records(internal_user_id, memory_scope, updated_at desc); +create unique index ux_agent_memory_active_dedup + on agent_memory_records(internal_user_id, deduplication_key) + where archived = false; From 940b81fd86ecab87a5ded98ec2cd512cf4e20c54 Mon Sep 17 00:00:00 2001 From: Alex Today Date: Tue, 9 Jun 2026 10:05:39 +0300 Subject: [PATCH 03/10] feat(deployment): add hosted privacy foundations --- .env.example | 9 ++ .github/workflows/ci.yml | 3 + docker-compose.yml | 4 + .../example/atlas/agent/core/CoreAgent.java | 17 ++- .../checkin/repository/CheckInRepository.java | 4 + .../example/atlas/config/AtlasProperties.java | 37 +++++- .../DeploymentConfigurationValidator.java | 20 ++++ .../atlas/deployment/DeploymentMode.java | 6 + .../deployment/DeploymentModeService.java | 43 +++++++ .../atlas/deployment/DeploymentStatus.java | 10 ++ .../repository/HabitCheckRepository.java | 4 + .../atlas/hosted/HostedRateLimiter.java | 39 +++++++ .../example/atlas/hosted/LlmQuotaService.java | 19 ++++ .../example/atlas/hosted/RateLimitBucket.java | 6 + .../repository/LifeProfileRepository.java | 2 + .../orchestrator/OrchestratorService.java | 7 ++ .../atlas/orchestrator/RequestType.java | 7 ++ .../example/atlas/privacy/PrivacyExport.java | 4 + .../example/atlas/privacy/PrivacyPanel.java | 11 ++ .../example/atlas/privacy/PrivacyService.java | 105 ++++++++++++++++++ .../EveningReflectionRepository.java | 4 + .../runtime/service/LocalLaunchState.java | 10 ++ src/main/resources/application.yml | 7 ++ .../atlas/hosted/HostedRateLimiterTest.java | 20 ++++ 24 files changed, 395 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/atlas/deployment/DeploymentConfigurationValidator.java create mode 100644 src/main/java/com/example/atlas/deployment/DeploymentMode.java create mode 100644 src/main/java/com/example/atlas/deployment/DeploymentModeService.java create mode 100644 src/main/java/com/example/atlas/deployment/DeploymentStatus.java create mode 100644 src/main/java/com/example/atlas/hosted/HostedRateLimiter.java create mode 100644 src/main/java/com/example/atlas/hosted/LlmQuotaService.java create mode 100644 src/main/java/com/example/atlas/hosted/RateLimitBucket.java create mode 100644 src/main/java/com/example/atlas/privacy/PrivacyExport.java create mode 100644 src/main/java/com/example/atlas/privacy/PrivacyPanel.java create mode 100644 src/main/java/com/example/atlas/privacy/PrivacyService.java create mode 100644 src/main/java/com/example/atlas/runtime/service/LocalLaunchState.java create mode 100644 src/test/java/com/example/atlas/hosted/HostedRateLimiterTest.java diff --git a/.env.example b/.env.example index 9f1fd52..97c205b 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ ATLAS_TELEGRAM_WEBHOOK_SECRET= # App SERVER_PORT=8080 +ATLAS_DEPLOYMENT_MODE=self_hosted +ATLAS_SETUP_ENABLED=true # Optional LLM configuration ATLAS_LLM_ENABLED=false @@ -27,6 +29,13 @@ ATLAS_LLM_DAY_PLAN_ENABLED=true ATLAS_LLM_REPORT_ENABLED=true ATLAS_LLM_QUESTION_ENABLED=true +# Optional memory snapshots +ATLAS_MEMORY_SNAPSHOTS_ENABLED=false +ATLAS_MEMORY_SNAPSHOT_PATH=/app/data/memory + +# Optional routines +ATLAS_ROUTINE_SCHEDULER_ENABLED=true + # Database POSTGRES_DB=atlas POSTGRES_USER=atlas diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1b3ce2..39856c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,9 @@ jobs: ATLAS_TELEGRAM_BOT_TOKEN: "" ATLAS_LLM_ENABLED: "false" ATLAS_LLM_API_KEY: "" + ATLAS_DEPLOYMENT_MODE: "self_hosted" + ATLAS_MEMORY_SNAPSHOTS_ENABLED: "false" + ATLAS_ROUTINE_SCHEDULER_ENABLED: "false" steps: - name: Check out repository diff --git a/docker-compose.yml b/docker-compose.yml index 30b36ab..7633df4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL:-jdbc:postgresql://atlas-postgres:5432/atlas} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME:-atlas} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:-atlas} + ATLAS_DEPLOYMENT_MODE: ${ATLAS_DEPLOYMENT_MODE:-self_hosted} ATLAS_SETUP_ENABLED: ${ATLAS_SETUP_ENABLED:-true} ATLAS_TELEGRAM_BOT_TOKEN: ${ATLAS_TELEGRAM_BOT_TOKEN:-} ATLAS_TELEGRAM_BOT_USERNAME: ${ATLAS_TELEGRAM_BOT_USERNAME:-} @@ -52,6 +53,9 @@ services: ATLAS_LLM_DAY_PLAN_ENABLED: ${ATLAS_LLM_DAY_PLAN_ENABLED:-true} ATLAS_LLM_REPORT_ENABLED: ${ATLAS_LLM_REPORT_ENABLED:-true} ATLAS_LLM_QUESTION_ENABLED: ${ATLAS_LLM_QUESTION_ENABLED:-true} + ATLAS_MEMORY_SNAPSHOTS_ENABLED: ${ATLAS_MEMORY_SNAPSHOTS_ENABLED:-false} + ATLAS_MEMORY_SNAPSHOT_PATH: ${ATLAS_MEMORY_SNAPSHOT_PATH:-/app/data/memory} + ATLAS_ROUTINE_SCHEDULER_ENABLED: ${ATLAS_ROUTINE_SCHEDULER_ENABLED:-true} volumes: - atlas-runtime-data:/app/data ports: diff --git a/src/main/java/com/example/atlas/agent/core/CoreAgent.java b/src/main/java/com/example/atlas/agent/core/CoreAgent.java index 2669022..cacb0ea 100644 --- a/src/main/java/com/example/atlas/agent/core/CoreAgent.java +++ b/src/main/java/com/example/atlas/agent/core/CoreAgent.java @@ -17,7 +17,15 @@ public String name() { @Override public boolean supports(RequestType requestType) { - return requestType == RequestType.START || requestType == RequestType.GENERAL; + return requestType == RequestType.START + || requestType == RequestType.GENERAL + || requestType == RequestType.PRIVACY + || requestType == RequestType.MEMORY + || requestType == RequestType.EXPORT + || requestType == RequestType.FORGET + || requestType == RequestType.DELETE_MY_DATA + || requestType == RequestType.ROUTINES + || requestType == RequestType.INTEGRATIONS; } @Override @@ -25,6 +33,13 @@ public AgentResult handle(AgentContext context) { String content = switch (context.requestType()) { case START -> TelegramReplyTemplates.startWelcome(); case GENERAL -> TelegramReplyTemplates.generalFallback(); + case PRIVACY -> "Privacy: ATLAS stores profile, check-ins, habits, reflections, reports, memory and Telegram identifiers. Use /export, /forget or /delete_my_data for data controls."; + case MEMORY -> "Memory: ATLAS can store user-scoped preferences, patterns and summaries. Raw sensitive content is not shown by default."; + case EXPORT -> "Export: ATLAS prepares user-scoped JSON and Markdown data export when persistence is available."; + case FORGET -> "Forget memory: this action clears memory only after explicit confirmation."; + case DELETE_MY_DATA -> "Delete my data: this destructive action requires explicit confirmation and applies only to the current user."; + case ROUTINES -> "Routines: check-in and evening reminder preferences include timezone, quiet hours and enabled state."; + case INTEGRATIONS -> "Integrations: Markdown export and calendar contracts are available as foundations without external sync."; default -> "Маршрут принят ATLAS Core."; }; diff --git a/src/main/java/com/example/atlas/checkin/repository/CheckInRepository.java b/src/main/java/com/example/atlas/checkin/repository/CheckInRepository.java index bccfefc..b6a1b65 100644 --- a/src/main/java/com/example/atlas/checkin/repository/CheckInRepository.java +++ b/src/main/java/com/example/atlas/checkin/repository/CheckInRepository.java @@ -16,6 +16,10 @@ public interface CheckInRepository extends JpaRepository { long countByTelegramUserAndCreatedAtAfter(TelegramUserEntity telegramUser, Instant createdAt); + long countByTelegramUser(TelegramUserEntity telegramUser); + + void deleteByTelegramUser(TelegramUserEntity telegramUser); + long countByCreatedAtAfter(Instant createdAt); List findTop20ByOrderByCreatedAtDesc(); diff --git a/src/main/java/com/example/atlas/config/AtlasProperties.java b/src/main/java/com/example/atlas/config/AtlasProperties.java index 36b9bba..10ba19d 100644 --- a/src/main/java/com/example/atlas/config/AtlasProperties.java +++ b/src/main/java/com/example/atlas/config/AtlasProperties.java @@ -1,15 +1,20 @@ package com.example.atlas.config; import com.example.atlas.llm.LlmProvider; +import com.example.atlas.deployment.DeploymentMode; import com.example.atlas.runtime.entity.TelegramLaunchMode; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.ConstructorBinding; @ConfigurationProperties(prefix = "atlas") -public record AtlasProperties(Telegram telegram, Setup setup, Llm llm) { +public record AtlasProperties(Telegram telegram, Setup setup, Llm llm, Deployment deployment, Memory memory, Routines routines) { public AtlasProperties(Telegram telegram) { - this(telegram, null, null); + this(telegram, null, null, null, null, null); + } + + public AtlasProperties(Telegram telegram, Setup setup, Llm llm) { + this(telegram, setup, llm, null, null, null); } @ConstructorBinding @@ -38,6 +43,15 @@ public AtlasProperties(Telegram telegram) { true ); } + if (deployment == null) { + deployment = new Deployment(DeploymentMode.SELF_HOSTED); + } + if (memory == null) { + memory = new Memory(false, "/app/data/memory"); + } + if (routines == null) { + routines = new Routines(true); + } } public record Telegram( @@ -158,4 +172,23 @@ private static String defaultString(String value) { return value == null ? "" : value; } } + + public record Deployment(DeploymentMode mode) { + public Deployment { + mode = mode == null ? DeploymentMode.SELF_HOSTED : mode; + } + + public boolean hosted() { + return mode == DeploymentMode.HOSTED; + } + } + + public record Memory(boolean snapshotsEnabled, String snapshotPath) { + public Memory { + snapshotPath = snapshotPath == null || snapshotPath.isBlank() ? "/app/data/memory" : snapshotPath; + } + } + + public record Routines(boolean schedulerEnabled) { + } } diff --git a/src/main/java/com/example/atlas/deployment/DeploymentConfigurationValidator.java b/src/main/java/com/example/atlas/deployment/DeploymentConfigurationValidator.java new file mode 100644 index 0000000..629e85b --- /dev/null +++ b/src/main/java/com/example/atlas/deployment/DeploymentConfigurationValidator.java @@ -0,0 +1,20 @@ +package com.example.atlas.deployment; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class DeploymentConfigurationValidator implements ApplicationRunner { + + private final DeploymentModeService deploymentModeService; + + public DeploymentConfigurationValidator(DeploymentModeService deploymentModeService) { + this.deploymentModeService = deploymentModeService; + } + + @Override + public void run(ApplicationArguments args) { + deploymentModeService.validate(); + } +} diff --git a/src/main/java/com/example/atlas/deployment/DeploymentMode.java b/src/main/java/com/example/atlas/deployment/DeploymentMode.java new file mode 100644 index 0000000..bd4253a --- /dev/null +++ b/src/main/java/com/example/atlas/deployment/DeploymentMode.java @@ -0,0 +1,6 @@ +package com.example.atlas.deployment; + +public enum DeploymentMode { + SELF_HOSTED, + HOSTED +} diff --git a/src/main/java/com/example/atlas/deployment/DeploymentModeService.java b/src/main/java/com/example/atlas/deployment/DeploymentModeService.java new file mode 100644 index 0000000..582e89b --- /dev/null +++ b/src/main/java/com/example/atlas/deployment/DeploymentModeService.java @@ -0,0 +1,43 @@ +package com.example.atlas.deployment; + +import com.example.atlas.config.AtlasProperties; +import com.example.atlas.runtime.entity.TelegramLaunchMode; +import org.springframework.stereotype.Service; + +@Service +public class DeploymentModeService { + + private final AtlasProperties properties; + + public DeploymentModeService(AtlasProperties properties) { + this.properties = properties; + } + + public DeploymentStatus status() { + boolean webhookMode = properties.telegram().mode() == TelegramLaunchMode.WEBHOOK; + return new DeploymentStatus( + properties.deployment().mode(), + properties.setup().enabled(), + properties.telegram().enabled(), + webhookMode, + isSafe() + ); + } + + public void validate() { + if (!isSafe()) { + throw new IllegalStateException("Unsafe ATLAS deployment configuration"); + } + } + + public boolean isSafe() { + if (!properties.deployment().hosted()) { + return true; + } + return !properties.setup().enabled() + && properties.telegram().enabled() + && properties.telegram().hasBotToken() + && properties.telegram().mode() == TelegramLaunchMode.WEBHOOK + && properties.telegram().effectiveWebhookUrl() != null; + } +} diff --git a/src/main/java/com/example/atlas/deployment/DeploymentStatus.java b/src/main/java/com/example/atlas/deployment/DeploymentStatus.java new file mode 100644 index 0000000..08bd12f --- /dev/null +++ b/src/main/java/com/example/atlas/deployment/DeploymentStatus.java @@ -0,0 +1,10 @@ +package com.example.atlas.deployment; + +public record DeploymentStatus( + DeploymentMode mode, + boolean setupEnabled, + boolean telegramEnabled, + boolean webhookMode, + boolean safe +) { +} diff --git a/src/main/java/com/example/atlas/habit/repository/HabitCheckRepository.java b/src/main/java/com/example/atlas/habit/repository/HabitCheckRepository.java index 6427057..8c4ca03 100644 --- a/src/main/java/com/example/atlas/habit/repository/HabitCheckRepository.java +++ b/src/main/java/com/example/atlas/habit/repository/HabitCheckRepository.java @@ -11,4 +11,8 @@ public interface HabitCheckRepository extends JpaRepository { List findByTelegramUserAndCreatedAtAfterOrderByCreatedAtDesc(TelegramUserEntity telegramUser, Instant createdAt); + + long countByTelegramUser(TelegramUserEntity telegramUser); + + void deleteByTelegramUser(TelegramUserEntity telegramUser); } diff --git a/src/main/java/com/example/atlas/hosted/HostedRateLimiter.java b/src/main/java/com/example/atlas/hosted/HostedRateLimiter.java new file mode 100644 index 0000000..0b52199 --- /dev/null +++ b/src/main/java/com/example/atlas/hosted/HostedRateLimiter.java @@ -0,0 +1,39 @@ +package com.example.atlas.hosted; + +import org.springframework.stereotype.Service; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class HostedRateLimiter { + + private final Map buckets = new ConcurrentHashMap<>(); + private final Clock clock; + + public HostedRateLimiter() { + this(Clock.systemUTC()); + } + + HostedRateLimiter(Clock clock) { + this.clock = clock; + } + + public boolean allow(Long userId, String operation, int limit, Duration window) { + String key = userId + ":" + operation; + Instant now = Instant.now(clock); + RateLimitBucket updated = buckets.compute(key, (ignored, current) -> { + if (current == null || !current.resetAt().isAfter(now)) { + return new RateLimitBucket(1, now.plus(window), true); + } + if (current.used() >= limit) { + return new RateLimitBucket(current.used(), current.resetAt(), false); + } + return new RateLimitBucket(current.used() + 1, current.resetAt(), true); + }); + return updated.allowed(); + } +} diff --git a/src/main/java/com/example/atlas/hosted/LlmQuotaService.java b/src/main/java/com/example/atlas/hosted/LlmQuotaService.java new file mode 100644 index 0000000..945d79f --- /dev/null +++ b/src/main/java/com/example/atlas/hosted/LlmQuotaService.java @@ -0,0 +1,19 @@ +package com.example.atlas.hosted; + +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +public class LlmQuotaService { + + private final HostedRateLimiter rateLimiter; + + public LlmQuotaService(HostedRateLimiter rateLimiter) { + this.rateLimiter = rateLimiter; + } + + public boolean allowLlmCall(Long userId) { + return rateLimiter.allow(userId, "llm", 40, Duration.ofDays(1)); + } +} diff --git a/src/main/java/com/example/atlas/hosted/RateLimitBucket.java b/src/main/java/com/example/atlas/hosted/RateLimitBucket.java new file mode 100644 index 0000000..371d9bb --- /dev/null +++ b/src/main/java/com/example/atlas/hosted/RateLimitBucket.java @@ -0,0 +1,6 @@ +package com.example.atlas.hosted; + +import java.time.Instant; + +record RateLimitBucket(int used, Instant resetAt, boolean allowed) { +} diff --git a/src/main/java/com/example/atlas/life/repository/LifeProfileRepository.java b/src/main/java/com/example/atlas/life/repository/LifeProfileRepository.java index bd8da39..e19ae0f 100644 --- a/src/main/java/com/example/atlas/life/repository/LifeProfileRepository.java +++ b/src/main/java/com/example/atlas/life/repository/LifeProfileRepository.java @@ -10,4 +10,6 @@ public interface LifeProfileRepository extends JpaRepository { Optional findByTelegramUser(TelegramUserEntity telegramUser); + + void deleteByTelegramUser(TelegramUserEntity telegramUser); } diff --git a/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java b/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java index cc71517..e6948b5 100644 --- a/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java +++ b/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java @@ -74,6 +74,13 @@ public RequestType resolveRequestType(String message) { case "/evening", "/review" -> RequestType.EVENING_REFLECTION; case "/food" -> RequestType.FOOD; case "/report" -> RequestType.REPORT; + case "/privacy" -> RequestType.PRIVACY; + case "/memory" -> RequestType.MEMORY; + case "/export" -> RequestType.EXPORT; + case "/forget" -> RequestType.FORGET; + case "/delete_my_data" -> RequestType.DELETE_MY_DATA; + case "/routines" -> RequestType.ROUTINES; + case "/integrations" -> RequestType.INTEGRATIONS; case "/emergency" -> RequestType.EMERGENCY; case "/help" -> RequestType.HELP; case "/cancel" -> RequestType.CANCEL; diff --git a/src/main/java/com/example/atlas/orchestrator/RequestType.java b/src/main/java/com/example/atlas/orchestrator/RequestType.java index 1b65826..4cc07d5 100644 --- a/src/main/java/com/example/atlas/orchestrator/RequestType.java +++ b/src/main/java/com/example/atlas/orchestrator/RequestType.java @@ -11,6 +11,13 @@ public enum RequestType { EVENING_REFLECTION, FOOD, REPORT, + PRIVACY, + MEMORY, + EXPORT, + FORGET, + DELETE_MY_DATA, + ROUTINES, + INTEGRATIONS, EMERGENCY, HELP, CANCEL, diff --git a/src/main/java/com/example/atlas/privacy/PrivacyExport.java b/src/main/java/com/example/atlas/privacy/PrivacyExport.java new file mode 100644 index 0000000..34e40e8 --- /dev/null +++ b/src/main/java/com/example/atlas/privacy/PrivacyExport.java @@ -0,0 +1,4 @@ +package com.example.atlas.privacy; + +public record PrivacyExport(String json, String markdown) { +} diff --git a/src/main/java/com/example/atlas/privacy/PrivacyPanel.java b/src/main/java/com/example/atlas/privacy/PrivacyPanel.java new file mode 100644 index 0000000..ffb2d5c --- /dev/null +++ b/src/main/java/com/example/atlas/privacy/PrivacyPanel.java @@ -0,0 +1,11 @@ +package com.example.atlas.privacy; + +public record PrivacyPanel( + long profileCount, + long checkInCount, + long habitCount, + long reflectionCount, + long memoryCount, + boolean telegramIdentifiersStored +) { +} diff --git a/src/main/java/com/example/atlas/privacy/PrivacyService.java b/src/main/java/com/example/atlas/privacy/PrivacyService.java new file mode 100644 index 0000000..bdcb1ea --- /dev/null +++ b/src/main/java/com/example/atlas/privacy/PrivacyService.java @@ -0,0 +1,105 @@ +package com.example.atlas.privacy; + +import com.example.atlas.checkin.repository.CheckInRepository; +import com.example.atlas.habit.repository.HabitCheckRepository; +import com.example.atlas.life.repository.LifeProfileRepository; +import com.example.atlas.memory.PersistentAgentMemoryService; +import com.example.atlas.reflection.repository.EveningReflectionRepository; +import com.example.atlas.user.entity.TelegramUserEntity; +import com.example.atlas.user.repository.TelegramUserRepository; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +@ConditionalOnBean({ + TelegramUserRepository.class, + LifeProfileRepository.class, + CheckInRepository.class, + HabitCheckRepository.class, + EveningReflectionRepository.class, + PersistentAgentMemoryService.class +}) +public class PrivacyService { + + private final TelegramUserRepository userRepository; + private final LifeProfileRepository lifeProfileRepository; + private final CheckInRepository checkInRepository; + private final HabitCheckRepository habitCheckRepository; + private final EveningReflectionRepository reflectionRepository; + private final PersistentAgentMemoryService memoryService; + + public PrivacyService( + TelegramUserRepository userRepository, + LifeProfileRepository lifeProfileRepository, + CheckInRepository checkInRepository, + HabitCheckRepository habitCheckRepository, + EveningReflectionRepository reflectionRepository, + PersistentAgentMemoryService memoryService + ) { + this.userRepository = userRepository; + this.lifeProfileRepository = lifeProfileRepository; + this.checkInRepository = checkInRepository; + this.habitCheckRepository = habitCheckRepository; + this.reflectionRepository = reflectionRepository; + this.memoryService = memoryService; + } + + @Transactional(readOnly = true) + public PrivacyPanel panel(UUID userId) { + TelegramUserEntity user = userRepository.findById(userId).orElseThrow(); + return new PrivacyPanel( + lifeProfileRepository.findByTelegramUser(user).isPresent() ? 1 : 0, + checkInRepository.countByTelegramUser(user), + habitCheckRepository.countByTelegramUser(user), + reflectionRepository.countByTelegramUser(user), + memoryService.countActive(userId), + true + ); + } + + @Transactional(readOnly = true) + public PrivacyExport export(UUID userId) { + PrivacyPanel panel = panel(userId); + String json = """ + {"userId":"%s","profileCount":%d,"checkInCount":%d,"habitCount":%d,"reflectionCount":%d,"memoryCount":%d} + """.formatted(userId, panel.profileCount(), panel.checkInCount(), panel.habitCount(), panel.reflectionCount(), panel.memoryCount()).strip(); + String markdown = """ + # ATLAS export + + User: %s + Profile records: %d + Check-ins: %d + Habits: %d + Reflections: %d + Memory records: %d + """.formatted(userId, panel.profileCount(), panel.checkInCount(), panel.habitCount(), panel.reflectionCount(), panel.memoryCount()); + return new PrivacyExport(json, markdown); + } + + @Transactional + public void forgetMemory(UUID userId, String confirmation) { + requireConfirmation(confirmation); + memoryService.archiveForUser(userId); + } + + @Transactional + public void deleteMyData(UUID userId, String confirmation) { + requireConfirmation(confirmation); + TelegramUserEntity user = userRepository.findById(userId).orElseThrow(); + memoryService.archiveForUser(userId); + reflectionRepository.deleteByTelegramUser(user); + habitCheckRepository.deleteByTelegramUser(user); + checkInRepository.deleteByTelegramUser(user); + lifeProfileRepository.deleteByTelegramUser(user); + userRepository.delete(user); + } + + private void requireConfirmation(String confirmation) { + if (!"DELETE".equals(confirmation)) { + throw new IllegalArgumentException("confirmation_required"); + } + } +} diff --git a/src/main/java/com/example/atlas/reflection/repository/EveningReflectionRepository.java b/src/main/java/com/example/atlas/reflection/repository/EveningReflectionRepository.java index 48c3fba..fafb8fe 100644 --- a/src/main/java/com/example/atlas/reflection/repository/EveningReflectionRepository.java +++ b/src/main/java/com/example/atlas/reflection/repository/EveningReflectionRepository.java @@ -11,4 +11,8 @@ public interface EveningReflectionRepository extends JpaRepository { List findByTelegramUserAndCreatedAtAfterOrderByCreatedAtDesc(TelegramUserEntity telegramUser, Instant createdAt); + + long countByTelegramUser(TelegramUserEntity telegramUser); + + void deleteByTelegramUser(TelegramUserEntity telegramUser); } diff --git a/src/main/java/com/example/atlas/runtime/service/LocalLaunchState.java b/src/main/java/com/example/atlas/runtime/service/LocalLaunchState.java new file mode 100644 index 0000000..82f73fe --- /dev/null +++ b/src/main/java/com/example/atlas/runtime/service/LocalLaunchState.java @@ -0,0 +1,10 @@ +package com.example.atlas.runtime.service; + +public enum LocalLaunchState { + SETUP_REQUIRED, + SETUP_ERROR, + TELEGRAM_DISABLED, + TELEGRAM_POLLING_ACTIVE, + TELEGRAM_WEBHOOK_ACTIVE, + READY +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 09a8b29..c9fb219 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,8 @@ management: enabled: true atlas: + deployment: + mode: ${ATLAS_DEPLOYMENT_MODE:self_hosted} setup: enabled: ${ATLAS_SETUP_ENABLED:true} telegram: @@ -51,3 +53,8 @@ atlas: day-plan-enabled: ${ATLAS_LLM_DAY_PLAN_ENABLED:true} report-enabled: ${ATLAS_LLM_REPORT_ENABLED:true} question-enabled: ${ATLAS_LLM_QUESTION_ENABLED:true} + memory: + snapshots-enabled: ${ATLAS_MEMORY_SNAPSHOTS_ENABLED:false} + snapshot-path: ${ATLAS_MEMORY_SNAPSHOT_PATH:/app/data/memory} + routines: + scheduler-enabled: ${ATLAS_ROUTINE_SCHEDULER_ENABLED:true} diff --git a/src/test/java/com/example/atlas/hosted/HostedRateLimiterTest.java b/src/test/java/com/example/atlas/hosted/HostedRateLimiterTest.java new file mode 100644 index 0000000..203f394 --- /dev/null +++ b/src/test/java/com/example/atlas/hosted/HostedRateLimiterTest.java @@ -0,0 +1,20 @@ +package com.example.atlas.hosted; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +class HostedRateLimiterTest { + + @Test + void blocksCallsAfterLimit() { + HostedRateLimiter limiter = new HostedRateLimiter(); + + assertThat(limiter.allow(1L, "question", 2, Duration.ofMinutes(1))).isTrue(); + assertThat(limiter.allow(1L, "question", 2, Duration.ofMinutes(1))).isTrue(); + assertThat(limiter.allow(1L, "question", 2, Duration.ofMinutes(1))).isFalse(); + assertThat(limiter.allow(2L, "question", 2, Duration.ofMinutes(1))).isTrue(); + } +} From 6611b75b97eab0f5e157e54e1f3fd21452e4522c Mon Sep 17 00:00:00 2001 From: Alex Today Date: Tue, 9 Jun 2026 10:06:14 +0300 Subject: [PATCH 04/10] feat(routines): add planning and report foundations --- .../atlas/planning/WeeklyPlanningService.java | 36 +++++++ .../planning/entity/WeeklyFocusEntity.java | 49 ++++++++++ .../repository/WeeklyFocusRepository.java | 14 +++ .../reporting/TrendDetectionService.java | 52 ++++++++++ .../example/atlas/reporting/TrendSummary.java | 4 + .../reporting/entity/ReportArchiveEntity.java | 45 +++++++++ .../repository/ReportArchiveRepository.java | 13 +++ .../routines/ReminderSchedulerService.java | 29 ++++++ .../routines/RoutinePreferencesService.java | 26 +++++ .../entity/RoutinePreferencesEntity.java | 96 +++++++++++++++++++ .../RoutinePreferencesRepository.java | 13 +++ .../V6__v080_routines_planning_reports.sql | 31 ++++++ .../reporting/TrendDetectionServiceTest.java | 29 ++++++ .../ReminderSchedulerServiceTest.java | 24 +++++ 14 files changed, 461 insertions(+) create mode 100644 src/main/java/com/example/atlas/planning/WeeklyPlanningService.java create mode 100644 src/main/java/com/example/atlas/planning/entity/WeeklyFocusEntity.java create mode 100644 src/main/java/com/example/atlas/planning/repository/WeeklyFocusRepository.java create mode 100644 src/main/java/com/example/atlas/reporting/TrendDetectionService.java create mode 100644 src/main/java/com/example/atlas/reporting/TrendSummary.java create mode 100644 src/main/java/com/example/atlas/reporting/entity/ReportArchiveEntity.java create mode 100644 src/main/java/com/example/atlas/reporting/repository/ReportArchiveRepository.java create mode 100644 src/main/java/com/example/atlas/routines/ReminderSchedulerService.java create mode 100644 src/main/java/com/example/atlas/routines/RoutinePreferencesService.java create mode 100644 src/main/java/com/example/atlas/routines/entity/RoutinePreferencesEntity.java create mode 100644 src/main/java/com/example/atlas/routines/repository/RoutinePreferencesRepository.java create mode 100644 src/main/resources/db/migration/V6__v080_routines_planning_reports.sql create mode 100644 src/test/java/com/example/atlas/reporting/TrendDetectionServiceTest.java create mode 100644 src/test/java/com/example/atlas/routines/ReminderSchedulerServiceTest.java diff --git a/src/main/java/com/example/atlas/planning/WeeklyPlanningService.java b/src/main/java/com/example/atlas/planning/WeeklyPlanningService.java new file mode 100644 index 0000000..d1ca82f --- /dev/null +++ b/src/main/java/com/example/atlas/planning/WeeklyPlanningService.java @@ -0,0 +1,36 @@ +package com.example.atlas.planning; + +import com.example.atlas.planning.entity.WeeklyFocusEntity; +import com.example.atlas.planning.repository.WeeklyFocusRepository; +import com.example.atlas.user.entity.TelegramUserEntity; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.UUID; + +@Service +@ConditionalOnBean(WeeklyFocusRepository.class) +public class WeeklyPlanningService { + + private final WeeklyFocusRepository repository; + + public WeeklyPlanningService(WeeklyFocusRepository repository) { + this.repository = repository; + } + + @Transactional + public WeeklyFocusEntity saveFocus(TelegramUserEntity user, LocalDate date, String focus) { + LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)); + return repository.save(new WeeklyFocusEntity(UUID.randomUUID(), user, weekStart, focus.strip(), Instant.now())); + } + + @Transactional(readOnly = true) + public String currentFocus(TelegramUserEntity user, LocalDate date) { + LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)); + return repository.findByTelegramUserAndWeekStart(user, weekStart).map(WeeklyFocusEntity::getFocus).orElse(""); + } +} diff --git a/src/main/java/com/example/atlas/planning/entity/WeeklyFocusEntity.java b/src/main/java/com/example/atlas/planning/entity/WeeklyFocusEntity.java new file mode 100644 index 0000000..9fe2729 --- /dev/null +++ b/src/main/java/com/example/atlas/planning/entity/WeeklyFocusEntity.java @@ -0,0 +1,49 @@ +package com.example.atlas.planning.entity; + +import com.example.atlas.user.entity.TelegramUserEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "weekly_focuses") +public class WeeklyFocusEntity { + + @Id + private UUID id; + + @ManyToOne + @JoinColumn(name = "telegram_user_id", nullable = false) + private TelegramUserEntity telegramUser; + + @Column(name = "week_start", nullable = false) + private LocalDate weekStart; + + @Column(columnDefinition = "text", nullable = false) + private String focus; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected WeeklyFocusEntity() { + } + + public WeeklyFocusEntity(UUID id, TelegramUserEntity telegramUser, LocalDate weekStart, String focus, Instant createdAt) { + this.id = id; + this.telegramUser = telegramUser; + this.weekStart = weekStart; + this.focus = focus; + this.createdAt = createdAt; + } + + public String getFocus() { + return focus; + } +} diff --git a/src/main/java/com/example/atlas/planning/repository/WeeklyFocusRepository.java b/src/main/java/com/example/atlas/planning/repository/WeeklyFocusRepository.java new file mode 100644 index 0000000..25dec57 --- /dev/null +++ b/src/main/java/com/example/atlas/planning/repository/WeeklyFocusRepository.java @@ -0,0 +1,14 @@ +package com.example.atlas.planning.repository; + +import com.example.atlas.planning.entity.WeeklyFocusEntity; +import com.example.atlas.user.entity.TelegramUserEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +public interface WeeklyFocusRepository extends JpaRepository { + + Optional findByTelegramUserAndWeekStart(TelegramUserEntity telegramUser, LocalDate weekStart); +} diff --git a/src/main/java/com/example/atlas/reporting/TrendDetectionService.java b/src/main/java/com/example/atlas/reporting/TrendDetectionService.java new file mode 100644 index 0000000..fef9ca7 --- /dev/null +++ b/src/main/java/com/example/atlas/reporting/TrendDetectionService.java @@ -0,0 +1,52 @@ +package com.example.atlas.reporting; + +import com.example.atlas.checkin.entity.CheckInEntity; +import com.example.atlas.habit.entity.HabitCheckEntity; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class TrendDetectionService { + + public TrendSummary summarize(List checkIns, List habits) { + return new TrendSummary( + trend(checkIns.stream().map(CheckInEntity::getEnergy).toList()), + trend(checkIns.stream().map(CheckInEntity::getFocus).toList()), + trend(checkIns.stream().map(CheckInEntity::getStress).toList()), + trend(checkIns.stream().map(CheckInEntity::getSleepQuality).toList()), + habitConsistency(habits) + ); + } + + private String trend(List values) { + List present = values.stream().filter(value -> value != null).toList(); + if (present.size() < 2) { + return "not_enough_data"; + } + int first = present.getLast(); + int last = present.getFirst(); + if (last >= first + 2) { + return "up"; + } + if (last <= first - 2) { + return "down"; + } + return "stable"; + } + + private String habitConsistency(List habits) { + if (habits.isEmpty()) { + return "not_enough_data"; + } + long completed = habits.stream().filter(HabitCheckEntity::isCompleted).count(); + double ratio = (double) completed / habits.size(); + if (ratio >= 0.8) { + return "high"; + } + if (ratio >= 0.4) { + return "mixed"; + } + return "low"; + } +} diff --git a/src/main/java/com/example/atlas/reporting/TrendSummary.java b/src/main/java/com/example/atlas/reporting/TrendSummary.java new file mode 100644 index 0000000..cfd7467 --- /dev/null +++ b/src/main/java/com/example/atlas/reporting/TrendSummary.java @@ -0,0 +1,4 @@ +package com.example.atlas.reporting; + +public record TrendSummary(String energyTrend, String focusTrend, String stressTrend, String sleepTrend, String habitConsistency) { +} diff --git a/src/main/java/com/example/atlas/reporting/entity/ReportArchiveEntity.java b/src/main/java/com/example/atlas/reporting/entity/ReportArchiveEntity.java new file mode 100644 index 0000000..a075f4f --- /dev/null +++ b/src/main/java/com/example/atlas/reporting/entity/ReportArchiveEntity.java @@ -0,0 +1,45 @@ +package com.example.atlas.reporting.entity; + +import com.example.atlas.user.entity.TelegramUserEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "report_archives") +public class ReportArchiveEntity { + + @Id + private UUID id; + + @ManyToOne + @JoinColumn(name = "telegram_user_id", nullable = false) + private TelegramUserEntity telegramUser; + + @Column(name = "week_start", nullable = false) + private LocalDate weekStart; + + @Column(columnDefinition = "text", nullable = false) + private String content; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected ReportArchiveEntity() { + } + + public ReportArchiveEntity(UUID id, TelegramUserEntity telegramUser, LocalDate weekStart, String content, Instant createdAt) { + this.id = id; + this.telegramUser = telegramUser; + this.weekStart = weekStart; + this.content = content; + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/example/atlas/reporting/repository/ReportArchiveRepository.java b/src/main/java/com/example/atlas/reporting/repository/ReportArchiveRepository.java new file mode 100644 index 0000000..1184b3e --- /dev/null +++ b/src/main/java/com/example/atlas/reporting/repository/ReportArchiveRepository.java @@ -0,0 +1,13 @@ +package com.example.atlas.reporting.repository; + +import com.example.atlas.reporting.entity.ReportArchiveEntity; +import com.example.atlas.user.entity.TelegramUserEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface ReportArchiveRepository extends JpaRepository { + + List findByTelegramUserOrderByCreatedAtDesc(TelegramUserEntity telegramUser); +} diff --git a/src/main/java/com/example/atlas/routines/ReminderSchedulerService.java b/src/main/java/com/example/atlas/routines/ReminderSchedulerService.java new file mode 100644 index 0000000..5a3e4c2 --- /dev/null +++ b/src/main/java/com/example/atlas/routines/ReminderSchedulerService.java @@ -0,0 +1,29 @@ +package com.example.atlas.routines; + +import com.example.atlas.routines.entity.RoutinePreferencesEntity; +import org.springframework.stereotype.Service; + +import java.time.LocalTime; + +@Service +public class ReminderSchedulerService { + + public boolean shouldSend(RoutinePreferencesEntity preferences, LocalTime now) { + if (preferences == null || !preferences.isEnabled()) { + return false; + } + LocalTime quietStart = LocalTime.parse(preferences.getQuietHoursStart()); + LocalTime quietEnd = LocalTime.parse(preferences.getQuietHoursEnd()); + return !insideQuietHours(now, quietStart, quietEnd); + } + + private boolean insideQuietHours(LocalTime now, LocalTime start, LocalTime end) { + if (start.equals(end)) { + return false; + } + if (start.isBefore(end)) { + return !now.isBefore(start) && now.isBefore(end); + } + return !now.isBefore(start) || now.isBefore(end); + } +} diff --git a/src/main/java/com/example/atlas/routines/RoutinePreferencesService.java b/src/main/java/com/example/atlas/routines/RoutinePreferencesService.java new file mode 100644 index 0000000..538617c --- /dev/null +++ b/src/main/java/com/example/atlas/routines/RoutinePreferencesService.java @@ -0,0 +1,26 @@ +package com.example.atlas.routines; + +import com.example.atlas.routines.entity.RoutinePreferencesEntity; +import com.example.atlas.routines.repository.RoutinePreferencesRepository; +import com.example.atlas.user.entity.TelegramUserEntity; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Service +@ConditionalOnBean(RoutinePreferencesRepository.class) +public class RoutinePreferencesService { + + private final RoutinePreferencesRepository repository; + + public RoutinePreferencesService(RoutinePreferencesRepository repository) { + this.repository = repository; + } + + @Transactional + public RoutinePreferencesEntity getOrCreate(TelegramUserEntity user) { + return repository.findByTelegramUser(user).orElseGet(() -> repository.save(RoutinePreferencesEntity.defaults(user, Instant.now()))); + } +} diff --git a/src/main/java/com/example/atlas/routines/entity/RoutinePreferencesEntity.java b/src/main/java/com/example/atlas/routines/entity/RoutinePreferencesEntity.java new file mode 100644 index 0000000..a22f00e --- /dev/null +++ b/src/main/java/com/example/atlas/routines/entity/RoutinePreferencesEntity.java @@ -0,0 +1,96 @@ +package com.example.atlas.routines.entity; + +import com.example.atlas.user.entity.TelegramUserEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "routine_preferences") +public class RoutinePreferencesEntity { + + @Id + private UUID id; + + @OneToOne + @JoinColumn(name = "telegram_user_id", nullable = false) + private TelegramUserEntity telegramUser; + + @Column(name = "checkin_time", nullable = false) + private String checkinTime; + + @Column(name = "evening_time", nullable = false) + private String eveningTime; + + @Column(nullable = false) + private String timezone; + + @Column(name = "quiet_hours_start", nullable = false) + private String quietHoursStart; + + @Column(name = "quiet_hours_end", nullable = false) + private String quietHoursEnd; + + @Column(nullable = false) + private boolean enabled; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected RoutinePreferencesEntity() { + } + + public RoutinePreferencesEntity(UUID id, TelegramUserEntity telegramUser, String checkinTime, String eveningTime, String timezone, String quietHoursStart, String quietHoursEnd, boolean enabled, Instant updatedAt) { + this.id = id; + this.telegramUser = telegramUser; + this.checkinTime = checkinTime; + this.eveningTime = eveningTime; + this.timezone = timezone; + this.quietHoursStart = quietHoursStart; + this.quietHoursEnd = quietHoursEnd; + this.enabled = enabled; + this.updatedAt = updatedAt; + } + + public static RoutinePreferencesEntity defaults(TelegramUserEntity user, Instant now) { + return new RoutinePreferencesEntity(UUID.randomUUID(), user, "09:00", "21:00", "Europe/Moscow", "22:00", "08:00", false, now); + } + + public UUID getId() { + return id; + } + + public TelegramUserEntity getTelegramUser() { + return telegramUser; + } + + public String getCheckinTime() { + return checkinTime; + } + + public String getEveningTime() { + return eveningTime; + } + + public String getTimezone() { + return timezone; + } + + public String getQuietHoursStart() { + return quietHoursStart; + } + + public String getQuietHoursEnd() { + return quietHoursEnd; + } + + public boolean isEnabled() { + return enabled; + } +} diff --git a/src/main/java/com/example/atlas/routines/repository/RoutinePreferencesRepository.java b/src/main/java/com/example/atlas/routines/repository/RoutinePreferencesRepository.java new file mode 100644 index 0000000..0f19888 --- /dev/null +++ b/src/main/java/com/example/atlas/routines/repository/RoutinePreferencesRepository.java @@ -0,0 +1,13 @@ +package com.example.atlas.routines.repository; + +import com.example.atlas.routines.entity.RoutinePreferencesEntity; +import com.example.atlas.user.entity.TelegramUserEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface RoutinePreferencesRepository extends JpaRepository { + + Optional findByTelegramUser(TelegramUserEntity telegramUser); +} diff --git a/src/main/resources/db/migration/V6__v080_routines_planning_reports.sql b/src/main/resources/db/migration/V6__v080_routines_planning_reports.sql new file mode 100644 index 0000000..f33b540 --- /dev/null +++ b/src/main/resources/db/migration/V6__v080_routines_planning_reports.sql @@ -0,0 +1,31 @@ +create table routine_preferences ( + id uuid primary key, + telegram_user_id uuid not null unique references telegram_users(id), + checkin_time varchar(16) not null, + evening_time varchar(16) not null, + timezone varchar(128) not null, + quiet_hours_start varchar(16) not null, + quiet_hours_end varchar(16) not null, + enabled boolean not null default false, + updated_at timestamptz not null +); + +create table weekly_focuses ( + id uuid primary key, + telegram_user_id uuid not null references telegram_users(id), + week_start date not null, + focus text not null, + created_at timestamptz not null +); + +create unique index ux_weekly_focus_user_week on weekly_focuses(telegram_user_id, week_start); + +create table report_archives ( + id uuid primary key, + telegram_user_id uuid not null references telegram_users(id), + week_start date not null, + content text not null, + created_at timestamptz not null +); + +create index idx_report_archives_user_created on report_archives(telegram_user_id, created_at desc); diff --git a/src/test/java/com/example/atlas/reporting/TrendDetectionServiceTest.java b/src/test/java/com/example/atlas/reporting/TrendDetectionServiceTest.java new file mode 100644 index 0000000..1415843 --- /dev/null +++ b/src/test/java/com/example/atlas/reporting/TrendDetectionServiceTest.java @@ -0,0 +1,29 @@ +package com.example.atlas.reporting; + +import com.example.atlas.checkin.entity.CheckInEntity; +import com.example.atlas.habit.entity.HabitCheckEntity; +import com.example.atlas.user.entity.TelegramUserEntity; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TrendDetectionServiceTest { + + @Test + void detectsDeterministicTrendsAndHabitConsistency() { + TelegramUserEntity user = TelegramUserEntity.create(7L, 42L, "user", "User", Instant.now()); + CheckInEntity oldCheckIn = CheckInEntity.create(user, 3, null, 4, 5, 8, 5, "", false, false, "", Instant.parse("2026-06-01T08:00:00Z")); + CheckInEntity latestCheckIn = CheckInEntity.create(user, 7, null, 4, 4, 5, 5, "", false, false, "", Instant.parse("2026-06-02T08:00:00Z")); + HabitCheckEntity habit = HabitCheckEntity.create(user, "Read", "2 pages", true, "", Instant.now()); + + TrendSummary summary = new TrendDetectionService().summarize(List.of(latestCheckIn, oldCheckIn), List.of(habit)); + + assertThat(summary.energyTrend()).isEqualTo("up"); + assertThat(summary.focusTrend()).isEqualTo("stable"); + assertThat(summary.stressTrend()).isEqualTo("down"); + assertThat(summary.habitConsistency()).isEqualTo("high"); + } +} diff --git a/src/test/java/com/example/atlas/routines/ReminderSchedulerServiceTest.java b/src/test/java/com/example/atlas/routines/ReminderSchedulerServiceTest.java new file mode 100644 index 0000000..6aee391 --- /dev/null +++ b/src/test/java/com/example/atlas/routines/ReminderSchedulerServiceTest.java @@ -0,0 +1,24 @@ +package com.example.atlas.routines; + +import com.example.atlas.routines.entity.RoutinePreferencesEntity; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class ReminderSchedulerServiceTest { + + private final ReminderSchedulerService service = new ReminderSchedulerService(); + + @Test + void respectsQuietHoursAndEnabledFlag() { + RoutinePreferencesEntity enabled = new RoutinePreferencesEntity(null, null, "09:00", "21:00", "Europe/Moscow", "22:00", "08:00", true, Instant.now()); + RoutinePreferencesEntity disabled = new RoutinePreferencesEntity(null, null, "09:00", "21:00", "Europe/Moscow", "22:00", "08:00", false, Instant.now()); + + assertThat(service.shouldSend(enabled, LocalTime.parse("09:00"))).isTrue(); + assertThat(service.shouldSend(enabled, LocalTime.parse("23:00"))).isFalse(); + assertThat(service.shouldSend(disabled, LocalTime.parse("09:00"))).isFalse(); + } +} From bf69c5bf1e3d4a591149a8f020b2a094ef9d0e58 Mon Sep 17 00:00:00 2001 From: Alex Today Date: Tue, 9 Jun 2026 10:06:50 +0300 Subject: [PATCH 05/10] feat(integrations): add export foundations --- docs/README.md | 14 ++++ docs/en/README.md | 9 ++- docs/en/changelog.md | 80 +++++++++++++++++++ docs/en/deployment.md | 10 +++ docs/en/integrations.md | 10 +++ docs/en/memory.md | 12 +++ docs/en/privacy.md | 11 +++ docs/en/reports.md | 9 +++ docs/en/routines.md | 11 +++ docs/en/weekly-planning.md | 5 ++ docs/ru/README.md | 7 ++ docs/ru/changelog.md | 80 +++++++++++++++++++ docs/ru/deployment.md | 10 +++ docs/ru/integrations.md | 10 +++ docs/ru/memory.md | 12 +++ docs/ru/privacy.md | 11 +++ docs/ru/reports.md | 9 +++ docs/ru/routines.md | 11 +++ docs/ru/weekly-planning.md | 5 ++ pom.xml | 2 +- .../atlas/export/MarkdownExportService.java | 27 +++++++ .../integrations/CalendarEventDraft.java | 11 +++ .../integrations/CalendarIntegrationPort.java | 9 +++ .../integrations/IntegrationSettings.java | 15 ++++ .../integrations/IntegrationSettingsPort.java | 14 ++++ .../atlas/integrations/IntegrationStatus.java | 7 ++ .../atlas/integrations/IntegrationType.java | 6 ++ .../entity/IntegrationSettingsEntity.java | 54 +++++++++++++ .../db/migration/V7__v090_integrations.sql | 10 +++ 29 files changed, 479 insertions(+), 2 deletions(-) create mode 100644 docs/en/deployment.md create mode 100644 docs/en/integrations.md create mode 100644 docs/en/memory.md create mode 100644 docs/en/privacy.md create mode 100644 docs/en/reports.md create mode 100644 docs/en/routines.md create mode 100644 docs/en/weekly-planning.md create mode 100644 docs/ru/deployment.md create mode 100644 docs/ru/integrations.md create mode 100644 docs/ru/memory.md create mode 100644 docs/ru/privacy.md create mode 100644 docs/ru/reports.md create mode 100644 docs/ru/routines.md create mode 100644 docs/ru/weekly-planning.md create mode 100644 src/main/java/com/example/atlas/export/MarkdownExportService.java create mode 100644 src/main/java/com/example/atlas/integrations/CalendarEventDraft.java create mode 100644 src/main/java/com/example/atlas/integrations/CalendarIntegrationPort.java create mode 100644 src/main/java/com/example/atlas/integrations/IntegrationSettings.java create mode 100644 src/main/java/com/example/atlas/integrations/IntegrationSettingsPort.java create mode 100644 src/main/java/com/example/atlas/integrations/IntegrationStatus.java create mode 100644 src/main/java/com/example/atlas/integrations/IntegrationType.java create mode 100644 src/main/java/com/example/atlas/integrations/entity/IntegrationSettingsEntity.java create mode 100644 src/main/resources/db/migration/V7__v090_integrations.sql diff --git a/docs/README.md b/docs/README.md index e31158c..bbee7c4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,13 @@ - [Локальный запуск](ru/local-launch.md) - [Telegram UX](ru/telegram-ux.md) - [LLM setup](ru/llm.md) +- [Память](ru/memory.md) +- [Режимы развертывания](ru/deployment.md) +- [Приватность](ru/privacy.md) +- [Рутины](ru/routines.md) +- [Недельное планирование](ru/weekly-planning.md) +- [Отчеты](ru/reports.md) +- [Интеграции](ru/integrations.md) - [История изменений](ru/changelog.md) ## English @@ -22,4 +29,11 @@ - [Local launch](en/local-launch.md) - [Telegram UX](en/telegram-ux.md) - [LLM setup](en/llm.md) +- [Memory](en/memory.md) +- [Deployment modes](en/deployment.md) +- [Privacy controls](en/privacy.md) +- [Routines](en/routines.md) +- [Weekly planning](en/weekly-planning.md) +- [Reports](en/reports.md) +- [Integrations](en/integrations.md) - [Changelog](en/changelog.md) diff --git a/docs/en/README.md b/docs/en/README.md index ee1eea1..684cbe7 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -12,8 +12,15 @@ ATLAS is a backend-first Telegram life operating system for state, focus, habits - [Local launch](local-launch.md) - [Telegram UX](telegram-ux.md) - [LLM setup](llm.md) +- [Memory](memory.md) +- [Deployment modes](deployment.md) +- [Privacy controls](privacy.md) +- [Routines](routines.md) +- [Weekly planning](weekly-planning.md) +- [Reports](reports.md) +- [Integrations](integrations.md) - [Changelog](changelog.md) ## Scope -ATLAS is a Telegram-first backend product. Frontend, landing pages, web dashboards, hosted official bot, reminders, persistent agent memory, embeddings and vector search are outside v0.6.0. +ATLAS is a Telegram-first backend product. Embeddings, vector search, OAuth sync and full external provider synchronization remain outside v0.9.0. diff --git a/docs/en/changelog.md b/docs/en/changelog.md index 107d8f8..b840a34 100644 --- a/docs/en/changelog.md +++ b/docs/en/changelog.md @@ -1,5 +1,85 @@ # Changelog +## v0.9.0 + +### Added +- Added integration port interfaces and safe integration settings metadata. +- Added user-scoped Markdown export foundation. +- Added calendar integration preview contract without OAuth or external sync. + +## v0.8.2 + +### Added +- Added deterministic trend detection for energy, focus, stress and sleep. +- Added habit consistency analysis. +- Added report archive persistence foundation. + +## v0.8.1 + +### Added +- Added persisted weekly focus model. +- Added weekly planning service for saving and retrieving current focus. +- Prepared weekly report connection points for weekly plan data. + +## v0.8.0 + +### Added +- Added routine preferences for check-in time, evening time, timezone, quiet hours and enabled state. +- Added reminder scheduler foundation that respects quiet hours. + +## v0.7.3 + +### Added +- Added hosted rate-limit and LLM quota foundations. +- Kept health endpoints available through Spring Boot readiness and liveness probes. + +## v0.7.2 + +### Added +- Added privacy panel, export, forget-memory and delete-my-data service foundations. +- Added strong confirmation checks for destructive operations. + +### Security +- Privacy operations are user-scoped and do not expose raw secrets. + +## v0.7.1 + +### Added +- Added hosted runtime foundations for server-owned Telegram configuration. +- Added webhook-first safety checks through deployment validation. +- Added basic per-user rate limiting. + +## v0.7.0 + +### Added +- Added explicit self-hosted and hosted deployment modes. +- Added safe deployment status and validation for unsafe hosted combinations. + +## v0.6.4 + +### Added +- Added memory-aware LLM context assembly. +- Added context limits and user-scoped shared/agent-specific memory retrieval. + +## v0.6.3 + +### Added +- Added PostgreSQL persistence schema for memory records. +- Added user-scoped memory repository and persistent memory service. +- Added optional runtime Markdown memory snapshots. + +## v0.6.2 + +### Added +- Added memory write model, policy, validation result and memory service contract. +- Extended agent results with proposed memory writes. + +## v0.6.1 + +### Added +- Added first scoped LLM agent abstractions and question agent. +- Added fallback and safety metadata on agent responses. + ## v0.6.0 ### Added diff --git a/docs/en/deployment.md b/docs/en/deployment.md new file mode 100644 index 0000000..c65094a --- /dev/null +++ b/docs/en/deployment.md @@ -0,0 +1,10 @@ +# Deployment Modes + +ATLAS supports two deployment modes through `ATLAS_DEPLOYMENT_MODE`: + +- `self_hosted`: default mode. Local setup can be enabled and a user-provided Telegram bot token is allowed. +- `hosted`: foundation for a server-owned runtime. Setup must be disabled, Telegram must run in webhook mode, and bot credentials must come from server environment or secrets. + +Hosted mode blocks unsafe combinations such as public setup, missing webhook URL, missing bot token or polling-only runtime. + +Status output may show mode, setup state, provider names and whether settings exist. It must not show Telegram tokens, webhook secrets or LLM API keys. diff --git a/docs/en/integrations.md b/docs/en/integrations.md new file mode 100644 index 0000000..b062822 --- /dev/null +++ b/docs/en/integrations.md @@ -0,0 +1,10 @@ +# Integrations + +The integration foundation is port-first: + +- integration settings store safe metadata only; +- Markdown export is user-scoped; +- calendar integration exposes a preview contract; +- no OAuth flow or full external sync is included. + +Future integrations can implement these ports without changing ATLAS into a multi-service system. diff --git a/docs/en/memory.md b/docs/en/memory.md new file mode 100644 index 0000000..29fa662 --- /dev/null +++ b/docs/en/memory.md @@ -0,0 +1,12 @@ +# Memory + +ATLAS memory has two layers: + +- Memory write contract: agents can propose user-scoped memories with type, scope, confidence, source and tags. +- Persistent memory: accepted records are stored in PostgreSQL and can optionally be mirrored as runtime Markdown snapshots. + +Memory writes are validated before storage. The policy rejects secrets, missing user scope, unsafe medical claims and low-value one-off noise. Retrieval is always scoped by the internal user id. + +Markdown snapshots are disabled by default. If enabled, they are written under `ATLAS_MEMORY_SNAPSHOT_PATH` and must stay in ignored runtime storage. + +No embeddings or vector search are part of this release line. diff --git a/docs/en/privacy.md b/docs/en/privacy.md new file mode 100644 index 0000000..67bd557 --- /dev/null +++ b/docs/en/privacy.md @@ -0,0 +1,11 @@ +# Privacy Controls + +ATLAS exposes foundations for: + +- `/privacy`: explain stored profile, check-ins, habits, reflections, reports, memory and Telegram identifiers. +- `/memory`: show memory categories and counts without raw sensitive content by default. +- `/export`: produce user-scoped JSON and Markdown export data. +- `/forget`: archive memory records only, after confirmation. +- `/delete_my_data`: delete user-scoped data after strong confirmation. + +Destructive operations require the exact confirmation value `DELETE` in the service layer. Export, forget and deletion use the internal user id and do not operate across users. diff --git a/docs/en/reports.md b/docs/en/reports.md new file mode 100644 index 0000000..3ad458f --- /dev/null +++ b/docs/en/reports.md @@ -0,0 +1,9 @@ +# Reports + +Reports now have deterministic foundations for: + +- energy, focus, stress and sleep trends; +- habit consistency; +- report archive navigation. + +Trend detection only uses saved check-ins and habit records. Reports must show missing data instead of inventing conclusions. diff --git a/docs/en/routines.md b/docs/en/routines.md new file mode 100644 index 0000000..84a41de --- /dev/null +++ b/docs/en/routines.md @@ -0,0 +1,11 @@ +# Routines + +Routine preferences store: + +- daily check-in time; +- evening reflection time; +- timezone; +- quiet hours; +- enabled flag. + +The reminder scheduler foundation checks whether a reminder may be sent without interrupting quiet hours. Telegram settings can use the same model for reminder buttons. diff --git a/docs/en/weekly-planning.md b/docs/en/weekly-planning.md new file mode 100644 index 0000000..da80c0a --- /dev/null +++ b/docs/en/weekly-planning.md @@ -0,0 +1,5 @@ +# Weekly Planning + +Weekly planning adds a persisted weekly focus per user and week. The week starts on Monday. + +The planning flow can save a focus, retrieve the current focus and connect that data to weekly reports. The model is intentionally small: it stores the focus text and week start without introducing external calendar sync. diff --git a/docs/ru/README.md b/docs/ru/README.md index 63a21dc..3e56485 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -12,6 +12,13 @@ ATLAS - backend-first Telegram-система для состояния, фок - [Локальный запуск](local-launch.md) - [Telegram UX](telegram-ux.md) - [LLM setup](llm.md) +- [Память](memory.md) +- [Режимы развертывания](deployment.md) +- [Приватность](privacy.md) +- [Рутины](routines.md) +- [Недельное планирование](weekly-planning.md) +- [Отчеты](reports.md) +- [Интеграции](integrations.md) - [История изменений](changelog.md) ## Scope diff --git a/docs/ru/changelog.md b/docs/ru/changelog.md index 9c487f9..890a43d 100644 --- a/docs/ru/changelog.md +++ b/docs/ru/changelog.md @@ -1,5 +1,85 @@ # История изменений +## v0.9.0 + +### Добавлено +- Добавлены порты интеграций и безопасные metadata для настроек интеграций. +- Добавлена основа пользовательского Markdown export. +- Добавлен контракт preview для calendar integration без OAuth и внешней синхронизации. + +## v0.8.2 + +### Добавлено +- Добавлен детерминированный trend detection для энергии, фокуса, стресса и сна. +- Добавлен анализ регулярности привычек. +- Добавлена основа архива отчетов. + +## v0.8.1 + +### Добавлено +- Добавлена модель фокуса недели. +- Добавлен сервис недельного планирования для сохранения и получения текущего фокуса. +- Подготовлена связь недельного плана с недельным отчетом. + +## v0.8.0 + +### Добавлено +- Добавлены настройки рутины: check-in time, evening time, timezone, quiet hours и enabled state. +- Добавлена основа scheduler, которая учитывает quiet hours. + +## v0.7.3 + +### Добавлено +- Добавлены основы hosted rate limits и LLM quotas. +- Readiness и liveness остаются доступны через Spring Boot health probes. + +## v0.7.2 + +### Добавлено +- Добавлены основы privacy panel, export, forget memory и delete my data. +- Добавлены строгие подтверждения для разрушающих операций. + +### Безопасность +- Privacy operations работают только в рамках пользователя и не раскрывают секреты. + +## v0.7.1 + +### Добавлено +- Добавлена основа hosted runtime для серверной Telegram-конфигурации. +- Добавлены webhook-first проверки через deployment validation. +- Добавлен базовый per-user rate limiting. + +## v0.7.0 + +### Добавлено +- Добавлены явные режимы self-hosted и hosted. +- Добавлены безопасный deployment status и валидация небезопасных hosted-сочетаний. + +## v0.6.4 + +### Добавлено +- Добавлен memory-aware LLM context assembly. +- Добавлены лимиты контекста и user-scoped retrieval для shared и agent-specific memory. + +## v0.6.3 + +### Добавлено +- Добавлена PostgreSQL-схема для memory records. +- Добавлены user-scoped repository и persistent memory service. +- Добавлены опциональные runtime Markdown memory snapshots. + +## v0.6.2 + +### Добавлено +- Добавлены memory write model, policy, validation result и memory service contract. +- Agent results расширены proposed memory writes. + +## v0.6.1 + +### Добавлено +- Добавлены первые scoped LLM agent abstractions и question agent. +- Добавлены fallback и safety metadata для agent responses. + ## v0.6.0 ### Добавлено diff --git a/docs/ru/deployment.md b/docs/ru/deployment.md new file mode 100644 index 0000000..cabd33e --- /dev/null +++ b/docs/ru/deployment.md @@ -0,0 +1,10 @@ +# Режимы развертывания + +ATLAS поддерживает два режима через `ATLAS_DEPLOYMENT_MODE`: + +- `self_hosted`: режим по умолчанию. Локальная настройка может быть включена, токен Telegram-бота задает владелец установки. +- `hosted`: основа серверного режима. Setup должен быть выключен, Telegram работает через webhook, токены берутся только из окружения или секретов сервера. + +Hosted mode блокирует небезопасные сочетания: публичный setup, отсутствие webhook URL, отсутствие токена или polling-only запуск. + +Статусы могут показывать режим, состояние setup и наличие настроек. Telegram token, webhook secret и LLM API key не отображаются. diff --git a/docs/ru/integrations.md b/docs/ru/integrations.md new file mode 100644 index 0000000..5b7803c --- /dev/null +++ b/docs/ru/integrations.md @@ -0,0 +1,10 @@ +# Интеграции + +Основа интеграций построена через порты: + +- настройки интеграций хранят только безопасные metadata; +- Markdown export работает в рамках пользователя; +- calendar integration содержит контракт preview; +- OAuth и полная внешняя синхронизация не входят в релиз. + +Будущие интеграции могут реализовать эти порты без разделения ATLAS на микросервисы. diff --git a/docs/ru/memory.md b/docs/ru/memory.md new file mode 100644 index 0000000..65af596 --- /dev/null +++ b/docs/ru/memory.md @@ -0,0 +1,12 @@ +# Память + +В ATLAS память состоит из двух уровней: + +- контракт записи: сценарии могут предлагать пользовательские записи с типом, областью, уверенностью, источником и тегами; +- постоянная память: принятые записи хранятся в PostgreSQL и при необходимости отражаются в runtime Markdown-файлах. + +Перед сохранением запись проходит политику безопасности. Отклоняются секреты, записи без пользовательской области, небезопасные медицинские утверждения и одноразовый шум без пользы. + +Markdown-снимки выключены по умолчанию. Если они включены, путь задается через `ATLAS_MEMORY_SNAPSHOT_PATH`, а файлы должны оставаться в игнорируемом runtime-хранилище. + +Embeddings и vector search не входят в эту линию релизов. diff --git a/docs/ru/privacy.md b/docs/ru/privacy.md new file mode 100644 index 0000000..b4fe158 --- /dev/null +++ b/docs/ru/privacy.md @@ -0,0 +1,11 @@ +# Приватность + +ATLAS добавляет основы пользовательского контроля данных: + +- `/privacy`: описание сохраненных профиля, check-ins, привычек, рефлексий, отчетов, памяти и Telegram-идентификаторов; +- `/memory`: категории и счетчики памяти без сырого чувствительного содержания по умолчанию; +- `/export`: пользовательский JSON и Markdown export; +- `/forget`: архивирование памяти после подтверждения; +- `/delete_my_data`: удаление пользовательских данных после строгого подтверждения. + +Разрушающие операции требуют значение `DELETE` на уровне сервиса. Export, forget и deletion всегда используют внутренний user id и не затрагивают других пользователей. diff --git a/docs/ru/reports.md b/docs/ru/reports.md new file mode 100644 index 0000000..ba88f35 --- /dev/null +++ b/docs/ru/reports.md @@ -0,0 +1,9 @@ +# Отчеты + +Отчеты получили детерминированные основы для: + +- трендов энергии, фокуса, стресса и сна; +- анализа регулярности привычек; +- архива отчетов. + +Trend detection использует только сохраненные check-ins и привычки. Отчеты должны показывать отсутствие данных, а не придумывать выводы. diff --git a/docs/ru/routines.md b/docs/ru/routines.md new file mode 100644 index 0000000..c3f94c6 --- /dev/null +++ b/docs/ru/routines.md @@ -0,0 +1,11 @@ +# Рутины + +Настройки рутины хранят: + +- время дневного check-in; +- время вечерней рефлексии; +- timezone; +- quiet hours; +- флаг включения. + +Основа scheduler проверяет, можно ли отправить напоминание, не нарушая quiet hours. Telegram-настройки могут использовать ту же модель для кнопок напоминаний. diff --git a/docs/ru/weekly-planning.md b/docs/ru/weekly-planning.md new file mode 100644 index 0000000..6c12e09 --- /dev/null +++ b/docs/ru/weekly-planning.md @@ -0,0 +1,5 @@ +# Недельное планирование + +Недельное планирование хранит фокус недели для пользователя и недели. Неделя начинается в понедельник. + +Flow может сохранить фокус, получить текущий фокус и связать его с недельным отчетом. Модель намеренно компактная и не добавляет внешнюю календарную синхронизацию. diff --git a/pom.xml b/pom.xml index 23c074e..d7e92b1 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ com.example atlas - 0.6.0 + 0.9.0 ATLAS Telegram life operating system for state, focus, habits, planning, reflection and progress. diff --git a/src/main/java/com/example/atlas/export/MarkdownExportService.java b/src/main/java/com/example/atlas/export/MarkdownExportService.java new file mode 100644 index 0000000..bc3f55e --- /dev/null +++ b/src/main/java/com/example/atlas/export/MarkdownExportService.java @@ -0,0 +1,27 @@ +package com.example.atlas.export; + +import com.example.atlas.privacy.PrivacyExport; +import com.example.atlas.privacy.PrivacyService; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +public class MarkdownExportService { + + private final ObjectProvider privacyService; + + public MarkdownExportService(ObjectProvider privacyService) { + this.privacyService = privacyService; + } + + public String exportUserMarkdown(UUID userId) { + PrivacyService service = privacyService.getIfAvailable(); + if (service == null) { + throw new IllegalStateException("Privacy export is not available without persistence."); + } + PrivacyExport export = service.export(userId); + return export.markdown(); + } +} diff --git a/src/main/java/com/example/atlas/integrations/CalendarEventDraft.java b/src/main/java/com/example/atlas/integrations/CalendarEventDraft.java new file mode 100644 index 0000000..96ed09c --- /dev/null +++ b/src/main/java/com/example/atlas/integrations/CalendarEventDraft.java @@ -0,0 +1,11 @@ +package com.example.atlas.integrations; + +import java.time.Instant; + +public record CalendarEventDraft( + String title, + String description, + Instant startsAt, + Instant endsAt +) { +} diff --git a/src/main/java/com/example/atlas/integrations/CalendarIntegrationPort.java b/src/main/java/com/example/atlas/integrations/CalendarIntegrationPort.java new file mode 100644 index 0000000..9ec4001 --- /dev/null +++ b/src/main/java/com/example/atlas/integrations/CalendarIntegrationPort.java @@ -0,0 +1,9 @@ +package com.example.atlas.integrations; + +import java.util.List; +import java.util.UUID; + +public interface CalendarIntegrationPort { + + List previewWeeklyPlan(UUID userId); +} diff --git a/src/main/java/com/example/atlas/integrations/IntegrationSettings.java b/src/main/java/com/example/atlas/integrations/IntegrationSettings.java new file mode 100644 index 0000000..1d9abca --- /dev/null +++ b/src/main/java/com/example/atlas/integrations/IntegrationSettings.java @@ -0,0 +1,15 @@ +package com.example.atlas.integrations; + +import java.util.Map; +import java.util.UUID; + +public record IntegrationSettings( + UUID userId, + IntegrationType type, + IntegrationStatus status, + Map safeMetadata +) { + public IntegrationSettings { + safeMetadata = safeMetadata == null ? Map.of() : Map.copyOf(safeMetadata); + } +} diff --git a/src/main/java/com/example/atlas/integrations/IntegrationSettingsPort.java b/src/main/java/com/example/atlas/integrations/IntegrationSettingsPort.java new file mode 100644 index 0000000..9e56d61 --- /dev/null +++ b/src/main/java/com/example/atlas/integrations/IntegrationSettingsPort.java @@ -0,0 +1,14 @@ +package com.example.atlas.integrations; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface IntegrationSettingsPort { + + Optional find(UUID userId, IntegrationType type); + + List findAll(UUID userId); + + IntegrationSettings save(IntegrationSettings settings); +} diff --git a/src/main/java/com/example/atlas/integrations/IntegrationStatus.java b/src/main/java/com/example/atlas/integrations/IntegrationStatus.java new file mode 100644 index 0000000..a969ba8 --- /dev/null +++ b/src/main/java/com/example/atlas/integrations/IntegrationStatus.java @@ -0,0 +1,7 @@ +package com.example.atlas.integrations; + +public enum IntegrationStatus { + DISABLED, + ENABLED, + ERROR +} diff --git a/src/main/java/com/example/atlas/integrations/IntegrationType.java b/src/main/java/com/example/atlas/integrations/IntegrationType.java new file mode 100644 index 0000000..76babd4 --- /dev/null +++ b/src/main/java/com/example/atlas/integrations/IntegrationType.java @@ -0,0 +1,6 @@ +package com.example.atlas.integrations; + +public enum IntegrationType { + MARKDOWN_EXPORT, + CALENDAR +} diff --git a/src/main/java/com/example/atlas/integrations/entity/IntegrationSettingsEntity.java b/src/main/java/com/example/atlas/integrations/entity/IntegrationSettingsEntity.java new file mode 100644 index 0000000..dd7085b --- /dev/null +++ b/src/main/java/com/example/atlas/integrations/entity/IntegrationSettingsEntity.java @@ -0,0 +1,54 @@ +package com.example.atlas.integrations.entity; + +import com.example.atlas.integrations.IntegrationStatus; +import com.example.atlas.integrations.IntegrationType; +import com.example.atlas.user.entity.TelegramUserEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "integration_settings") +public class IntegrationSettingsEntity { + + @Id + private UUID id; + + @ManyToOne + @JoinColumn(name = "telegram_user_id", nullable = false) + private TelegramUserEntity telegramUser; + + @Enumerated(EnumType.STRING) + @Column(name = "integration_type", nullable = false) + private IntegrationType integrationType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private IntegrationStatus status; + + @Column(name = "safe_metadata_json", columnDefinition = "text", nullable = false) + private String safeMetadataJson; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected IntegrationSettingsEntity() { + } + + public IntegrationSettingsEntity(UUID id, TelegramUserEntity telegramUser, IntegrationType integrationType, IntegrationStatus status, String safeMetadataJson, Instant updatedAt) { + this.id = id; + this.telegramUser = telegramUser; + this.integrationType = integrationType; + this.status = status; + this.safeMetadataJson = safeMetadataJson; + this.updatedAt = updatedAt; + } +} diff --git a/src/main/resources/db/migration/V7__v090_integrations.sql b/src/main/resources/db/migration/V7__v090_integrations.sql new file mode 100644 index 0000000..79e8687 --- /dev/null +++ b/src/main/resources/db/migration/V7__v090_integrations.sql @@ -0,0 +1,10 @@ +create table integration_settings ( + id uuid primary key, + telegram_user_id uuid not null references telegram_users(id), + integration_type varchar(64) not null, + status varchar(32) not null, + safe_metadata_json text not null, + updated_at timestamptz not null +); + +create unique index ux_integration_settings_user_type on integration_settings(telegram_user_id, integration_type); From 4e1f0954630533450fb6d39e664918f76d70cd8f Mon Sep 17 00:00:00 2001 From: Alex Today Date: Wed, 10 Jun 2026 08:35:27 +0300 Subject: [PATCH 06/10] feat(agents): wire user scoped memory aware routing --- .../com/example/atlas/agent/AgentContext.java | 23 +++++- .../atlas/agent/planner/PlannerAgent.java | 18 +++++ .../atlas/agent/question/QuestionAgent.java | 28 +++++++- .../atlas/agent/report/ReportAgent.java | 10 ++- .../example/atlas/llm/LlmDayPlanService.java | 26 ++++++- .../atlas/llm/LlmQuestionAnswerService.java | 26 ++++++- .../atlas/llm/LlmReportSummaryService.java | 26 ++++++- .../orchestrator/OrchestratorService.java | 44 +++++++++++- .../orchestrator/OrchestratorServiceTest.java | 72 +++++++++++++++++++ 9 files changed, 259 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/example/atlas/agent/AgentContext.java b/src/main/java/com/example/atlas/agent/AgentContext.java index f65fe20..f3e262a 100644 --- a/src/main/java/com/example/atlas/agent/AgentContext.java +++ b/src/main/java/com/example/atlas/agent/AgentContext.java @@ -1,17 +1,36 @@ package com.example.atlas.agent; import com.example.atlas.orchestrator.RequestType; +import com.example.atlas.user.entity.TelegramUserEntity; import java.time.Instant; +import java.util.UUID; public record AgentContext( - Long userId, + Long telegramUserId, + UUID internalUserId, + TelegramUserEntity user, String message, RequestType requestType, Instant receivedAt ) { + public AgentContext(Long telegramUserId, String message, RequestType requestType, Instant receivedAt) { + this(telegramUserId, null, null, message, requestType, receivedAt); + } + public static AgentContext anonymous(String message, RequestType requestType) { - return new AgentContext(null, message, requestType, Instant.now()); + return new AgentContext(null, null, null, message, requestType, Instant.now()); + } + + public static AgentContext forUser(TelegramUserEntity user, String message, RequestType requestType) { + return new AgentContext( + user == null ? null : user.getTelegramUserId(), + user == null ? null : user.getId(), + user, + message, + requestType, + Instant.now() + ); } } diff --git a/src/main/java/com/example/atlas/agent/planner/PlannerAgent.java b/src/main/java/com/example/atlas/agent/planner/PlannerAgent.java index 23bc716..847d3dc 100644 --- a/src/main/java/com/example/atlas/agent/planner/PlannerAgent.java +++ b/src/main/java/com/example/atlas/agent/planner/PlannerAgent.java @@ -3,12 +3,26 @@ import com.example.atlas.agent.Agent; import com.example.atlas.agent.AgentContext; import com.example.atlas.agent.AgentResult; +import com.example.atlas.life.service.LifeDayPlanService; import com.example.atlas.orchestrator.RequestType; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class PlannerAgent implements Agent { + private final ObjectProvider dayPlanService; + + @Autowired + public PlannerAgent(ObjectProvider dayPlanService) { + this.dayPlanService = dayPlanService; + } + + public PlannerAgent() { + this.dayPlanService = null; + } + @Override public String name() { return "ATLAS Planner"; @@ -21,6 +35,10 @@ public boolean supports(RequestType requestType) { @Override public AgentResult handle(AgentContext context) { + LifeDayPlanService service = dayPlanService == null ? null : dayPlanService.getIfAvailable(); + if (service != null && context.user() != null && context.requestType() == RequestType.DAY_PLAN) { + return AgentResult.reply(service.dayPlan(context.user()), name()); + } String content = switch (context.requestType()) { case DAY_PLAN -> "План дня: 1 главный фокус, короткий список действий, поддержка состояния, минимальная привычка и вечерняя рефлексия."; case WEEK_PLAN -> "План недели: несколько ключевых результатов, регулярные check-ins, привычки, восстановление ритма и короткий отчёт в конце недели."; diff --git a/src/main/java/com/example/atlas/agent/question/QuestionAgent.java b/src/main/java/com/example/atlas/agent/question/QuestionAgent.java index d687f04..fb181c1 100644 --- a/src/main/java/com/example/atlas/agent/question/QuestionAgent.java +++ b/src/main/java/com/example/atlas/agent/question/QuestionAgent.java @@ -11,6 +11,9 @@ import com.example.atlas.memory.MemoryType; import com.example.atlas.memory.MemoryWrite; import com.example.atlas.orchestrator.RequestType; +import com.example.atlas.llm.LlmQuestionAnswerService; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.List; @@ -18,6 +21,17 @@ @Component public class QuestionAgent implements Agent { + private final ObjectProvider questionAnswerService; + + @Autowired + public QuestionAgent(ObjectProvider questionAnswerService) { + this.questionAnswerService = questionAnswerService; + } + + public QuestionAgent() { + this.questionAnswerService = null; + } + @Override public String name() { return "ATLAS Question"; @@ -31,6 +45,16 @@ public boolean supports(RequestType requestType) { @Override public AgentResult handle(AgentContext context) { String message = context.message() == null ? "" : context.message().toLowerCase(); + LlmQuestionAnswerService service = questionAnswerService == null ? null : questionAnswerService.getIfAvailable(); + if (service != null && context.user() != null) { + return service.answer(context.user(), context.message()) + .map(answer -> AgentResult.reply(answer, name())) + .orElseGet(() -> deterministicAnswer(context, message)); + } + return deterministicAnswer(context, message); + } + + private AgentResult deterministicAnswer(AgentContext context, String message) { if (outOfScope(message)) { return AgentResult.fallback( "ATLAS отвечает только про планирование дня, привычки, check-ins, рефлексию, отчеты, состояние, фокус и ритм. Можно начать с /checkin, /day, /habits, /evening или /report.", @@ -42,11 +66,11 @@ public AgentResult handle(AgentContext context) { "В рамках ATLAS лучше сузить вопрос до одного шага: состояние сейчас, главный фокус, минимальная привычка или вечерний вывод. Начни с /checkin или попроси план через /day.", name() ); - if (context.userId() == null || message.isBlank()) { + if (context.internalUserId() == null || message.isBlank()) { return result; } MemoryWrite write = new MemoryWrite( - null, + context.internalUserId(), AgentType.QUESTION, MemoryType.PREFERENCE, MemoryScope.AGENT_PRIVATE, diff --git a/src/main/java/com/example/atlas/agent/report/ReportAgent.java b/src/main/java/com/example/atlas/agent/report/ReportAgent.java index 885cec8..29facfd 100644 --- a/src/main/java/com/example/atlas/agent/report/ReportAgent.java +++ b/src/main/java/com/example/atlas/agent/report/ReportAgent.java @@ -3,6 +3,7 @@ import com.example.atlas.agent.Agent; import com.example.atlas.agent.AgentContext; import com.example.atlas.agent.AgentResult; +import com.example.atlas.life.service.WeeklyLifeReportService; import com.example.atlas.orchestrator.RequestType; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; @@ -12,14 +13,17 @@ public class ReportAgent implements Agent { private final ObjectProvider reportSummaryService; + private final ObjectProvider weeklyLifeReportService; @Autowired - public ReportAgent(ObjectProvider reportSummaryService) { + public ReportAgent(ObjectProvider reportSummaryService, ObjectProvider weeklyLifeReportService) { this.reportSummaryService = reportSummaryService; + this.weeklyLifeReportService = weeklyLifeReportService; } public ReportAgent() { this.reportSummaryService = null; + this.weeklyLifeReportService = null; } @Override @@ -34,6 +38,10 @@ public boolean supports(RequestType requestType) { @Override public AgentResult handle(AgentContext context) { + WeeklyLifeReportService weeklyService = weeklyLifeReportService == null ? null : weeklyLifeReportService.getIfAvailable(); + if (weeklyService != null && context.user() != null) { + return AgentResult.reply(weeklyService.weeklyReport(context.user()), name()); + } ReportSummaryService service = reportSummaryService == null ? null : reportSummaryService.getIfAvailable(); if (service != null) { ReportSummaryService.ReportSummary summary = service.weeklySummary(); diff --git a/src/main/java/com/example/atlas/llm/LlmDayPlanService.java b/src/main/java/com/example/atlas/llm/LlmDayPlanService.java index a718819..db31181 100644 --- a/src/main/java/com/example/atlas/llm/LlmDayPlanService.java +++ b/src/main/java/com/example/atlas/llm/LlmDayPlanService.java @@ -1,7 +1,10 @@ package com.example.atlas.llm; import com.example.atlas.config.AtlasProperties; +import com.example.atlas.hosted.LlmQuotaService; import com.example.atlas.user.entity.TelegramUserEntity; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.stereotype.Service; @@ -16,23 +19,37 @@ public class LlmDayPlanService { private final LlmContextAssembler contextAssembler; private final PromptTemplateService promptTemplateService; private final LlmSafetyService safetyService; + private final ObjectProvider quotaService; + @Autowired public LlmDayPlanService( AtlasProperties properties, LlmClient llmClient, LlmContextAssembler contextAssembler, PromptTemplateService promptTemplateService, - LlmSafetyService safetyService + LlmSafetyService safetyService, + ObjectProvider quotaService ) { this.properties = properties; this.llmClient = llmClient; this.contextAssembler = contextAssembler; this.promptTemplateService = promptTemplateService; this.safetyService = safetyService; + this.quotaService = quotaService; + } + + public LlmDayPlanService( + AtlasProperties properties, + LlmClient llmClient, + LlmContextAssembler contextAssembler, + PromptTemplateService promptTemplateService, + LlmSafetyService safetyService + ) { + this(properties, llmClient, contextAssembler, promptTemplateService, safetyService, null); } public Optional dayPlan(TelegramUserEntity user, String deterministicFallback) { - if (!properties.llm().dayPlanAvailable() || !llmClient.available()) { + if (!properties.llm().dayPlanAvailable() || !llmClient.available() || !quotaAllows(user)) { return Optional.empty(); } try { @@ -43,4 +60,9 @@ public Optional dayPlan(TelegramUserEntity user, String deterministicFal return Optional.empty(); } } + + private boolean quotaAllows(TelegramUserEntity user) { + LlmQuotaService service = quotaService == null ? null : quotaService.getIfAvailable(); + return service == null || user == null || service.allowLlmCall(user.getTelegramUserId()); + } } diff --git a/src/main/java/com/example/atlas/llm/LlmQuestionAnswerService.java b/src/main/java/com/example/atlas/llm/LlmQuestionAnswerService.java index e9faf6c..b68445d 100644 --- a/src/main/java/com/example/atlas/llm/LlmQuestionAnswerService.java +++ b/src/main/java/com/example/atlas/llm/LlmQuestionAnswerService.java @@ -1,8 +1,11 @@ package com.example.atlas.llm; import com.example.atlas.config.AtlasProperties; +import com.example.atlas.hosted.LlmQuotaService; import com.example.atlas.user.UserLanguage; import com.example.atlas.user.entity.TelegramUserEntity; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.stereotype.Service; @@ -44,19 +47,33 @@ public class LlmQuestionAnswerService { private final LlmContextAssembler contextAssembler; private final PromptTemplateService promptTemplateService; private final LlmSafetyService safetyService; + private final ObjectProvider quotaService; + @Autowired public LlmQuestionAnswerService( AtlasProperties properties, LlmClient llmClient, LlmContextAssembler contextAssembler, PromptTemplateService promptTemplateService, - LlmSafetyService safetyService + LlmSafetyService safetyService, + ObjectProvider quotaService ) { this.properties = properties; this.llmClient = llmClient; this.contextAssembler = contextAssembler; this.promptTemplateService = promptTemplateService; this.safetyService = safetyService; + this.quotaService = quotaService; + } + + public LlmQuestionAnswerService( + AtlasProperties properties, + LlmClient llmClient, + LlmContextAssembler contextAssembler, + PromptTemplateService promptTemplateService, + LlmSafetyService safetyService + ) { + this(properties, llmClient, contextAssembler, promptTemplateService, safetyService, null); } public Optional answer(TelegramUserEntity user, String question) { @@ -64,7 +81,7 @@ public Optional answer(TelegramUserEntity user, String question) { UserLanguage language = user.getLanguage().orElse(UserLanguage.RU); return Optional.of(safetyService.deterministicSafetyResponse(language)); } - if (!properties.llm().questionAvailable() || !llmClient.available()) { + if (!properties.llm().questionAvailable() || !llmClient.available() || !quotaAllows(user)) { return Optional.empty(); } if (!inAtlasScope(question)) { @@ -80,6 +97,11 @@ public Optional answer(TelegramUserEntity user, String question) { } } + private boolean quotaAllows(TelegramUserEntity user) { + LlmQuotaService service = quotaService == null ? null : quotaService.getIfAvailable(); + return service == null || user == null || service.allowLlmCall(user.getTelegramUserId()); + } + private boolean inAtlasScope(String question) { if (question == null || question.isBlank()) { return false; diff --git a/src/main/java/com/example/atlas/llm/LlmReportSummaryService.java b/src/main/java/com/example/atlas/llm/LlmReportSummaryService.java index e27650b..50877af 100644 --- a/src/main/java/com/example/atlas/llm/LlmReportSummaryService.java +++ b/src/main/java/com/example/atlas/llm/LlmReportSummaryService.java @@ -1,7 +1,10 @@ package com.example.atlas.llm; import com.example.atlas.config.AtlasProperties; +import com.example.atlas.hosted.LlmQuotaService; import com.example.atlas.user.entity.TelegramUserEntity; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.stereotype.Service; @@ -16,23 +19,37 @@ public class LlmReportSummaryService { private final LlmContextAssembler contextAssembler; private final PromptTemplateService promptTemplateService; private final LlmSafetyService safetyService; + private final ObjectProvider quotaService; + @Autowired public LlmReportSummaryService( AtlasProperties properties, LlmClient llmClient, LlmContextAssembler contextAssembler, PromptTemplateService promptTemplateService, - LlmSafetyService safetyService + LlmSafetyService safetyService, + ObjectProvider quotaService ) { this.properties = properties; this.llmClient = llmClient; this.contextAssembler = contextAssembler; this.promptTemplateService = promptTemplateService; this.safetyService = safetyService; + this.quotaService = quotaService; + } + + public LlmReportSummaryService( + AtlasProperties properties, + LlmClient llmClient, + LlmContextAssembler contextAssembler, + PromptTemplateService promptTemplateService, + LlmSafetyService safetyService + ) { + this(properties, llmClient, contextAssembler, promptTemplateService, safetyService, null); } public Optional summary(TelegramUserEntity user, String deterministicMetrics) { - if (!properties.llm().reportAvailable() || !llmClient.available()) { + if (!properties.llm().reportAvailable() || !llmClient.available() || !quotaAllows(user)) { return Optional.empty(); } try { @@ -48,4 +65,9 @@ public Optional summary(TelegramUserEntity user, String deterministicMet return Optional.empty(); } } + + private boolean quotaAllows(TelegramUserEntity user) { + LlmQuotaService service = quotaService == null ? null : quotaService.getIfAvailable(); + return service == null || user == null || service.allowLlmCall(user.getTelegramUserId()); + } } diff --git a/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java b/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java index e6948b5..00c6983 100644 --- a/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java +++ b/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java @@ -3,6 +3,11 @@ import com.example.atlas.agent.Agent; import com.example.atlas.agent.AgentContext; import com.example.atlas.agent.AgentResult; +import com.example.atlas.memory.AgentMemoryService; +import com.example.atlas.memory.MemoryWrite; +import com.example.atlas.user.entity.TelegramUserEntity; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.Comparator; @@ -13,11 +18,21 @@ public class OrchestratorService { private final List agents; + private final ObjectProvider memoryService; + + @Autowired + public OrchestratorService(List agents, ObjectProvider memoryService) { + this.agents = agents.stream() + .sorted(Comparator.comparing(Agent::name)) + .toList(); + this.memoryService = memoryService; + } public OrchestratorService(List agents) { this.agents = agents.stream() .sorted(Comparator.comparing(Agent::name)) .toList(); + this.memoryService = null; } public AgentResult route(String message) { @@ -26,7 +41,13 @@ public AgentResult route(String message) { } public AgentResult route(RequestType requestType, String message) { - AgentContext context = AgentContext.anonymous(message, requestType); + return route(null, requestType, message); + } + + public AgentResult route(TelegramUserEntity user, RequestType requestType, String message) { + AgentContext context = user == null + ? AgentContext.anonymous(message, requestType) + : AgentContext.forUser(user, message, requestType); List results = agents.stream() .filter(agent -> agent.supports(requestType)) @@ -41,7 +62,9 @@ public AgentResult route(RequestType requestType, String message) { } if (results.size() == 1) { - return results.getFirst(); + AgentResult result = results.getFirst(); + persistMemory(result.memoryWrites()); + return result; } String content = results.stream() @@ -53,7 +76,22 @@ public AgentResult route(RequestType requestType, String message) { .distinct() .toList(); - return new AgentResult(content, handledBy); + List memoryWrites = results.stream() + .flatMap(result -> result.memoryWrites().stream()) + .toList(); + persistMemory(memoryWrites); + return new AgentResult(content, handledBy, java.util.Map.of(), false, false, memoryWrites); + } + + private void persistMemory(List memoryWrites) { + if (memoryWrites == null || memoryWrites.isEmpty() || memoryService == null) { + return; + } + AgentMemoryService service = memoryService.getIfAvailable(); + if (service == null) { + return; + } + memoryWrites.forEach(service::write); } public RequestType resolveRequestType(String message) { diff --git a/src/test/java/com/example/atlas/orchestrator/OrchestratorServiceTest.java b/src/test/java/com/example/atlas/orchestrator/OrchestratorServiceTest.java index f3c2a7c..d68bb61 100644 --- a/src/test/java/com/example/atlas/orchestrator/OrchestratorServiceTest.java +++ b/src/test/java/com/example/atlas/orchestrator/OrchestratorServiceTest.java @@ -6,11 +6,22 @@ import com.example.atlas.agent.fuel.FuelAgent; import com.example.atlas.agent.habits.HabitsAgent; import com.example.atlas.agent.planner.PlannerAgent; +import com.example.atlas.agent.question.QuestionAgent; import com.example.atlas.agent.recovery.RecoveryAgent; import com.example.atlas.agent.report.ReportAgent; +import com.example.atlas.agent.AgentType; +import com.example.atlas.memory.AgentMemoryRecord; +import com.example.atlas.memory.AgentMemoryService; +import com.example.atlas.memory.MemoryWrite; +import com.example.atlas.memory.MemoryWriteResult; +import com.example.atlas.user.entity.TelegramUserEntity; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import java.time.Instant; import java.util.List; +import java.util.ArrayList; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -69,4 +80,65 @@ void allAgentsHaveNames() { assertThat(orchestratorService.route("/report").handledBy()) .allSatisfy(name -> assertThat(name).startsWith("ATLAS ")); } + + @Test + void persistsAgentMemoryWritesWhenMemoryServiceExists() { + RecordingMemoryService memoryService = new RecordingMemoryService(); + OrchestratorService service = new OrchestratorService(List.of(new QuestionAgent()), provider(memoryService)); + TelegramUserEntity user = TelegramUserEntity.create(7L, 42L, "user", "User", Instant.now()); + + service.route(user, RequestType.GENERAL, "plan habit focus"); + + assertThat(memoryService.writes).singleElement() + .extracting(MemoryWrite::ownerAgent) + .isEqualTo(AgentType.QUESTION); + } + + private static ObjectProvider provider(T value) { + return new ObjectProvider<>() { + @Override + public T getObject(Object... args) { + return value; + } + + @Override + public T getIfAvailable() { + return value; + } + + @Override + public T getIfUnique() { + return value; + } + + @Override + public T getObject() { + return value; + } + }; + } + + private static class RecordingMemoryService implements AgentMemoryService { + private final List writes = new ArrayList<>(); + + @Override + public MemoryWriteResult write(MemoryWrite write) { + writes.add(write); + return MemoryWriteResult.stored(UUID.randomUUID()); + } + + @Override + public List findForAgent(UUID userId, AgentType agentType, int limit) { + return List.of(); + } + + @Override + public List findSharedContext(UUID userId, int limit) { + return List.of(); + } + + @Override + public void archiveForUser(UUID userId) { + } + } } From 051f246c7a078f26228e18cd3aea72b7de85dcc6 Mon Sep 17 00:00:00 2001 From: Alex Today Date: Wed, 10 Jun 2026 08:35:52 +0300 Subject: [PATCH 07/10] feat(privacy): connect user data controls --- .../example/atlas/agent/core/CoreAgent.java | 161 +++++++++++++++++- .../ConversationStateRepository.java | 2 + .../repository/TelegramMessageRepository.java | 2 + .../repository/WeeklyFocusRepository.java | 4 + .../example/atlas/privacy/PrivacyService.java | 122 ++++++++++++- .../repository/ReportArchiveRepository.java | 2 + .../RoutinePreferencesRepository.java | 2 + 7 files changed, 284 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/example/atlas/agent/core/CoreAgent.java b/src/main/java/com/example/atlas/agent/core/CoreAgent.java index cacb0ea..21ba7d9 100644 --- a/src/main/java/com/example/atlas/agent/core/CoreAgent.java +++ b/src/main/java/com/example/atlas/agent/core/CoreAgent.java @@ -4,12 +4,41 @@ import com.example.atlas.agent.AgentContext; import com.example.atlas.agent.AgentResult; import com.example.atlas.orchestrator.RequestType; +import com.example.atlas.integrations.IntegrationSettingsPort; +import com.example.atlas.privacy.PrivacyExport; +import com.example.atlas.privacy.PrivacyPanel; +import com.example.atlas.privacy.PrivacyService; +import com.example.atlas.routines.RoutinePreferencesService; +import com.example.atlas.routines.entity.RoutinePreferencesEntity; import com.example.atlas.telegram.TelegramReplyTemplates; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class CoreAgent implements Agent { + private final ObjectProvider privacyService; + private final ObjectProvider routinePreferencesService; + private final ObjectProvider integrationSettingsPort; + + @Autowired + public CoreAgent( + ObjectProvider privacyService, + ObjectProvider routinePreferencesService, + ObjectProvider integrationSettingsPort + ) { + this.privacyService = privacyService; + this.routinePreferencesService = routinePreferencesService; + this.integrationSettingsPort = integrationSettingsPort; + } + + public CoreAgent() { + this.privacyService = null; + this.routinePreferencesService = null; + this.integrationSettingsPort = null; + } + @Override public String name() { return "ATLAS Core"; @@ -33,16 +62,134 @@ public AgentResult handle(AgentContext context) { String content = switch (context.requestType()) { case START -> TelegramReplyTemplates.startWelcome(); case GENERAL -> TelegramReplyTemplates.generalFallback(); - case PRIVACY -> "Privacy: ATLAS stores profile, check-ins, habits, reflections, reports, memory and Telegram identifiers. Use /export, /forget or /delete_my_data for data controls."; - case MEMORY -> "Memory: ATLAS can store user-scoped preferences, patterns and summaries. Raw sensitive content is not shown by default."; - case EXPORT -> "Export: ATLAS prepares user-scoped JSON and Markdown data export when persistence is available."; - case FORGET -> "Forget memory: this action clears memory only after explicit confirmation."; - case DELETE_MY_DATA -> "Delete my data: this destructive action requires explicit confirmation and applies only to the current user."; - case ROUTINES -> "Routines: check-in and evening reminder preferences include timezone, quiet hours and enabled state."; - case INTEGRATIONS -> "Integrations: Markdown export and calendar contracts are available as foundations without external sync."; + case PRIVACY -> privacy(context); + case MEMORY -> memory(context); + case EXPORT -> export(context); + case FORGET -> forget(context); + case DELETE_MY_DATA -> deleteMyData(context); + case ROUTINES -> routines(context); + case INTEGRATIONS -> integrations(context); default -> "Маршрут принят ATLAS Core."; }; return AgentResult.reply(content, name()); } + + private String privacy(AgentContext context) { + PrivacyService service = service(); + if (service == null || context.internalUserId() == null) { + return "Privacy: ATLAS stores profile, check-ins, habits, reflections, reports, memory and Telegram identifiers. Use /export, /forget DELETE or /delete_my_data DELETE for data controls."; + } + PrivacyPanel panel = service.panel(context.internalUserId()); + return """ + Privacy + + Stored data: + - profile records: %d + - check-ins: %d + - habits: %d + - reflections: %d + - memory records: %d + - Telegram identifiers: %s + + Use /export for a data export, /forget DELETE to archive memory, or /delete_my_data DELETE to delete user-scoped data. + """.formatted( + panel.profileCount(), + panel.checkInCount(), + panel.habitCount(), + panel.reflectionCount(), + panel.memoryCount(), + panel.telegramIdentifiersStored() ? "present" : "not present" + ); + } + + private String memory(AgentContext context) { + PrivacyService service = service(); + if (service == null || context.internalUserId() == null) { + return "Memory: ATLAS can store user-scoped preferences, patterns and summaries. Raw sensitive content is not shown by default."; + } + PrivacyPanel panel = service.panel(context.internalUserId()); + return "Memory: %d active records. Raw memory content is hidden by default. Use /forget DELETE to archive memory only.".formatted(panel.memoryCount()); + } + + private String export(AgentContext context) { + PrivacyService service = service(); + if (service == null || context.internalUserId() == null) { + return "Export is available after persistence and Telegram user context are available."; + } + PrivacyExport export = service.export(context.internalUserId()); + return export.markdown() + "\n\nJSON\n" + export.json(); + } + + private String forget(AgentContext context) { + PrivacyService service = service(); + if (service == null || context.internalUserId() == null) { + return "Forget memory requires persisted user context."; + } + if (!confirmed(context)) { + return "Forget memory requires confirmation. Send: /forget DELETE"; + } + service.forgetMemory(context.internalUserId(), "DELETE"); + return "Memory archived for the current user."; + } + + private String deleteMyData(AgentContext context) { + PrivacyService service = service(); + if (service == null || context.internalUserId() == null) { + return "Delete my data requires persisted user context."; + } + if (!confirmed(context)) { + return "Delete my data is destructive and requires confirmation. Send: /delete_my_data DELETE"; + } + service.deleteMyData(context.internalUserId(), "DELETE"); + return "User-scoped ATLAS data was deleted for the current user."; + } + + private boolean confirmed(AgentContext context) { + return context.message() != null && context.message().strip().endsWith("DELETE"); + } + + private PrivacyService service() { + return privacyService == null ? null : privacyService.getIfAvailable(); + } + + private String routines(AgentContext context) { + RoutinePreferencesService service = routinePreferencesService == null ? null : routinePreferencesService.getIfAvailable(); + if (service == null || context.user() == null) { + return "Routines: check-in and evening reminder preferences include timezone, quiet hours and enabled state."; + } + RoutinePreferencesEntity preferences = service.getOrCreate(context.user()); + return """ + Routines + + Enabled: %s + Check-in time: %s + Evening time: %s + Timezone: %s + Quiet hours: %s-%s + """.formatted( + preferences.isEnabled(), + preferences.getCheckinTime(), + preferences.getEveningTime(), + preferences.getTimezone(), + preferences.getQuietHoursStart(), + preferences.getQuietHoursEnd() + ); + } + + private String integrations(AgentContext context) { + IntegrationSettingsPort port = integrationSettingsPort == null ? null : integrationSettingsPort.getIfAvailable(); + if (port == null || context.internalUserId() == null) { + return "Integrations: Markdown export and calendar contracts are available as foundations without external sync."; + } + java.util.List settings = port.findAll(context.internalUserId()); + if (settings.isEmpty()) { + return "Integrations: no integrations enabled. Available foundations: Markdown export and calendar preview contract."; + } + String lines = settings.stream() + .map(setting -> "- %s: %s".formatted(setting.type(), setting.status())) + .reduce((left, right) -> left + "\n" + right) + .orElse(""); + return "Integrations\n\n" + lines; + } } diff --git a/src/main/java/com/example/atlas/conversation/repository/ConversationStateRepository.java b/src/main/java/com/example/atlas/conversation/repository/ConversationStateRepository.java index b4ada03..4875228 100644 --- a/src/main/java/com/example/atlas/conversation/repository/ConversationStateRepository.java +++ b/src/main/java/com/example/atlas/conversation/repository/ConversationStateRepository.java @@ -11,4 +11,6 @@ public interface ConversationStateRepository extends JpaRepository { Optional findByTelegramUserAndStatus(TelegramUserEntity telegramUser, ConversationStatus status); + + void deleteByTelegramUser(TelegramUserEntity telegramUser); } diff --git a/src/main/java/com/example/atlas/message/repository/TelegramMessageRepository.java b/src/main/java/com/example/atlas/message/repository/TelegramMessageRepository.java index a4663ad..773eb28 100644 --- a/src/main/java/com/example/atlas/message/repository/TelegramMessageRepository.java +++ b/src/main/java/com/example/atlas/message/repository/TelegramMessageRepository.java @@ -16,4 +16,6 @@ public interface TelegramMessageRepository extends JpaRepository { Optional findByTelegramUserAndWeekStart(TelegramUserEntity telegramUser, LocalDate weekStart); + + java.util.List findByTelegramUserOrderByWeekStartDesc(TelegramUserEntity telegramUser); + + void deleteByTelegramUser(TelegramUserEntity telegramUser); } diff --git a/src/main/java/com/example/atlas/privacy/PrivacyService.java b/src/main/java/com/example/atlas/privacy/PrivacyService.java index bdcb1ea..f075099 100644 --- a/src/main/java/com/example/atlas/privacy/PrivacyService.java +++ b/src/main/java/com/example/atlas/privacy/PrivacyService.java @@ -1,12 +1,19 @@ package com.example.atlas.privacy; import com.example.atlas.checkin.repository.CheckInRepository; +import com.example.atlas.conversation.repository.ConversationStateRepository; import com.example.atlas.habit.repository.HabitCheckRepository; import com.example.atlas.life.repository.LifeProfileRepository; +import com.example.atlas.integrations.repository.IntegrationSettingsRepository; import com.example.atlas.memory.PersistentAgentMemoryService; +import com.example.atlas.message.repository.TelegramMessageRepository; +import com.example.atlas.planning.repository.WeeklyFocusRepository; import com.example.atlas.reflection.repository.EveningReflectionRepository; +import com.example.atlas.reporting.repository.ReportArchiveRepository; +import com.example.atlas.routines.repository.RoutinePreferencesRepository; import com.example.atlas.user.entity.TelegramUserEntity; import com.example.atlas.user.repository.TelegramUserRepository; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +37,12 @@ public class PrivacyService { private final HabitCheckRepository habitCheckRepository; private final EveningReflectionRepository reflectionRepository; private final PersistentAgentMemoryService memoryService; + private final ObjectProvider messageRepository; + private final ObjectProvider conversationStateRepository; + private final ObjectProvider routinePreferencesRepository; + private final ObjectProvider weeklyFocusRepository; + private final ObjectProvider reportArchiveRepository; + private final ObjectProvider integrationSettingsRepository; public PrivacyService( TelegramUserRepository userRepository, @@ -37,7 +50,13 @@ public PrivacyService( CheckInRepository checkInRepository, HabitCheckRepository habitCheckRepository, EveningReflectionRepository reflectionRepository, - PersistentAgentMemoryService memoryService + PersistentAgentMemoryService memoryService, + ObjectProvider messageRepository, + ObjectProvider conversationStateRepository, + ObjectProvider routinePreferencesRepository, + ObjectProvider weeklyFocusRepository, + ObjectProvider reportArchiveRepository, + ObjectProvider integrationSettingsRepository ) { this.userRepository = userRepository; this.lifeProfileRepository = lifeProfileRepository; @@ -45,6 +64,12 @@ public PrivacyService( this.habitCheckRepository = habitCheckRepository; this.reflectionRepository = reflectionRepository; this.memoryService = memoryService; + this.messageRepository = messageRepository; + this.conversationStateRepository = conversationStateRepository; + this.routinePreferencesRepository = routinePreferencesRepository; + this.weeklyFocusRepository = weeklyFocusRepository; + this.reportArchiveRepository = reportArchiveRepository; + this.integrationSettingsRepository = integrationSettingsRepository; } @Transactional(readOnly = true) @@ -63,19 +88,33 @@ public PrivacyPanel panel(UUID userId) { @Transactional(readOnly = true) public PrivacyExport export(UUID userId) { PrivacyPanel panel = panel(userId); + TelegramUserEntity user = userRepository.findById(userId).orElseThrow(); String json = """ - {"userId":"%s","profileCount":%d,"checkInCount":%d,"habitCount":%d,"reflectionCount":%d,"memoryCount":%d} - """.formatted(userId, panel.profileCount(), panel.checkInCount(), panel.habitCount(), panel.reflectionCount(), panel.memoryCount()).strip(); + {"userId":"%s","telegramUserId":%d,"profileCount":%d,"checkInCount":%d,"habitCount":%d,"reflectionCount":%d,"memoryCount":%d,"checkIns":%s,"habits":%s,"reflections":%s,"memory":%s} + """.formatted( + userId, + user.getTelegramUserId(), + panel.profileCount(), + panel.checkInCount(), + panel.habitCount(), + panel.reflectionCount(), + panel.memoryCount(), + checkInsJson(user), + habitsJson(user), + reflectionsJson(user), + memoryJson(userId) + ).strip(); String markdown = """ # ATLAS export User: %s + Telegram user id: %d Profile records: %d Check-ins: %d Habits: %d Reflections: %d Memory records: %d - """.formatted(userId, panel.profileCount(), panel.checkInCount(), panel.habitCount(), panel.reflectionCount(), panel.memoryCount()); + """.formatted(userId, user.getTelegramUserId(), panel.profileCount(), panel.checkInCount(), panel.habitCount(), panel.reflectionCount(), panel.memoryCount()); return new PrivacyExport(json, markdown); } @@ -90,6 +129,12 @@ public void deleteMyData(UUID userId, String confirmation) { requireConfirmation(confirmation); TelegramUserEntity user = userRepository.findById(userId).orElseThrow(); memoryService.archiveForUser(userId); + deleteIfAvailable(integrationSettingsRepository, repository -> repository.deleteByTelegramUser(user)); + deleteIfAvailable(reportArchiveRepository, repository -> repository.deleteByTelegramUser(user)); + deleteIfAvailable(weeklyFocusRepository, repository -> repository.deleteByTelegramUser(user)); + deleteIfAvailable(routinePreferencesRepository, repository -> repository.deleteByTelegramUser(user)); + deleteIfAvailable(conversationStateRepository, repository -> repository.deleteByTelegramUser(user)); + deleteIfAvailable(messageRepository, repository -> repository.deleteByTelegramUser(user)); reflectionRepository.deleteByTelegramUser(user); habitCheckRepository.deleteByTelegramUser(user); checkInRepository.deleteByTelegramUser(user); @@ -102,4 +147,73 @@ private void requireConfirmation(String confirmation) { throw new IllegalArgumentException("confirmation_required"); } } + + private String checkInsJson(TelegramUserEntity user) { + return checkInRepository.findByTelegramUserOrderByCreatedAtDesc(user).stream() + .map(checkIn -> """ + {"createdAt":"%s","energy":%s,"focus":%s,"stress":%s,"sleep":%s,"mood":%s,"overload":%s,"pain":%s,"priority":"%s","notes":"%s"} + """.formatted( + checkIn.getCreatedAt(), + number(checkIn.getEnergy()), + number(checkIn.getFocus()), + number(checkIn.getStress()), + number(checkIn.getSleepQuality()), + number(checkIn.getMood()), + checkIn.isOverloadFlag(), + checkIn.isPainFlag(), + escape(checkIn.getMainPriority()), + escape(checkIn.getNotes()) + ).strip()) + .reduce((left, right) -> left + "," + right) + .map(value -> "[" + value + "]") + .orElse("[]"); + } + + private String habitsJson(TelegramUserEntity user) { + return habitCheckRepository.findByTelegramUserAndCreatedAtAfterOrderByCreatedAtDesc(user, java.time.Instant.EPOCH).stream() + .map(habit -> """ + {"name":"%s","minimum":"%s","completed":%s} + """.formatted(escape(habit.getHabitName()), escape(habit.getMinimumVersion()), habit.isCompleted()).strip()) + .reduce((left, right) -> left + "," + right) + .map(value -> "[" + value + "]") + .orElse("[]"); + } + + private String reflectionsJson(TelegramUserEntity user) { + return reflectionRepository.findByTelegramUserAndCreatedAtAfterOrderByCreatedAtDesc(user, java.time.Instant.EPOCH).stream() + .map(reflection -> """ + {"result":"%s","blocker":"%s","tomorrowFocus":"%s"} + """.formatted(escape(reflection.getMainResult()), escape(reflection.getMainBlocker()), escape(reflection.getTomorrowFocus())).strip()) + .reduce((left, right) -> left + "," + right) + .map(value -> "[" + value + "]") + .orElse("[]"); + } + + private String memoryJson(UUID userId) { + return memoryService.findRecent(userId, 100).stream() + .map(memory -> """ + {"agent":"%s","type":"%s","scope":"%s","title":"%s","content":"%s","confidence":"%s"} + """.formatted(memory.agentType(), memory.type(), memory.scope(), escape(memory.title()), escape(memory.content()), memory.confidence()).strip()) + .reduce((left, right) -> left + "," + right) + .map(value -> "[" + value + "]") + .orElse("[]"); + } + + private String number(Integer value) { + return value == null ? "null" : value.toString(); + } + + private String escape(String value) { + if (value == null) { + return ""; + } + return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", ""); + } + + private void deleteIfAvailable(ObjectProvider provider, java.util.function.Consumer deleteAction) { + T repository = provider == null ? null : provider.getIfAvailable(); + if (repository != null) { + deleteAction.accept(repository); + } + } } diff --git a/src/main/java/com/example/atlas/reporting/repository/ReportArchiveRepository.java b/src/main/java/com/example/atlas/reporting/repository/ReportArchiveRepository.java index 1184b3e..bb562dc 100644 --- a/src/main/java/com/example/atlas/reporting/repository/ReportArchiveRepository.java +++ b/src/main/java/com/example/atlas/reporting/repository/ReportArchiveRepository.java @@ -10,4 +10,6 @@ public interface ReportArchiveRepository extends JpaRepository { List findByTelegramUserOrderByCreatedAtDesc(TelegramUserEntity telegramUser); + + void deleteByTelegramUser(TelegramUserEntity telegramUser); } diff --git a/src/main/java/com/example/atlas/routines/repository/RoutinePreferencesRepository.java b/src/main/java/com/example/atlas/routines/repository/RoutinePreferencesRepository.java index 0f19888..d2bcd63 100644 --- a/src/main/java/com/example/atlas/routines/repository/RoutinePreferencesRepository.java +++ b/src/main/java/com/example/atlas/routines/repository/RoutinePreferencesRepository.java @@ -10,4 +10,6 @@ public interface RoutinePreferencesRepository extends JpaRepository { Optional findByTelegramUser(TelegramUserEntity telegramUser); + + void deleteByTelegramUser(TelegramUserEntity telegramUser); } From d1bcd11e306aefbd09ba5578ed03108ad9462930 Mon Sep 17 00:00:00 2001 From: Alex Today Date: Wed, 10 Jun 2026 08:36:09 +0300 Subject: [PATCH 08/10] feat(hosted): enforce runtime hardening --- .../deployment/DeploymentModeService.java | 3 +- .../atlas/telegram/TelegramUpdateHandler.java | 37 +++++++++++++++-- src/main/resources/application.yml | 2 + .../deployment/DeploymentModeServiceTest.java | 41 +++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/example/atlas/deployment/DeploymentModeServiceTest.java diff --git a/src/main/java/com/example/atlas/deployment/DeploymentModeService.java b/src/main/java/com/example/atlas/deployment/DeploymentModeService.java index 582e89b..e0eb90d 100644 --- a/src/main/java/com/example/atlas/deployment/DeploymentModeService.java +++ b/src/main/java/com/example/atlas/deployment/DeploymentModeService.java @@ -38,6 +38,7 @@ public boolean isSafe() { && properties.telegram().enabled() && properties.telegram().hasBotToken() && properties.telegram().mode() == TelegramLaunchMode.WEBHOOK - && properties.telegram().effectiveWebhookUrl() != null; + && properties.telegram().effectiveWebhookUrl() != null + && properties.telegram().hasWebhookSecret(); } } diff --git a/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java b/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java index 22fb0cb..b0674fb 100644 --- a/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java +++ b/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java @@ -1,6 +1,7 @@ package com.example.atlas.telegram; import com.example.atlas.conversation.service.TelegramLifeFlowService; +import com.example.atlas.hosted.HostedRateLimiter; import com.example.atlas.message.service.TelegramMessagePersistenceService; import com.example.atlas.orchestrator.OrchestratorService; import com.example.atlas.orchestrator.RequestType; @@ -17,6 +18,7 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; +import java.time.Duration; import java.time.Instant; @Component @@ -33,6 +35,7 @@ public class TelegramUpdateHandler { private final TelegramActionRouter actionRouter; private final TelegramKeyboardFactory keyboardFactory; private final ObjectProvider eventPublisher; + private final ObjectProvider hostedRateLimiter; @Autowired public TelegramUpdateHandler( @@ -44,7 +47,8 @@ public TelegramUpdateHandler( ObjectProvider lifeFlowService, TelegramActionRouter actionRouter, TelegramKeyboardFactory keyboardFactory, - ObjectProvider eventPublisher + ObjectProvider eventPublisher, + ObjectProvider hostedRateLimiter ) { this.orchestratorService = orchestratorService; this.messageSender = messageSender; @@ -55,6 +59,7 @@ public TelegramUpdateHandler( this.actionRouter = actionRouter; this.keyboardFactory = keyboardFactory; this.eventPublisher = eventPublisher; + this.hostedRateLimiter = hostedRateLimiter; } public TelegramUpdateHandler( @@ -76,6 +81,7 @@ public TelegramUpdateHandler( lifeFlowService, actionRouter, keyboardFactory, + null, null ); } @@ -94,6 +100,7 @@ public TelegramUpdateHandler( this.actionRouter = new TelegramActionRouter(); this.keyboardFactory = new TelegramKeyboardFactory(); this.eventPublisher = null; + this.hostedRateLimiter = null; } public boolean handleUpdate(TelegramUpdate update) { @@ -137,6 +144,10 @@ public boolean handleUpdate(TelegramUpdate update) { RequestType requestType = orchestratorService.resolveRequestType(message.text()); TelegramUserEntity user = upsertUser(message); + if (!rateLimit(message.from() == null ? null : message.from().id(), "message")) { + messageSender.sendText(message.chat().id(), "Too many requests. Please wait a bit and try again."); + return true; + } recordIncoming(user, message.chat().id(), requestType, message.text()); if (requestType == RequestType.CLEAR) { @@ -205,6 +216,11 @@ private boolean handleCallbackQuery(TelegramUpdate update) { } TelegramUserEntity user = upsertUser(callbackQuery); + if (!rateLimit(callbackUserId(callbackQuery), "callback")) { + messageSender.sendText(chatId, "Too many requests. Please wait a bit and try again."); + answerCallback(callbackQuery.id(), "Rate limit"); + return true; + } RoutedResponse response = routeCallback(user, callbackData); if (response.editPanel() && callbackQuery.message().messageId() != null) { boolean edited = messageSender.editPanel(chatId, callbackQuery.message().messageId(), response.content(), response.replyMarkup()); @@ -247,15 +263,22 @@ private RoutedResponse handleTextMessage(TelegramUserEntity user, String text, R result.requestType(), result.replyMarkup() == null ? keyboardFactory.forRequest(result.requestType()) : result.replyMarkup() )) - .orElseGet(() -> fallbackResponse(text, requestType)); + .orElseGet(() -> fallbackResponse(user, text, requestType)); } - return fallbackResponse(text, requestType); + return fallbackResponse(user, text, requestType); } private RoutedResponse fallbackResponse(String text, RequestType requestType) { return new RoutedResponse(handleTextMessage(text, requestType), requestType, keyboardFactory.forRequest(requestType)); } + private RoutedResponse fallbackResponse(TelegramUserEntity user, String text, RequestType requestType) { + if (safetyGuard.requiresSafetyResponse(text)) { + return new RoutedResponse(safetyGuard.safetyResponse(), requestType, keyboardFactory.forRequest(requestType)); + } + return new RoutedResponse(orchestratorService.route(user, requestType, text).content(), requestType, keyboardFactory.forRequest(requestType)); + } + private RoutedResponse routeCallback(TelegramUserEntity user, String callbackData) { TelegramLifeFlowService service = lifeFlowService == null ? null : lifeFlowService.getIfAvailable(); if (!actionRouter.isSupportedCallback(callbackData)) { @@ -569,6 +592,14 @@ private String mainMenuCaption(UserLanguage language) { : "ATLAS\n\nЧто хочешь сделать сейчас?"; } + private boolean rateLimit(Long telegramUserId, String operation) { + HostedRateLimiter limiter = hostedRateLimiter == null ? null : hostedRateLimiter.getIfAvailable(); + if (limiter == null || telegramUserId == null) { + return true; + } + return limiter.allow(telegramUserId, operation, 60, Duration.ofMinutes(1)); + } + private void answerCallback(String callbackQueryId, String text) { if (callbackQueryId == null || callbackQueryId.isBlank()) { return; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c9fb219..21cf4dd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,8 @@ spring: application: name: atlas + lifecycle: + timeout-per-shutdown-phase: 20s datasource: url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/atlas} username: ${SPRING_DATASOURCE_USERNAME:atlas} diff --git a/src/test/java/com/example/atlas/deployment/DeploymentModeServiceTest.java b/src/test/java/com/example/atlas/deployment/DeploymentModeServiceTest.java new file mode 100644 index 0000000..aea0224 --- /dev/null +++ b/src/test/java/com/example/atlas/deployment/DeploymentModeServiceTest.java @@ -0,0 +1,41 @@ +package com.example.atlas.deployment; + +import com.example.atlas.config.AtlasProperties; +import com.example.atlas.runtime.entity.TelegramLaunchMode; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DeploymentModeServiceTest { + + @Test + void hostedModeRequiresWebhookSecret() { + AtlasProperties withoutSecret = properties(""); + AtlasProperties withSecret = properties("secret"); + + assertThat(new DeploymentModeService(withoutSecret).isSafe()).isFalse(); + assertThat(new DeploymentModeService(withSecret).isSafe()).isTrue(); + } + + private AtlasProperties properties(String webhookSecret) { + return new AtlasProperties( + new AtlasProperties.Telegram( + true, + "token", + "atlas_bot", + TelegramLaunchMode.WEBHOOK, + "/telegram/webhook", + "https://atlas.example/telegram/webhook", + webhookSecret, + "", + false, + true + ), + new AtlasProperties.Setup(false), + null, + new AtlasProperties.Deployment(DeploymentMode.HOSTED), + null, + null + ); + } +} From d9981e6daf9ae5c03f5c50890df79ec5970a7aa9 Mon Sep 17 00:00:00 2001 From: Alex Today Date: Wed, 10 Jun 2026 08:36:31 +0300 Subject: [PATCH 09/10] feat(routines): connect planning reports and integrations --- .../service/TelegramLifeFlowService.java | 51 ++++++++- .../PersistentIntegrationSettingsAdapter.java | 102 ++++++++++++++++++ .../entity/IntegrationSettingsEntity.java | 30 ++++++ .../IntegrationSettingsRepository.java | 19 ++++ .../life/service/WeeklyLifeReportService.java | 81 ++++++++++++-- .../atlas/planning/WeeklyPlanningService.java | 8 +- .../planning/entity/WeeklyFocusEntity.java | 9 ++ .../reporting/entity/ReportArchiveEntity.java | 12 +++ .../routines/ReminderSchedulerService.java | 13 +++ .../atlas/telegram/TelegramAction.java | 6 +- .../atlas/telegram/TelegramActionRouter.java | 18 +++- .../telegram/TelegramKeyboardFactory.java | 4 + .../ReminderSchedulerServiceTest.java | 12 +++ 13 files changed, 354 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/example/atlas/integrations/PersistentIntegrationSettingsAdapter.java create mode 100644 src/main/java/com/example/atlas/integrations/repository/IntegrationSettingsRepository.java diff --git a/src/main/java/com/example/atlas/conversation/service/TelegramLifeFlowService.java b/src/main/java/com/example/atlas/conversation/service/TelegramLifeFlowService.java index b060098..00bff02 100644 --- a/src/main/java/com/example/atlas/conversation/service/TelegramLifeFlowService.java +++ b/src/main/java/com/example/atlas/conversation/service/TelegramLifeFlowService.java @@ -12,6 +12,7 @@ import com.example.atlas.life.service.WeeklyLifeReportService; import com.example.atlas.llm.LlmQuestionAnswerService; import com.example.atlas.orchestrator.RequestType; +import com.example.atlas.planning.WeeklyPlanningService; import com.example.atlas.reflection.service.EveningReflectionService; import com.example.atlas.safety.SafetyGuard; import com.example.atlas.telegram.InlineKeyboardMarkup; @@ -54,6 +55,7 @@ public class TelegramLifeFlowService { private final SafetyGuard safetyGuard; private final TelegramKeyboardFactory keyboardFactory; private final ObjectProvider llmQuestionAnswerService; + private final ObjectProvider weeklyPlanningService; @Autowired public TelegramLifeFlowService( @@ -66,7 +68,8 @@ public TelegramLifeFlowService( WeeklyLifeReportService weeklyReportService, SafetyGuard safetyGuard, TelegramKeyboardFactory keyboardFactory, - ObjectProvider llmQuestionAnswerService + ObjectProvider llmQuestionAnswerService, + ObjectProvider weeklyPlanningService ) { this.conversationStateService = conversationStateService; this.lifeProfileService = lifeProfileService; @@ -78,6 +81,7 @@ public TelegramLifeFlowService( this.safetyGuard = safetyGuard; this.keyboardFactory = keyboardFactory; this.llmQuestionAnswerService = llmQuestionAnswerService; + this.weeklyPlanningService = weeklyPlanningService; } public TelegramLifeFlowService( @@ -101,6 +105,7 @@ public TelegramLifeFlowService( weeklyReportService, safetyGuard, keyboardFactory, + null, null ); } @@ -125,6 +130,7 @@ public TelegramLifeFlowService( weeklyReportService, safetyGuard, new TelegramKeyboardFactory(), + null, null ); } @@ -154,6 +160,9 @@ public Optional handle(TelegramUserEntity user, String text, Request } return Optional.of(new FlowResult(dayPlanService.dayPlan(user), requestType, keyboardFactory.dayPlanActions(language(user)))); } + if (requestType == RequestType.WEEK_PLAN) { + return Optional.of(weeklyPlan(user, text)); + } if (requestType == RequestType.HABITS) { UserLanguage language = language(user); return Optional.of(startFlow(user, ConversationFlowType.HABIT_TRACKING, "ASK_HABIT", habitEmptyState(language))); @@ -196,6 +205,46 @@ private Optional answerQuestion(TelegramUserEntity user, String text .map(answer -> new FlowResult(answer, RequestType.GENERAL, keyboardFactory.questionActions(language))); } + private FlowResult weeklyPlan(TelegramUserEntity user, String text) { + WeeklyPlanningService service = weeklyPlanningService == null ? null : weeklyPlanningService.getIfAvailable(); + UserLanguage language = language(user); + if (service == null) { + return new FlowResult( + language == UserLanguage.EN + ? "Weekly planning is not available without persistence." + : "Недельное планирование недоступно без persistence.", + RequestType.WEEK_PLAN, + keyboardFactory.backToMenu(language) + ); + } + String focus = commandArgument(text); + if (focus.isBlank()) { + String current = service.currentFocus(user, java.time.LocalDate.now()); + return new FlowResult( + current == null || current.isBlank() + ? "Weekly focus is not set. Send: /week
" + : "Weekly focus: " + current + "\n\nTo update it, send: /week
", + RequestType.WEEK_PLAN, + keyboardFactory.reportActions(language) + ); + } + service.saveFocus(user, java.time.LocalDate.now(), focus); + return new FlowResult( + "Weekly focus saved: " + focus + "\n\nWeekly reports will use this focus.", + RequestType.WEEK_PLAN, + keyboardFactory.reportActions(language) + ); + } + + private String commandArgument(String text) { + if (text == null) { + return ""; + } + String stripped = text.strip(); + int space = stripped.indexOf(' '); + return space < 0 ? "" : stripped.substring(space + 1).strip(); + } + private FlowResult startOnboardingOrWelcomeBack(TelegramUserEntity user) { Optional active = conversationStateService.active(user); if (active.isPresent()) { diff --git a/src/main/java/com/example/atlas/integrations/PersistentIntegrationSettingsAdapter.java b/src/main/java/com/example/atlas/integrations/PersistentIntegrationSettingsAdapter.java new file mode 100644 index 0000000..a0f04e3 --- /dev/null +++ b/src/main/java/com/example/atlas/integrations/PersistentIntegrationSettingsAdapter.java @@ -0,0 +1,102 @@ +package com.example.atlas.integrations; + +import com.example.atlas.integrations.entity.IntegrationSettingsEntity; +import com.example.atlas.integrations.repository.IntegrationSettingsRepository; +import com.example.atlas.user.entity.TelegramUserEntity; +import com.example.atlas.user.repository.TelegramUserRepository; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@ConditionalOnBean({IntegrationSettingsRepository.class, TelegramUserRepository.class}) +public class PersistentIntegrationSettingsAdapter implements IntegrationSettingsPort { + + private final IntegrationSettingsRepository repository; + private final TelegramUserRepository userRepository; + + public PersistentIntegrationSettingsAdapter(IntegrationSettingsRepository repository, TelegramUserRepository userRepository) { + this.repository = repository; + this.userRepository = userRepository; + } + + @Override + @Transactional(readOnly = true) + public Optional find(UUID userId, IntegrationType type) { + TelegramUserEntity user = userRepository.findById(userId).orElse(null); + if (user == null) { + return Optional.empty(); + } + return repository.findByTelegramUserAndIntegrationType(user, type).map(this::toSettings); + } + + @Override + @Transactional(readOnly = true) + public List findAll(UUID userId) { + TelegramUserEntity user = userRepository.findById(userId).orElse(null); + if (user == null) { + return List.of(); + } + return repository.findByTelegramUserOrderByIntegrationTypeAsc(user).stream() + .map(this::toSettings) + .toList(); + } + + @Override + @Transactional + public IntegrationSettings save(IntegrationSettings settings) { + TelegramUserEntity user = userRepository.findById(settings.userId()).orElseThrow(); + Instant now = Instant.now(); + IntegrationSettingsEntity entity = repository.findByTelegramUserAndIntegrationType(user, settings.type()) + .map(existing -> { + existing.update(settings.status(), encode(settings.safeMetadata()), now); + return existing; + }) + .orElseGet(() -> new IntegrationSettingsEntity(UUID.randomUUID(), user, settings.type(), settings.status(), encode(settings.safeMetadata()), now)); + return toSettings(repository.save(entity)); + } + + private IntegrationSettings toSettings(IntegrationSettingsEntity entity) { + return new IntegrationSettings( + entity.getTelegramUser().getId(), + entity.getIntegrationType(), + entity.getStatus(), + decode(entity.getSafeMetadataJson()) + ); + } + + private String encode(Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return ""; + } + return metadata.entrySet().stream() + .map(entry -> safe(entry.getKey()) + "=" + safe(entry.getValue())) + .sorted() + .collect(Collectors.joining("\n")); + } + + private Map decode(String metadata) { + if (metadata == null || metadata.isBlank()) { + return Map.of(); + } + return Arrays.stream(metadata.split("\n")) + .map(line -> line.split("=", 2)) + .filter(parts -> parts.length == 2) + .collect(Collectors.toUnmodifiableMap(parts -> parts[0], parts -> parts[1], (left, right) -> right)); + } + + private String safe(String value) { + if (value == null) { + return ""; + } + return value.replace("\n", " ").replace("\r", " ").replace("=", ":").strip(); + } +} diff --git a/src/main/java/com/example/atlas/integrations/entity/IntegrationSettingsEntity.java b/src/main/java/com/example/atlas/integrations/entity/IntegrationSettingsEntity.java index dd7085b..22fb0f2 100644 --- a/src/main/java/com/example/atlas/integrations/entity/IntegrationSettingsEntity.java +++ b/src/main/java/com/example/atlas/integrations/entity/IntegrationSettingsEntity.java @@ -51,4 +51,34 @@ public IntegrationSettingsEntity(UUID id, TelegramUserEntity telegramUser, Integ this.safeMetadataJson = safeMetadataJson; this.updatedAt = updatedAt; } + + public UUID getId() { + return id; + } + + public TelegramUserEntity getTelegramUser() { + return telegramUser; + } + + public IntegrationType getIntegrationType() { + return integrationType; + } + + public IntegrationStatus getStatus() { + return status; + } + + public String getSafeMetadataJson() { + return safeMetadataJson; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void update(IntegrationStatus status, String safeMetadataJson, Instant updatedAt) { + this.status = status; + this.safeMetadataJson = safeMetadataJson; + this.updatedAt = updatedAt; + } } diff --git a/src/main/java/com/example/atlas/integrations/repository/IntegrationSettingsRepository.java b/src/main/java/com/example/atlas/integrations/repository/IntegrationSettingsRepository.java new file mode 100644 index 0000000..591ca6a --- /dev/null +++ b/src/main/java/com/example/atlas/integrations/repository/IntegrationSettingsRepository.java @@ -0,0 +1,19 @@ +package com.example.atlas.integrations.repository; + +import com.example.atlas.integrations.IntegrationType; +import com.example.atlas.integrations.entity.IntegrationSettingsEntity; +import com.example.atlas.user.entity.TelegramUserEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface IntegrationSettingsRepository extends JpaRepository { + + Optional findByTelegramUserAndIntegrationType(TelegramUserEntity telegramUser, IntegrationType integrationType); + + List findByTelegramUserOrderByIntegrationTypeAsc(TelegramUserEntity telegramUser); + + void deleteByTelegramUser(TelegramUserEntity telegramUser); +} diff --git a/src/main/java/com/example/atlas/life/service/WeeklyLifeReportService.java b/src/main/java/com/example/atlas/life/service/WeeklyLifeReportService.java index afd5bda..91ab28a 100644 --- a/src/main/java/com/example/atlas/life/service/WeeklyLifeReportService.java +++ b/src/main/java/com/example/atlas/life/service/WeeklyLifeReportService.java @@ -6,8 +6,13 @@ import com.example.atlas.habit.service.HabitService; import com.example.atlas.life.entity.LifeProfileEntity; import com.example.atlas.llm.LlmReportSummaryService; +import com.example.atlas.planning.WeeklyPlanningService; import com.example.atlas.reflection.entity.EveningReflectionEntity; import com.example.atlas.reflection.service.EveningReflectionService; +import com.example.atlas.reporting.TrendDetectionService; +import com.example.atlas.reporting.TrendSummary; +import com.example.atlas.reporting.entity.ReportArchiveEntity; +import com.example.atlas.reporting.repository.ReportArchiveRepository; import com.example.atlas.user.entity.TelegramUserEntity; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; @@ -17,9 +22,12 @@ import java.time.Clock; import java.time.Instant; +import java.time.LocalDate; import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; import java.util.List; import java.util.OptionalDouble; +import java.util.UUID; @Service @ConditionalOnBean({LifeProfileService.class, CheckInRepository.class, HabitService.class, EveningReflectionService.class}) @@ -31,6 +39,9 @@ public class WeeklyLifeReportService { private final EveningReflectionService reflectionService; private final Clock clock; private final ObjectProvider llmReportSummaryService; + private final ObjectProvider weeklyPlanningService; + private final ObjectProvider trendDetectionService; + private final ObjectProvider reportArchiveRepository; public WeeklyLifeReportService( LifeProfileService lifeProfileService, @@ -38,7 +49,7 @@ public WeeklyLifeReportService( HabitService habitService, EveningReflectionService reflectionService ) { - this(lifeProfileService, checkInRepository, habitService, reflectionService, Clock.systemUTC(), null); + this(lifeProfileService, checkInRepository, habitService, reflectionService, Clock.systemUTC(), null, null, null, null); } @Autowired @@ -47,9 +58,12 @@ public WeeklyLifeReportService( CheckInRepository checkInRepository, HabitService habitService, EveningReflectionService reflectionService, - ObjectProvider llmReportSummaryService + ObjectProvider llmReportSummaryService, + ObjectProvider weeklyPlanningService, + ObjectProvider trendDetectionService, + ObjectProvider reportArchiveRepository ) { - this(lifeProfileService, checkInRepository, habitService, reflectionService, Clock.systemUTC(), llmReportSummaryService); + this(lifeProfileService, checkInRepository, habitService, reflectionService, Clock.systemUTC(), llmReportSummaryService, weeklyPlanningService, trendDetectionService, reportArchiveRepository); } WeeklyLifeReportService( @@ -59,6 +73,20 @@ public WeeklyLifeReportService( EveningReflectionService reflectionService, Clock clock, ObjectProvider llmReportSummaryService + ) { + this(lifeProfileService, checkInRepository, habitService, reflectionService, clock, llmReportSummaryService, null, null, null); + } + + WeeklyLifeReportService( + LifeProfileService lifeProfileService, + CheckInRepository checkInRepository, + HabitService habitService, + EveningReflectionService reflectionService, + Clock clock, + ObjectProvider llmReportSummaryService, + ObjectProvider weeklyPlanningService, + ObjectProvider trendDetectionService, + ObjectProvider reportArchiveRepository ) { this.lifeProfileService = lifeProfileService; this.checkInRepository = checkInRepository; @@ -66,16 +94,18 @@ public WeeklyLifeReportService( this.reflectionService = reflectionService; this.clock = clock; this.llmReportSummaryService = llmReportSummaryService; + this.weeklyPlanningService = weeklyPlanningService; + this.trendDetectionService = trendDetectionService; + this.reportArchiveRepository = reportArchiveRepository; } @Transactional public String weeklyReport(TelegramUserEntity user) { String deterministic = deterministicWeeklyReport(user); LlmReportSummaryService service = llmReportSummaryService == null ? null : llmReportSummaryService.getIfAvailable(); - if (service == null) { - return deterministic; - } - return service.summary(user, deterministic).orElse(deterministic); + String report = service == null ? deterministic : service.summary(user, deterministic).orElse(deterministic); + archive(user, report); + return report; } private String deterministicWeeklyReport(TelegramUserEntity user) { @@ -84,16 +114,21 @@ private String deterministicWeeklyReport(TelegramUserEntity user) { List checkIns = checkInRepository.findByTelegramUserAndCreatedAtAfterOrderByCreatedAtDesc(user, since); List habits = habitService.recent(user, since); List reflections = reflectionService.recent(user, since); + TrendSummary trends = trends(checkIns, habits); return """ Недельный отчёт + Weekly focus: %s Check-ins: %d из 7 Средняя энергия: %s Средний фокус: %s Средний стресс: %s Сон: %s + Trends: + energy=%s, focus=%s, stress=%s, sleep=%s, habits=%s + Привычки: %s @@ -106,11 +141,17 @@ private String deterministicWeeklyReport(TelegramUserEntity user) { Фокус следующей недели: %s """.formatted( + weeklyFocus(user), checkIns.size(), averageText(checkIns.stream().map(CheckInEntity::getEnergy).toList()), averageText(checkIns.stream().map(CheckInEntity::getFocus).toList()), averageText(checkIns.stream().map(CheckInEntity::getStress).toList()), averageText(checkIns.stream().map(CheckInEntity::getSleepQuality).toList()), + trends.energyTrend(), + trends.focusTrend(), + trends.stressTrend(), + trends.sleepTrend(), + trends.habitConsistency(), habitSummary(habits), reflectionSummary(reflections), pattern(checkIns), @@ -194,4 +235,30 @@ private double average(List values) { private String orMissing(String value) { return value == null || value.isBlank() ? "нет данных" : value; } + + private TrendSummary trends(List checkIns, List habits) { + TrendDetectionService service = trendDetectionService == null ? null : trendDetectionService.getIfAvailable(); + if (service == null) { + return new TrendSummary("not_available", "not_available", "not_available", "not_available", "not_available"); + } + return service.summarize(checkIns, habits); + } + + private String weeklyFocus(TelegramUserEntity user) { + WeeklyPlanningService service = weeklyPlanningService == null ? null : weeklyPlanningService.getIfAvailable(); + if (service == null) { + return "not set"; + } + String focus = service.currentFocus(user, LocalDate.now(clock)); + return focus == null || focus.isBlank() ? "not set" : focus; + } + + private void archive(TelegramUserEntity user, String report) { + ReportArchiveRepository repository = reportArchiveRepository == null ? null : reportArchiveRepository.getIfAvailable(); + if (repository == null || user == null || report == null || report.isBlank()) { + return; + } + LocalDate weekStart = LocalDate.now(clock).with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)); + repository.save(new ReportArchiveEntity(UUID.randomUUID(), user, weekStart, report, Instant.now(clock))); + } } diff --git a/src/main/java/com/example/atlas/planning/WeeklyPlanningService.java b/src/main/java/com/example/atlas/planning/WeeklyPlanningService.java index d1ca82f..4324dc5 100644 --- a/src/main/java/com/example/atlas/planning/WeeklyPlanningService.java +++ b/src/main/java/com/example/atlas/planning/WeeklyPlanningService.java @@ -25,7 +25,13 @@ public WeeklyPlanningService(WeeklyFocusRepository repository) { @Transactional public WeeklyFocusEntity saveFocus(TelegramUserEntity user, LocalDate date, String focus) { LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)); - return repository.save(new WeeklyFocusEntity(UUID.randomUUID(), user, weekStart, focus.strip(), Instant.now())); + Instant now = Instant.now(); + return repository.findByTelegramUserAndWeekStart(user, weekStart) + .map(existing -> { + existing.updateFocus(focus.strip(), now); + return repository.save(existing); + }) + .orElseGet(() -> repository.save(new WeeklyFocusEntity(UUID.randomUUID(), user, weekStart, focus.strip(), now))); } @Transactional(readOnly = true) diff --git a/src/main/java/com/example/atlas/planning/entity/WeeklyFocusEntity.java b/src/main/java/com/example/atlas/planning/entity/WeeklyFocusEntity.java index 9fe2729..8194d4c 100644 --- a/src/main/java/com/example/atlas/planning/entity/WeeklyFocusEntity.java +++ b/src/main/java/com/example/atlas/planning/entity/WeeklyFocusEntity.java @@ -46,4 +46,13 @@ public WeeklyFocusEntity(UUID id, TelegramUserEntity telegramUser, LocalDate wee public String getFocus() { return focus; } + + public LocalDate getWeekStart() { + return weekStart; + } + + public void updateFocus(String focus, Instant now) { + this.focus = focus; + this.createdAt = now; + } } diff --git a/src/main/java/com/example/atlas/reporting/entity/ReportArchiveEntity.java b/src/main/java/com/example/atlas/reporting/entity/ReportArchiveEntity.java index a075f4f..7d3bbef 100644 --- a/src/main/java/com/example/atlas/reporting/entity/ReportArchiveEntity.java +++ b/src/main/java/com/example/atlas/reporting/entity/ReportArchiveEntity.java @@ -42,4 +42,16 @@ public ReportArchiveEntity(UUID id, TelegramUserEntity telegramUser, LocalDate w this.content = content; this.createdAt = createdAt; } + + public LocalDate getWeekStart() { + return weekStart; + } + + public String getContent() { + return content; + } + + public Instant getCreatedAt() { + return createdAt; + } } diff --git a/src/main/java/com/example/atlas/routines/ReminderSchedulerService.java b/src/main/java/com/example/atlas/routines/ReminderSchedulerService.java index 5a3e4c2..2d2ad7d 100644 --- a/src/main/java/com/example/atlas/routines/ReminderSchedulerService.java +++ b/src/main/java/com/example/atlas/routines/ReminderSchedulerService.java @@ -3,11 +3,16 @@ import com.example.atlas.routines.entity.RoutinePreferencesEntity; import org.springframework.stereotype.Service; +import java.time.LocalDate; import java.time.LocalTime; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; @Service public class ReminderSchedulerService { + private final Set sentReminders = ConcurrentHashMap.newKeySet(); + public boolean shouldSend(RoutinePreferencesEntity preferences, LocalTime now) { if (preferences == null || !preferences.isEnabled()) { return false; @@ -17,6 +22,14 @@ public boolean shouldSend(RoutinePreferencesEntity preferences, LocalTime now) { return !insideQuietHours(now, quietStart, quietEnd); } + public boolean claimReminder(RoutinePreferencesEntity preferences, String reminderType, LocalDate date, LocalTime now) { + if (!shouldSend(preferences, now)) { + return false; + } + String key = preferences.getTelegramUser().getId() + ":" + reminderType + ":" + date; + return sentReminders.add(key); + } + private boolean insideQuietHours(LocalTime now, LocalTime start, LocalTime end) { if (start.equals(end)) { return false; diff --git a/src/main/java/com/example/atlas/telegram/TelegramAction.java b/src/main/java/com/example/atlas/telegram/TelegramAction.java index 82a1b7d..bc94d81 100644 --- a/src/main/java/com/example/atlas/telegram/TelegramAction.java +++ b/src/main/java/com/example/atlas/telegram/TelegramAction.java @@ -23,5 +23,9 @@ public enum TelegramAction { RESTART_ONBOARDING, CHANGE_LANGUAGE, SELECT_LANGUAGE_RU, - SELECT_LANGUAGE_EN + SELECT_LANGUAGE_EN, + OPEN_PRIVACY, + OPEN_ROUTINES, + OPEN_WEEKLY_PLANNING, + OPEN_INTEGRATIONS } diff --git a/src/main/java/com/example/atlas/telegram/TelegramActionRouter.java b/src/main/java/com/example/atlas/telegram/TelegramActionRouter.java index 41ce4fc..55d78a4 100644 --- a/src/main/java/com/example/atlas/telegram/TelegramActionRouter.java +++ b/src/main/java/com/example/atlas/telegram/TelegramActionRouter.java @@ -34,6 +34,10 @@ public class TelegramActionRouter { public static final String SETTINGS_LANGUAGE = "atlas:settings:language"; public static final String LANGUAGE_RU = "atlas:language:ru"; public static final String LANGUAGE_EN = "atlas:language:en"; + public static final String PRIVACY = "atlas:privacy"; + public static final String ROUTINES = "atlas:routines"; + public static final String WEEKLY_PLANNING = "atlas:weekly_planning"; + public static final String INTEGRATIONS = "atlas:integrations"; private static final Set LIFE_AREAS = Set.of( "DAILY_STRUCTURE", @@ -70,7 +74,11 @@ public class TelegramActionRouter { Map.entry(ONBOARDING_RESTART, TelegramAction.RESTART_ONBOARDING), Map.entry(SETTINGS_LANGUAGE, TelegramAction.CHANGE_LANGUAGE), Map.entry(LANGUAGE_RU, TelegramAction.SELECT_LANGUAGE_RU), - Map.entry(LANGUAGE_EN, TelegramAction.SELECT_LANGUAGE_EN) + Map.entry(LANGUAGE_EN, TelegramAction.SELECT_LANGUAGE_EN), + Map.entry(PRIVACY, TelegramAction.OPEN_PRIVACY), + Map.entry(ROUTINES, TelegramAction.OPEN_ROUTINES), + Map.entry(WEEKLY_PLANNING, TelegramAction.OPEN_WEEKLY_PLANNING), + Map.entry(INTEGRATIONS, TelegramAction.OPEN_INTEGRATIONS) ); public Optional actionForCommand(String text, boolean onboardingCompleted) { @@ -133,6 +141,10 @@ public RequestType requestType(TelegramAction action) { case OPEN_MAIN_MENU, GO_BACK, CONTINUE_FLOW, RESTART_FLOW, CONFIRM_ACTION, DECLINE_ACTION, START_QUESTION, OPEN_SETTINGS, OPEN_PROFILE, CONFIRM_RESTART_ONBOARDING, CHANGE_LANGUAGE, SELECT_LANGUAGE_RU, SELECT_LANGUAGE_EN -> RequestType.GENERAL; + case OPEN_PRIVACY -> RequestType.PRIVACY; + case OPEN_ROUTINES -> RequestType.ROUTINES; + case OPEN_WEEKLY_PLANNING -> RequestType.WEEK_PLAN; + case OPEN_INTEGRATIONS -> RequestType.INTEGRATIONS; }; } @@ -150,6 +162,10 @@ public String commandForAction(TelegramAction action) { case OPEN_MAIN_MENU, GO_BACK, CONTINUE_FLOW, RESTART_FLOW, CONFIRM_ACTION, DECLINE_ACTION, START_QUESTION, OPEN_SETTINGS, OPEN_PROFILE, CONFIRM_RESTART_ONBOARDING, CHANGE_LANGUAGE, SELECT_LANGUAGE_RU, SELECT_LANGUAGE_EN -> ""; + case OPEN_PRIVACY -> "/privacy"; + case OPEN_ROUTINES -> "/routines"; + case OPEN_WEEKLY_PLANNING -> "/week"; + case OPEN_INTEGRATIONS -> "/integrations"; }; } diff --git a/src/main/java/com/example/atlas/telegram/TelegramKeyboardFactory.java b/src/main/java/com/example/atlas/telegram/TelegramKeyboardFactory.java index 3e434df..a56500d 100644 --- a/src/main/java/com/example/atlas/telegram/TelegramKeyboardFactory.java +++ b/src/main/java/com/example/atlas/telegram/TelegramKeyboardFactory.java @@ -233,6 +233,8 @@ public InlineKeyboardMarkup settingsActions(UserLanguage language) { return keyboard( row(button("🌐 Change language", TelegramActionRouter.SETTINGS_LANGUAGE)), row(button("🧭 Profile", TelegramActionRouter.PROFILE)), + row(button("Privacy", TelegramActionRouter.PRIVACY), button("Routines", TelegramActionRouter.ROUTINES)), + row(button("Weekly plan", TelegramActionRouter.WEEKLY_PLANNING), button("Integrations", TelegramActionRouter.INTEGRATIONS)), row(button("🔄 Restart onboarding", TelegramActionRouter.SETTINGS_RESTART_CONFIRM)), row(menuButton(language)) ); @@ -240,6 +242,8 @@ public InlineKeyboardMarkup settingsActions(UserLanguage language) { return keyboard( row(button("🌐 Изменить язык", TelegramActionRouter.SETTINGS_LANGUAGE)), row(button("🧭 Профиль", TelegramActionRouter.PROFILE)), + row(button("Privacy", TelegramActionRouter.PRIVACY), button("Routines", TelegramActionRouter.ROUTINES)), + row(button("Weekly plan", TelegramActionRouter.WEEKLY_PLANNING), button("Integrations", TelegramActionRouter.INTEGRATIONS)), row(button("🔄 Перезапустить onboarding", TelegramActionRouter.SETTINGS_RESTART_CONFIRM)), row(menuButton(language)) ); diff --git a/src/test/java/com/example/atlas/routines/ReminderSchedulerServiceTest.java b/src/test/java/com/example/atlas/routines/ReminderSchedulerServiceTest.java index 6aee391..fb0ba57 100644 --- a/src/test/java/com/example/atlas/routines/ReminderSchedulerServiceTest.java +++ b/src/test/java/com/example/atlas/routines/ReminderSchedulerServiceTest.java @@ -1,9 +1,11 @@ package com.example.atlas.routines; import com.example.atlas.routines.entity.RoutinePreferencesEntity; +import com.example.atlas.user.entity.TelegramUserEntity; import org.junit.jupiter.api.Test; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalTime; import static org.assertj.core.api.Assertions.assertThat; @@ -21,4 +23,14 @@ void respectsQuietHoursAndEnabledFlag() { assertThat(service.shouldSend(enabled, LocalTime.parse("23:00"))).isFalse(); assertThat(service.shouldSend(disabled, LocalTime.parse("09:00"))).isFalse(); } + + @Test + void preventsDuplicateReminderClaimsPerUserTypeAndDay() { + TelegramUserEntity user = TelegramUserEntity.create(7L, 42L, "user", "User", Instant.now()); + RoutinePreferencesEntity enabled = new RoutinePreferencesEntity(null, user, "09:00", "21:00", "Europe/Moscow", "22:00", "08:00", true, Instant.now()); + + assertThat(service.claimReminder(enabled, "checkin", LocalDate.parse("2026-06-09"), LocalTime.parse("09:00"))).isTrue(); + assertThat(service.claimReminder(enabled, "checkin", LocalDate.parse("2026-06-09"), LocalTime.parse("09:05"))).isFalse(); + assertThat(service.claimReminder(enabled, "evening", LocalDate.parse("2026-06-09"), LocalTime.parse("21:00"))).isTrue(); + } } From 360ac69ac0e245cf2d86201dfebbf8b9e9244714 Mon Sep 17 00:00:00 2001 From: Alex Today Date: Wed, 10 Jun 2026 08:36:50 +0300 Subject: [PATCH 10/10] docs(docs): update release completion docs --- docs/en/changelog.md | 11 +++ docs/en/deployment.md | 10 ++- docs/en/integrations.md | 4 +- docs/en/privacy.md | 8 +- docs/en/reports.md | 4 +- docs/en/routines.md | 4 +- docs/en/weekly-planning.md | 4 +- docs/ru/changelog.md | 166 +++++-------------------------------- docs/ru/deployment.md | 16 +++- docs/ru/integrations.md | 6 +- docs/ru/privacy.md | 14 ++-- docs/ru/reports.md | 13 +-- docs/ru/routines.md | 6 +- docs/ru/weekly-planning.md | 6 +- 14 files changed, 94 insertions(+), 178 deletions(-) diff --git a/docs/en/changelog.md b/docs/en/changelog.md index b840a34..06c6513 100644 --- a/docs/en/changelog.md +++ b/docs/en/changelog.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Changed +- Connected user-aware agent routing so Planner, Report and Question agents can use persisted user context. +- Persisted agent-proposed memory writes through `AgentMemoryService`. +- Wired privacy commands to real export, forget-memory and delete-my-data service behavior. +- Connected weekly focus to weekly reports and archived generated reports. +- Persisted integration settings through `IntegrationSettingsPort`. +- Wired hosted rate limits and LLM quotas into Telegram and LLM flows. +- Hardened hosted mode to require a webhook secret and documented backup/restore. + ## v0.9.0 ### Added diff --git a/docs/en/deployment.md b/docs/en/deployment.md index c65094a..f2eeee0 100644 --- a/docs/en/deployment.md +++ b/docs/en/deployment.md @@ -5,6 +5,14 @@ ATLAS supports two deployment modes through `ATLAS_DEPLOYMENT_MODE`: - `self_hosted`: default mode. Local setup can be enabled and a user-provided Telegram bot token is allowed. - `hosted`: foundation for a server-owned runtime. Setup must be disabled, Telegram must run in webhook mode, and bot credentials must come from server environment or secrets. -Hosted mode blocks unsafe combinations such as public setup, missing webhook URL, missing bot token or polling-only runtime. +Hosted mode blocks unsafe combinations such as public setup, missing webhook URL, missing webhook secret, missing bot token or polling-only runtime. Status output may show mode, setup state, provider names and whether settings exist. It must not show Telegram tokens, webhook secrets or LLM API keys. + +## Backup And Restore Checklist + +- Back up PostgreSQL with `pg_dump` or provider-native snapshots. +- Back up persistent runtime volumes, especially `/app/data/memory` if memory snapshots are enabled. +- Do not back up plaintext `.env` files into shared storage; store secrets in the deployment secret manager. +- Restore PostgreSQL first, then runtime volumes, then restart ATLAS with the same `ATLAS_DEPLOYMENT_MODE`. +- Verify `/actuator/health/readiness` and `/actuator/health/liveness` after restore. diff --git a/docs/en/integrations.md b/docs/en/integrations.md index b062822..91c6228 100644 --- a/docs/en/integrations.md +++ b/docs/en/integrations.md @@ -2,9 +2,9 @@ The integration foundation is port-first: -- integration settings store safe metadata only; +- integration settings persist safe metadata only through `IntegrationSettingsPort`; - Markdown export is user-scoped; - calendar integration exposes a preview contract; - no OAuth flow or full external sync is included. -Future integrations can implement these ports without changing ATLAS into a multi-service system. +The Telegram settings panel links to integrations status. Future integrations can implement these ports without changing ATLAS into a multi-service system. diff --git a/docs/en/privacy.md b/docs/en/privacy.md index 67bd557..6012959 100644 --- a/docs/en/privacy.md +++ b/docs/en/privacy.md @@ -1,11 +1,11 @@ # Privacy Controls -ATLAS exposes foundations for: +ATLAS exposes user-facing controls for: - `/privacy`: explain stored profile, check-ins, habits, reflections, reports, memory and Telegram identifiers. - `/memory`: show memory categories and counts without raw sensitive content by default. -- `/export`: produce user-scoped JSON and Markdown export data. -- `/forget`: archive memory records only, after confirmation. -- `/delete_my_data`: delete user-scoped data after strong confirmation. +- `/export`: produce user-scoped JSON and Markdown export data with saved check-ins, habits, reflections and memory records. +- `/forget DELETE`: archive memory records only, after confirmation. +- `/delete_my_data DELETE`: delete user-scoped profile, tracking, conversation, message, routine, planning, report, integration and memory data after strong confirmation. Destructive operations require the exact confirmation value `DELETE` in the service layer. Export, forget and deletion use the internal user id and do not operate across users. diff --git a/docs/en/reports.md b/docs/en/reports.md index 3ad458f..db319a3 100644 --- a/docs/en/reports.md +++ b/docs/en/reports.md @@ -4,6 +4,6 @@ Reports now have deterministic foundations for: - energy, focus, stress and sleep trends; - habit consistency; -- report archive navigation. +- report archive persistence. -Trend detection only uses saved check-ins and habit records. Reports must show missing data instead of inventing conclusions. +Weekly reports include deterministic trends, saved weekly focus when available and are archived after generation. Trend detection only uses saved check-ins and habit records. Reports must show missing data instead of inventing conclusions. diff --git a/docs/en/routines.md b/docs/en/routines.md index 84a41de..a5326e1 100644 --- a/docs/en/routines.md +++ b/docs/en/routines.md @@ -8,4 +8,6 @@ Routine preferences store: - quiet hours; - enabled flag. -The reminder scheduler foundation checks whether a reminder may be sent without interrupting quiet hours. Telegram settings can use the same model for reminder buttons. +The reminder scheduler checks whether a reminder may be sent without interrupting quiet hours and claims a per-user, per-day reminder key to prevent duplicate check-in or evening reminders. + +The Telegram settings panel links to the routines panel so users can see the current persisted reminder preferences. diff --git a/docs/en/weekly-planning.md b/docs/en/weekly-planning.md index da80c0a..838169d 100644 --- a/docs/en/weekly-planning.md +++ b/docs/en/weekly-planning.md @@ -2,4 +2,6 @@ Weekly planning adds a persisted weekly focus per user and week. The week starts on Monday. -The planning flow can save a focus, retrieve the current focus and connect that data to weekly reports. The model is intentionally small: it stores the focus text and week start without introducing external calendar sync. +The Telegram command `/week
` saves or updates the current weekly focus. `/week` without an argument shows the current focus. + +Weekly reports include the saved weekly focus when available. The model is intentionally small: it stores the focus text and week start without introducing external calendar sync. diff --git a/docs/ru/changelog.md b/docs/ru/changelog.md index 890a43d..76d7141 100644 --- a/docs/ru/changelog.md +++ b/docs/ru/changelog.md @@ -1,171 +1,51 @@ # История изменений +## Unreleased + +### Изменено +- Agent routing стал user-aware: Planner, Report и Question agents получают persisted user context. +- Agent-proposed memory writes сохраняются через `AgentMemoryService`. +- Команды `/privacy`, `/memory`, `/export`, `/forget DELETE` и `/delete_my_data DELETE` подключены к реальному сервисному поведению. +- Weekly focus подключён к weekly report, отчёты архивируются после генерации. +- Integration settings сохраняются через `IntegrationSettingsPort`. +- Hosted rate limits и LLM quotas подключены к Telegram и LLM flows. +- Hosted mode требует webhook secret; добавлен backup/restore checklist. + ## v0.9.0 -### Добавлено -- Добавлены порты интеграций и безопасные metadata для настроек интеграций. -- Добавлена основа пользовательского Markdown export. -- Добавлен контракт preview для calendar integration без OAuth и внешней синхронизации. +- Добавлены порты интеграций, settings model, Markdown export foundation и calendar preview contract. ## v0.8.2 -### Добавлено -- Добавлен детерминированный trend detection для энергии, фокуса, стресса и сна. -- Добавлен анализ регулярности привычек. -- Добавлена основа архива отчетов. +- Добавлены deterministic trends, habit consistency и report archive foundation. ## v0.8.1 -### Добавлено -- Добавлена модель фокуса недели. -- Добавлен сервис недельного планирования для сохранения и получения текущего фокуса. -- Подготовлена связь недельного плана с недельным отчетом. +- Добавлены weekly focus model и weekly planning service. ## v0.8.0 -### Добавлено -- Добавлены настройки рутины: check-in time, evening time, timezone, quiet hours и enabled state. -- Добавлена основа scheduler, которая учитывает quiet hours. - -## v0.7.3 - -### Добавлено -- Добавлены основы hosted rate limits и LLM quotas. -- Readiness и liveness остаются доступны через Spring Boot health probes. - -## v0.7.2 - -### Добавлено -- Добавлены основы privacy panel, export, forget memory и delete my data. -- Добавлены строгие подтверждения для разрушающих операций. - -### Безопасность -- Privacy operations работают только в рамках пользователя и не раскрывают секреты. - -## v0.7.1 - -### Добавлено -- Добавлена основа hosted runtime для серверной Telegram-конфигурации. -- Добавлены webhook-first проверки через deployment validation. -- Добавлен базовый per-user rate limiting. - -## v0.7.0 - -### Добавлено -- Добавлены явные режимы self-hosted и hosted. -- Добавлены безопасный deployment status и валидация небезопасных hosted-сочетаний. - -## v0.6.4 - -### Добавлено -- Добавлен memory-aware LLM context assembly. -- Добавлены лимиты контекста и user-scoped retrieval для shared и agent-specific memory. - -## v0.6.3 - -### Добавлено -- Добавлена PostgreSQL-схема для memory records. -- Добавлены user-scoped repository и persistent memory service. -- Добавлены опциональные runtime Markdown memory snapshots. - -## v0.6.2 - -### Добавлено -- Добавлены memory write model, policy, validation result и memory service contract. -- Agent results расширены proposed memory writes. - -## v0.6.1 - -### Добавлено -- Добавлены первые scoped LLM agent abstractions и question agent. -- Добавлены fallback и safety metadata для agent responses. - -## v0.6.0 - -### Добавлено -- Добавлен опциональный LLM abstraction layer. -- Добавлен OpenAI-compatible LLM client. -- Добавлены настройки LLM через environment variables. -- Добавлены prompt templates для плана дня, недельного отчёта и вопросов. -- Добавлен сбор контекста из профиля, check-in, привычек и рефлексий. -- Добавлено LLM-улучшение плана дня при включённом LLM. -- Добавлено LLM-улучшение недельного отчёта при включённом LLM. -- Добавлен структурированный режим ответов на вопросы в рамках ATLAS. -- Добавлен безопасный fallback на deterministic responses. -- Добавлена документация по LLM на русском и английском языках. - -### Изменено -- ATLAS может работать как с LLM, так и без LLM. -- В setup/status добавлен безопасный статус LLM без отображения секретов. - -### Исправлено -- Добавлена защита от утечки LLM API key в логах, UI и документации. -- Добавлена обработка timeout, rate limit и ошибок провайдера. - -### Безопасность -- LLM не используется для медицинских диагнозов или лечебных рекомендаций. -- При серьёзных симптомах ATLAS использует безопасные ответы и рекомендует обратиться к специалисту. - -## v0.5.4 - -### Добавлено -- Добавлена документация по архитектуре модульного монолита. -- Добавлены ADR по ключевым архитектурным решениям. -- Добавлены внутренние application events. -- Добавлены архитектурные тесты для контроля зависимостей между слоями. - -### Изменено -- Уточнены границы модулей: Telegram, identity, setup, profile, tracking, planning, reporting, safety и runtime. -- Telegram слой оформлен как adapter, а не как доменная логика. -- Основные сценарии перенесены ближе к application use cases. -- README дополнен архитектурным описанием проекта. - -### Архитектура -- ATLAS остаётся модульным монолитом. -- Микросервисы не вводятся в этом релизе. -- Зафиксирован будущий путь выделения сервисов через bounded contexts и internal events. - -## v0.5.3 - -### Добавлено -- Добавлены стандартные кнопки навигации: Назад, Меню, Отменить, Продолжить. -- Добавлена панель продолжения незавершённого сценария. -- Добавлены действия после завершения check-in, плана дня, привычек, вечерней рефлексии и отчёта. -- Добавлены улучшенные empty states для отчёта, плана дня и привычек. -- Добавлена улучшенная панель настроек и профиля. -- Добавлено подтверждение перезапуска onboarding. - -### Изменено -- Улучшена консистентность команд и кнопок. -- Улучшена обработка устаревших и некорректных callback-кнопок. -- Документация реорганизована в `/docs/ru` и `/docs/en`. - -### Исправлено -- Меню больше не должно случайно сбрасывать активный сценарий. -- Секреты Telegram не отображаются в настройках и профиле. +- Добавлены routine preferences и scheduler foundation с quiet hours. -## v0.5.2 +## v0.7.x -- Добавлен дружелюбный локальный запуск через `make start`. -- Добавлены безопасные локальные `.env` placeholders. -- Добавлены setup mode и preconfigured local bot mode. -- Добавлен безопасный setup status без Telegram secrets. +- Добавлены deployment modes, hosted foundation, privacy controls и hosted production hardening foundations. -## v0.5.1 +## v0.6.x -- Добавлен Telegram UX layer с inline-кнопками, callback routing, language-first onboarding и меню. +- Добавлены LLM agents, memory contract, persistent memory и memory-aware context assembly. -## v0.5.0 +## v0.5.x -- Добавлены life onboarding, check-in, habits, evening reflection и weekly report flows. +- Добавлены life flows, Telegram UX, local launch и модульная архитектура. ## v0.4.0 -- Добавлены PostgreSQL persistence, runtime settings, setup page, Telegram token validation, polling и webhook modes. +- Добавлены PostgreSQL persistence, runtime settings, setup page, polling и webhook modes. ## v0.3.x -- Добавлена базовая Telegram-интеграция, webhook endpoint, безопасное логирование и backend-only структура. +- Добавлена базовая Telegram-интеграция, webhook endpoint и безопасное логирование. ## v0.2.0 diff --git a/docs/ru/deployment.md b/docs/ru/deployment.md index cabd33e..6044528 100644 --- a/docs/ru/deployment.md +++ b/docs/ru/deployment.md @@ -1,10 +1,18 @@ -# Режимы развертывания +# Режимы развёртывания ATLAS поддерживает два режима через `ATLAS_DEPLOYMENT_MODE`: -- `self_hosted`: режим по умолчанию. Локальная настройка может быть включена, токен Telegram-бота задает владелец установки. -- `hosted`: основа серверного режима. Setup должен быть выключен, Telegram работает через webhook, токены берутся только из окружения или секретов сервера. +- `self_hosted`: режим по умолчанию. Локальный setup может быть включён, Telegram bot token задаёт владелец установки. +- `hosted`: серверный режим. Setup должен быть выключен, Telegram работает через webhook, bot token и webhook secret берутся только из окружения или secret manager. -Hosted mode блокирует небезопасные сочетания: публичный setup, отсутствие webhook URL, отсутствие токена или polling-only запуск. +Hosted mode блокирует небезопасные сочетания: публичный setup, отсутствие webhook URL, отсутствие webhook secret, отсутствие bot token или polling-only runtime. Статусы могут показывать режим, состояние setup и наличие настроек. Telegram token, webhook secret и LLM API key не отображаются. + +## Backup And Restore Checklist + +- Делай backup PostgreSQL через `pg_dump` или snapshots провайдера. +- Делай backup runtime volumes, особенно `/app/data/memory`, если включены memory snapshots. +- Не складывай plaintext `.env` в общий backup; секреты должны жить в secret manager. +- При восстановлении сначала восстанови PostgreSQL, затем runtime volumes, затем запусти ATLAS с тем же `ATLAS_DEPLOYMENT_MODE`. +- После восстановления проверь `/actuator/health/readiness` и `/actuator/health/liveness`. diff --git a/docs/ru/integrations.md b/docs/ru/integrations.md index 5b7803c..17569f0 100644 --- a/docs/ru/integrations.md +++ b/docs/ru/integrations.md @@ -2,9 +2,9 @@ Основа интеграций построена через порты: -- настройки интеграций хранят только безопасные metadata; +- настройки интеграций хранят только безопасные metadata через `IntegrationSettingsPort`; - Markdown export работает в рамках пользователя; -- calendar integration содержит контракт preview; -- OAuth и полная внешняя синхронизация не входят в релиз. +- calendar integration содержит preview-контракт без OAuth и внешней синхронизации; +- панель настроек Telegram ведёт в статус интеграций. Будущие интеграции могут реализовать эти порты без разделения ATLAS на микросервисы. diff --git a/docs/ru/privacy.md b/docs/ru/privacy.md index b4fe158..47db665 100644 --- a/docs/ru/privacy.md +++ b/docs/ru/privacy.md @@ -1,11 +1,11 @@ # Приватность -ATLAS добавляет основы пользовательского контроля данных: +ATLAS даёт пользователю контроль над сохранёнными данными: -- `/privacy`: описание сохраненных профиля, check-ins, привычек, рефлексий, отчетов, памяти и Telegram-идентификаторов; -- `/memory`: категории и счетчики памяти без сырого чувствительного содержания по умолчанию; -- `/export`: пользовательский JSON и Markdown export; -- `/forget`: архивирование памяти после подтверждения; -- `/delete_my_data`: удаление пользовательских данных после строгого подтверждения. +- `/privacy`: показывает сохранённые категории данных: профиль, check-ins, привычки, рефлексии, отчёты, память и Telegram-идентификаторы. +- `/memory`: показывает счётчик активных записей памяти без сырого содержимого. +- `/export`: возвращает пользовательский JSON и Markdown export с check-ins, привычками, рефлексиями и памятью. +- `/forget DELETE`: архивирует записи памяти текущего пользователя. +- `/delete_my_data DELETE`: удаляет профиль, трекинг, сообщения, conversation state, routines, weekly planning, reports, integrations и память текущего пользователя. -Разрушающие операции требуют значение `DELETE` на уровне сервиса. Export, forget и deletion всегда используют внутренний user id и не затрагивают других пользователей. +Разрушающие операции требуют точное подтверждение `DELETE`. Все операции используют внутренний user id и не затрагивают других пользователей. diff --git a/docs/ru/reports.md b/docs/ru/reports.md index ba88f35..b49bc18 100644 --- a/docs/ru/reports.md +++ b/docs/ru/reports.md @@ -1,9 +1,10 @@ -# Отчеты +# Отчёты -Отчеты получили детерминированные основы для: +Отчёты используют детерминированные данные: -- трендов энергии, фокуса, стресса и сна; -- анализа регулярности привычек; -- архива отчетов. +- тренды энергии, фокуса, стресса и сна; +- регулярность привычек; +- сохранённый недельный фокус; +- архив сгенерированных отчётов. -Trend detection использует только сохраненные check-ins и привычки. Отчеты должны показывать отсутствие данных, а не придумывать выводы. +Trend detection использует только сохранённые check-ins и привычки. Отчёты показывают отсутствие данных вместо выдуманных выводов. diff --git a/docs/ru/routines.md b/docs/ru/routines.md index c3f94c6..3fc2eb4 100644 --- a/docs/ru/routines.md +++ b/docs/ru/routines.md @@ -1,6 +1,6 @@ # Рутины -Настройки рутины хранят: +Настройки рутин хранят: - время дневного check-in; - время вечерней рефлексии; @@ -8,4 +8,6 @@ - quiet hours; - флаг включения. -Основа scheduler проверяет, можно ли отправить напоминание, не нарушая quiet hours. Telegram-настройки могут использовать ту же модель для кнопок напоминаний. +Scheduler проверяет quiet hours и использует per-user/per-day ключ напоминания, чтобы не отправлять дубликаты одного типа напоминания за день. + +Панель настроек Telegram ведёт в раздел routines и показывает текущие сохранённые настройки. diff --git a/docs/ru/weekly-planning.md b/docs/ru/weekly-planning.md index 6c12e09..e0d934c 100644 --- a/docs/ru/weekly-planning.md +++ b/docs/ru/weekly-planning.md @@ -1,5 +1,7 @@ # Недельное планирование -Недельное планирование хранит фокус недели для пользователя и недели. Неделя начинается в понедельник. +Недельное планирование хранит один фокус недели для пользователя и недели. Неделя начинается в понедельник. -Flow может сохранить фокус, получить текущий фокус и связать его с недельным отчетом. Модель намеренно компактная и не добавляет внешнюю календарную синхронизацию. +Команда `/week <главный фокус>` сохраняет или обновляет текущий недельный фокус. Команда `/week` без аргумента показывает текущий фокус. + +Недельный отчёт использует сохранённый фокус, если он есть. Модель намеренно компактная и не добавляет внешнюю календарную синхронизацию.