# Core Engine - Design Document ## 1. Overview The Core Engine implements the news aggregation and briefing pipeline for daybrief.ai. It consists of: - **Source Management**: CRUD operations for RSS feed sources with extensible provider pattern - **Ingestion Engine**: Scheduled fetching and parsing of RSS feeds into NewsItem entities - **Processing Pipeline**: AI-powered summarization, tagging, and scoring using Spring AI - **Briefing System**: User-configurable DayBriefs with scheduled execution and report generation ## 2. Architecture ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ REST API Layer │ │ SourceController │ NewsController │ DayBriefController │ └─────────────────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────────────────┐ │ Service Layer │ │ SourceService │ NewsItemService │ DayBriefService │ ReportService │ └─────────────────────────────────────────────────────────────────────────────┘ │ ┌──────────────────────┬──────────────────────┬───────────────────────────────┐ │ Ingestion Engine │ Processing Pipeline │ Briefing Executor │ │ ┌────────────────┐ │ ┌────────────────┐ │ ┌─────────────────────────┐ │ │ │ FeedFetcher │ │ │ NewsProcessor │ │ │ BriefingScheduler │ │ │ │ (Scheduler) │ │ │ (Scheduler) │ │ │ BriefingExecutor │ │ │ └────────────────┘ │ └────────────────┘ │ └─────────────────────────┘ │ │ │ │ │ │ │ │ │ ┌────────────────┐ │ ┌────────────────┐ │ │ │ │ SourceFetcher │ │ │ AI Services │ │ │ │ │ (Interface) │ │ │ (Spring AI) │ │ │ │ └────────────────┘ │ └────────────────┘ │ │ │ ▲ │ │ │ │ │ │ │ │ │ ┌────────────────┐ │ │ │ │ │ RssFetcher │ │ │ │ │ │ (Rome lib) │ │ │ │ │ └────────────────┘ │ │ │ └──────────────────────┴──────────────────────┴───────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────────────────┐ │ Repository Layer │ │ SourceRepository │ NewsItemRepository │ DayBriefRepository │ ReportRepo │ └─────────────────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────────────────┐ │ PostgreSQL │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ## 3. Components and Interfaces ### 3.1 Source Fetching Abstraction ```java // provider/sourcefetch/SourceFetcher.java public interface SourceFetcher { /** * Returns the source type this fetcher handles */ SourceType getSourceType(); /** * Validates if the URL is a valid source for this type */ SourceValidationResult validate(String url); /** * Fetches items from the source * @param source The source entity * @param lastFetchedAt Only fetch items newer than this (null for first fetch) * @return List of raw feed items */ List fetch(Source source, LocalDateTime lastFetchedAt); } // provider/sourcefetch/model/SourceValidationResult.java @Data @Builder public class SourceValidationResult { private boolean valid; private String feedTitle; // Auto-detected title private String feedDescription; // Auto-detected description private String errorMessage; // If invalid } // provider/sourcefetch/model/FetchedItem.java @Data @Builder public class FetchedItem { private String guid; private String title; private String link; private String author; private LocalDateTime publishedAt; private String rawContent; // Original HTML/text private String cleanContent; // Stripped text } ``` ### 3.2 RSS Fetcher Implementation ```java // provider/sourcefetch/RssFetcher.java @Component @RequiredArgsConstructor public class RssFetcher implements SourceFetcher { @Override public SourceType getSourceType() { return SourceType.RSS; } @Override public SourceValidationResult validate(String url) { // Use Rome library to validate and extract feed metadata } @Override public List fetch(Source source, LocalDateTime lastFetchedAt) { // Use Rome library with HTTP caching (ETag, Last-Modified) // Parse items and convert to FetchedItem } } ``` ### 3.3 AI Processing Services Each AI task has its own dedicated service with specific input/output records for structured output. ```java // provider/ai/AiService.java public interface AiService { /** * Generate a summary for a news item */ SummaryResult summarize(String title, String content); /** * Extract tags from a news item */ TagsResult extractTags(String title, String content, String summary); /** * Score a news item against briefing criteria */ ScoreResult score(String title, String summary, String briefingCriteria); } ``` #### AI Output Records (for Spring AI structured output) ```java // provider/ai/model/SummaryResult.java @JsonPropertyOrder({"summary"}) public record SummaryResult( @JsonProperty("summary") String summary // 2-3 sentences ) {} // provider/ai/model/TagsResult.java @JsonPropertyOrder({"topics", "entities", "sentiment"}) public record TagsResult( @JsonProperty("topics") List topics, @JsonProperty("entities") EntitiesResult entities, @JsonProperty("sentiment") String sentiment // positive, neutral, negative ) {} // provider/ai/model/EntitiesResult.java @JsonPropertyOrder({"people", "companies", "technologies"}) public record EntitiesResult( @JsonProperty("people") List people, @JsonProperty("companies") List companies, @JsonProperty("technologies") List technologies ) {} // provider/ai/model/ScoreResult.java @JsonPropertyOrder({"score", "reasoning"}) public record ScoreResult( @JsonProperty("score") Integer score, // 0-100 @JsonProperty("reasoning") String reasoning // Brief explanation ) {} ``` ### 3.4 Prompt Resources Prompts are stored as text files in `src/main/resources/prompts/` for easy maintenance and versioning. ``` src/main/resources/prompts/ ├── summarize.st ├── extract-tags.st └── score-relevance.st ``` ```text // prompts/summarize.st Summarize the following news article in 2-3 concise sentences. Focus on the key facts and main point. Title: {title} Content: {content} ``` ```text // prompts/extract-tags.st Analyze the following news article and extract: - topics: List of 1-5 topic categories (e.g., "AI", "Finance", "Climate") - entities: People, companies, and technologies mentioned - sentiment: Overall sentiment (positive, neutral, or negative) Title: {title} Content: {content} Summary: {summary} ``` ```text // prompts/score-relevance.st Score the relevance of this news article (0-100) based on the user's interests. User's interests and goals: {briefingCriteria} Article title: {title} Article summary: {summary} Score higher if the article directly relates to the stated interests. Score lower if it's tangentially related or off-topic. Provide brief reasoning for the score. ``` ### 3.5 Spring AI Implementation ```java // provider/ai/SpringAiService.java @Service @RequiredArgsConstructor @ConditionalOnProperty(prefix = "application.ai", name = "provider", havingValue = "openai") public class SpringAiService implements AiService { private final ChatClient chatClient; @Value("classpath:prompts/summarize.st") private Resource summarizePromptResource; @Value("classpath:prompts/extract-tags.st") private Resource extractTagsPromptResource; @Value("classpath:prompts/score-relevance.st") private Resource scoreRelevancePromptResource; @Override public SummaryResult summarize(String title, String content) { return chatClient.prompt() .user(u -> u.text(loadPrompt(summarizePromptResource)) .param("title", title) .param("content", truncate(content, 4000))) .call() .entity(SummaryResult.class); } @Override public TagsResult extractTags(String title, String content, String summary) { return chatClient.prompt() .user(u -> u.text(loadPrompt(extractTagsPromptResource)) .param("title", title) .param("content", truncate(content, 2000)) .param("summary", summary)) .call() .entity(TagsResult.class); } @Override public ScoreResult score(String title, String summary, String briefingCriteria) { return chatClient.prompt() .user(u -> u.text(loadPrompt(scoreRelevancePromptResource)) .param("title", title) .param("summary", summary) .param("briefingCriteria", briefingCriteria)) .call() .entity(ScoreResult.class); } private String loadPrompt(Resource resource) { try { return resource.getContentAsString(StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException("Failed to load prompt resource: " + resource.getFilename(), e); } } private String truncate(String text, int maxLength) { if (text == null) return ""; return text.length() > maxLength ? text.substring(0, maxLength) + "..." : text; } } ``` ### 3.6 Core Services ```java // core/service/SourceService.java @Service @RequiredArgsConstructor public class SourceService { private final SourceRepository sourceRepository; private final List sourceFetchers; // Injected fetchers public Source createSource(UUID userId, String url, String name, SourceType type, List tags); public Source getSource(UUID userId, UUID sourceId); public Page listSources(UUID userId, SourceStatus status, Pageable pageable); public Source updateSource(UUID userId, UUID sourceId, String name, List tags, SourceStatus status); public void deleteSource(UUID userId, UUID sourceId); // Soft delete public SourceValidationResult validateSource(String url, SourceType type); } // core/service/NewsItemService.java @Service @RequiredArgsConstructor public class NewsItemService { private final NewsItemRepository newsItemRepository; public NewsItem getNewsItem(UUID userId, UUID newsItemId); public Page searchNewsItems(UUID userId, String query, UUID sourceId, LocalDateTime from, LocalDateTime to, Pageable pageable); } // core/service/DayBriefService.java @Service @RequiredArgsConstructor public class DayBriefService { private final DayBriefRepository dayBriefRepository; private final SourceRepository sourceRepository; public DayBrief createDayBrief(UUID userId, String title, String description, String briefing, List sourceIds, BriefingFrequency frequency, LocalTime scheduleTime, String timezone); public DayBrief getDayBrief(UUID userId, UUID dayBriefId); public Page listDayBriefs(UUID userId, DayBriefStatus status, Pageable pageable); public DayBrief updateDayBrief(UUID userId, UUID dayBriefId, ...); public void deleteDayBrief(UUID userId, UUID dayBriefId); // Soft delete } // core/service/ReportService.java @Service @RequiredArgsConstructor public class ReportService { private final DailyReportRepository reportRepository; public DailyReport getReport(UUID userId, UUID dayBriefId, UUID reportId); public Page listReports(UUID userId, UUID dayBriefId, Pageable pageable); } ``` ### 3.7 Background Jobs ```java // core/job/FeedIngestionJob.java @Component @RequiredArgsConstructor @Slf4j public class FeedIngestionJob { private final SourceRepository sourceRepository; private final NewsItemRepository newsItemRepository; private final List sourceFetchers; @Scheduled(fixedRateString = "${application.jobs.feed-ingestion.interval:900000}") public void fetchFeeds() { List activeSources = sourceRepository.findByStatus(SourceStatus.ACTIVE); for (Source source : activeSources) { try { fetchSource(source); } catch (Exception e) { log.error("Failed to fetch source {}: {}", source.getId(), e.getMessage()); source.setStatus(SourceStatus.ERROR); source.setLastError(e.getMessage()); sourceRepository.save(source); } } } private void fetchSource(Source source) { SourceFetcher fetcher = findFetcher(source.getType()); List items = fetcher.fetch(source, source.getLastFetchedAt()); for (FetchedItem item : items) { if (!newsItemRepository.existsBySourceIdAndGuid(source.getId(), item.getGuid())) { NewsItem newsItem = mapToNewsItem(source, item); newsItemRepository.save(newsItem); } } source.setLastFetchedAt(LocalDateTime.now()); source.setLastError(null); sourceRepository.save(source); } } // core/job/NewsProcessingJob.java @Component @RequiredArgsConstructor @Slf4j public class NewsProcessingJob { private final NewsItemRepository newsItemRepository; private final AiService aiService; @Scheduled(fixedRateString = "${application.jobs.news-processing.interval:60000}") public void processNews() { List pendingItems = newsItemRepository .findByStatusInOrderByCreatedAtAsc( List.of(NewsItemStatus.PENDING, NewsItemStatus.SUMMARIZED), PageRequest.of(0, 10) ); for (NewsItem item : pendingItems) { processItem(item); } } private void processItem(NewsItem item) { try { if (item.getStatus() == NewsItemStatus.PENDING) { SummaryResult summary = aiService.summarize(item.getTitle(), item.getCleanContent()); item.setSummary(summary.summary()); item.setStatus(NewsItemStatus.SUMMARIZED); newsItemRepository.save(item); } if (item.getStatus() == NewsItemStatus.SUMMARIZED) { TagsResult tags = aiService.extractTags( item.getTitle(), item.getCleanContent(), item.getSummary()); item.setTags(mapToJson(tags)); item.setStatus(NewsItemStatus.PROCESSED); newsItemRepository.save(item); } } catch (Exception e) { log.error("Failed to process item {}: {}", item.getId(), e.getMessage()); item.setRetryCount(item.getRetryCount() + 1); if (item.getRetryCount() >= 3) { item.setStatus(NewsItemStatus.ERROR); item.setErrorMessage(e.getMessage()); } newsItemRepository.save(item); } } } // core/job/BriefingExecutionJob.java @Component @RequiredArgsConstructor @Slf4j public class BriefingExecutionJob { private final DayBriefRepository dayBriefRepository; private final NewsItemRepository newsItemRepository; private final DailyReportRepository reportRepository; private final ReportItemRepository reportItemRepository; private final AiService aiService; @Scheduled(cron = "0 * * * * *") // Every minute public void checkAndExecuteBriefings() { LocalDateTime now = LocalDateTime.now(); List dueBriefings = dayBriefRepository.findDueBriefings( DayBriefStatus.ACTIVE, now); for (DayBrief briefing : dueBriefings) { try { executeBriefing(briefing); } catch (Exception e) { log.error("Failed to execute briefing {}: {}", briefing.getId(), e.getMessage()); } } } @Transactional public DailyReport executeBriefing(DayBrief dayBrief) { // 1. Get news items from linked sources since last execution LocalDateTime since = dayBrief.getLastExecutedAt() != null ? dayBrief.getLastExecutedAt() : LocalDateTime.now().minusDays(1); List candidates = newsItemRepository .findBySourceIdInAndStatusAndPublishedAtAfter( dayBrief.getSourceIds(), NewsItemStatus.PROCESSED, since ); // 2. Score each item against briefing criteria List scoredItems = candidates.stream() .map(item -> { ScoreResult score = aiService.score( item.getTitle(), item.getSummary(), dayBrief.getBriefing() ); return new ScoredItem(item, score.score(), score.reasoning()); }) .sorted(Comparator.comparing(ScoredItem::score).reversed()) .limit(10) .toList(); // 3. Create report with top items DailyReport report = DailyReport.builder() .dayBrief(dayBrief) .generatedAt(LocalDateTime.now()) .status(ReportStatus.GENERATED) .build(); report = reportRepository.save(report); // 4. Create report items int position = 1; for (ScoredItem scored : scoredItems) { ReportItem reportItem = ReportItem.builder() .report(report) .newsItem(scored.item()) .score(scored.score()) .position(position++) .build(); reportItemRepository.save(reportItem); } // 5. Update briefing last executed dayBrief.setLastExecutedAt(LocalDateTime.now()); dayBriefRepository.save(dayBrief); return report; } private record ScoredItem(NewsItem item, int score, String reasoning) {} } ``` ## 4. Data Models ### 4.1 Entities ```java // core/model/Source.java @Entity @Table(name = "sources", uniqueConstraints = { @UniqueConstraint(columnNames = {"user_id", "url"}) }) @Data @NoArgsConstructor @AllArgsConstructor @Builder public class Source { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @Column(name = "user_id", nullable = false) private UUID userId; @Column(nullable = false) private String name; @Column(nullable = false, length = 2048) private String url; @Enumerated(EnumType.STRING) @Column(nullable = false) private SourceType type; @Enumerated(EnumType.STRING) @Column(nullable = false) @Builder.Default private SourceStatus status = SourceStatus.ACTIVE; @Column(columnDefinition = "TEXT[]") private List tags; @Column(name = "last_fetched_at") private LocalDateTime lastFetchedAt; @Column(name = "last_error", length = 1024) private String lastError; @Column(name = "etag") private String etag; // For HTTP caching @Column(name = "last_modified") private String lastModified; // For HTTP caching @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; @Column(name = "updated_at") private LocalDateTime updatedAt; @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(); } @PreUpdate protected void onUpdate() { updatedAt = LocalDateTime.now(); } } // core/model/SourceType.java public enum SourceType { RSS // Future: NEWSLETTER, WEBSITE, API } // core/model/SourceStatus.java public enum SourceStatus { ACTIVE, PAUSED, ERROR, DELETED } // core/model/NewsItem.java @Entity @Table(name = "news_items", uniqueConstraints = { @UniqueConstraint(columnNames = {"source_id", "guid"}) }) @Data @NoArgsConstructor @AllArgsConstructor @Builder public class NewsItem { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "source_id", nullable = false) private Source source; @Column(nullable = false, length = 512) private String guid; @Column(nullable = false, length = 1024) private String title; @Column(nullable = false, length = 2048) private String link; @Column private String author; @Column(name = "published_at") private LocalDateTime publishedAt; @Column(name = "raw_content", columnDefinition = "TEXT") private String rawContent; @Column(name = "clean_content", columnDefinition = "TEXT") private String cleanContent; @Column(columnDefinition = "TEXT") private String summary; @Column(columnDefinition = "JSONB") @JdbcTypeCode(SqlTypes.JSON) private NewsItemTags tags; @Enumerated(EnumType.STRING) @Column(nullable = false) @Builder.Default private NewsItemStatus status = NewsItemStatus.PENDING; @Column(name = "error_message", length = 1024) private String errorMessage; @Column(name = "retry_count") @Builder.Default private Integer retryCount = 0; @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; @Column(name = "updated_at") private LocalDateTime updatedAt; @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(); } @PreUpdate protected void onUpdate() { updatedAt = LocalDateTime.now(); } } // core/model/NewsItemTags.java (Embeddable for JSON) @Data @NoArgsConstructor @AllArgsConstructor @Builder public class NewsItemTags { private List topics; private List people; private List companies; private List technologies; private String sentiment; } // core/model/NewsItemStatus.java public enum NewsItemStatus { PENDING, SUMMARIZED, PROCESSED, ERROR } // core/model/DayBrief.java @Entity @Table(name = "day_briefs") @Data @NoArgsConstructor @AllArgsConstructor @Builder public class DayBrief { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @Column(name = "user_id", nullable = false) private UUID userId; @Column(nullable = false) private String title; @Column(length = 1024) private String description; @Column(nullable = false, columnDefinition = "TEXT") private String briefing; // User's goals/interests for AI filtering @Enumerated(EnumType.STRING) @Column(nullable = false) private BriefingFrequency frequency; @Column(name = "schedule_time", nullable = false) private LocalTime scheduleTime; @Column(nullable = false) @Builder.Default private String timezone = "UTC"; @Enumerated(EnumType.STRING) @Column(nullable = false) @Builder.Default private DayBriefStatus status = DayBriefStatus.ACTIVE; @Column(name = "last_executed_at") private LocalDateTime lastExecutedAt; @ManyToMany @JoinTable( name = "day_brief_sources", joinColumns = @JoinColumn(name = "day_brief_id"), inverseJoinColumns = @JoinColumn(name = "source_id") ) private Set sources = new HashSet<>(); @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; @Column(name = "updated_at") private LocalDateTime updatedAt; @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(); } @PreUpdate protected void onUpdate() { updatedAt = LocalDateTime.now(); } public List getSourceIds() { return sources.stream().map(Source::getId).toList(); } } // core/model/BriefingFrequency.java public enum BriefingFrequency { DAILY, WEEKLY } // core/model/DayBriefStatus.java public enum DayBriefStatus { ACTIVE, PAUSED, DELETED } // core/model/DailyReport.java @Entity @Table(name = "daily_reports") @Data @NoArgsConstructor @AllArgsConstructor @Builder public class DailyReport { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "day_brief_id", nullable = false) private DayBrief dayBrief; @Column(name = "generated_at", nullable = false) private LocalDateTime generatedAt; @Enumerated(EnumType.STRING) @Column(nullable = false) @Builder.Default private ReportStatus status = ReportStatus.PENDING; @OneToMany(mappedBy = "report", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("position ASC") private List items = new ArrayList<>(); @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); } } // core/model/ReportStatus.java public enum ReportStatus { PENDING, GENERATED, ERROR } // core/model/ReportItem.java @Entity @Table(name = "report_items") @Data @NoArgsConstructor @AllArgsConstructor @Builder public class ReportItem { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "report_id", nullable = false) private DailyReport report; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "news_item_id", nullable = false) private NewsItem newsItem; @Column(nullable = false) private Integer score; @Column(nullable = false) private Integer position; } ``` ### 4.2 DTOs ```java // core/dto/SourceDTO.java @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class SourceDTO { private UUID id; @NotBlank(message = "URL is required") private String url; private String name; private SourceType type; private SourceStatus status; private List tags; private LocalDateTime lastFetchedAt; private String lastError; private Long itemCount; // Response only private LocalDateTime createdAt; } // core/dto/NewsItemDTO.java @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class NewsItemDTO { private UUID id; private String title; private String link; private String author; private LocalDateTime publishedAt; private String content; // cleanContent for detail view private String summary; private NewsItemTags tags; private UUID sourceId; private String sourceName; // Response only private LocalDateTime createdAt; } // core/dto/DayBriefDTO.java @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class DayBriefDTO { private UUID id; @NotBlank(message = "Title is required") private String title; private String description; @NotBlank(message = "Briefing criteria is required") private String briefing; @NotEmpty(message = "At least one source is required") private List sourceIds; @NotNull(message = "Frequency is required") private BriefingFrequency frequency; @NotNull(message = "Schedule time is required") private LocalTime scheduleTime; private String timezone; private DayBriefStatus status; private LocalDateTime lastExecutedAt; private Integer sourceCount; // Response only private LocalDateTime createdAt; } // core/dto/DailyReportDTO.java @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class DailyReportDTO { private UUID id; private UUID dayBriefId; private String dayBriefTitle; // Response only private String dayBriefDescription; // Response only private LocalDateTime generatedAt; private ReportStatus status; private List items; private Integer itemCount; // For list view } // core/dto/ReportItemDTO.java @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class ReportItemDTO { private UUID newsItemId; private String title; private String summary; private String link; private LocalDateTime publishedAt; private Integer score; private Integer position; private String sourceName; } ``` ## 5. Error Handling ### 5.1 Custom Exceptions ```java // core/exception/SourceValidationException.java public class SourceValidationException extends BadRequestException { public SourceValidationException(String message) { super(message); } } // core/exception/DuplicateSourceException.java public class DuplicateSourceException extends BadRequestException { public DuplicateSourceException(String url) { super("Source with URL already exists: " + url); } } // core/exception/SourceFetchException.java public class SourceFetchException extends RuntimeException { public SourceFetchException(String message, Throwable cause) { super(message, cause); } } // core/exception/AiProcessingException.java public class AiProcessingException extends RuntimeException { public AiProcessingException(String message, Throwable cause) { super(message, cause); } } ``` ### 5.2 Exception Handler Updates Add to GlobalExceptionHandler: ```java @ExceptionHandler(SourceValidationException.class) public ResponseEntity handleSourceValidation( SourceValidationException ex, HttpServletRequest request) { return buildErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), request); } @ExceptionHandler(DuplicateSourceException.class) public ResponseEntity handleDuplicateSource( DuplicateSourceException ex, HttpServletRequest request) { return buildErrorResponse(HttpStatus.CONFLICT, ex.getMessage(), request); } ``` ## 6. Testing Strategy ### 6.1 Unit Tests | Component | Test Class | Focus | |-----------|------------|-------| | RssFetcher | RssFetcherTest | Feed parsing, validation, HTTP caching | | SpringAiService | SpringAiServiceTest | Mock ChatClient, verify prompts | | SourceService | SourceServiceTest | CRUD operations, ownership checks | | NewsProcessingJob | NewsProcessingJobTest | Pipeline transitions, retry logic | | BriefingExecutionJob | BriefingExecutionJobTest | Scoring, selection, report creation | ### 6.2 Integration Tests | Test Class | Scope | |------------|-------| | SourceIT | Full source CRUD via API | | NewsItemIT | News item retrieval and search | | DayBriefIT | DayBrief CRUD and source linking | | ReportIT | Report generation and retrieval | | FeedIngestionIT | End-to-end feed fetch with mock RSS | ### 6.3 Test Data Use WireMock for external RSS feeds. Sample test feed: ```xml Test Feed test-item-1 Test Article https://example.com/article Mon, 01 Jan 2024 00:00:00 GMT Test content ``` ## 7. Performance Considerations ### 7.1 Database Indexes ```sql -- Source queries CREATE INDEX idx_sources_user_status ON sources(user_id, status); CREATE INDEX idx_sources_status ON sources(status); -- NewsItem queries CREATE INDEX idx_news_items_source_guid ON news_items(source_id, guid); CREATE INDEX idx_news_items_source_published ON news_items(source_id, published_at DESC); CREATE INDEX idx_news_items_status ON news_items(status); CREATE INDEX idx_news_items_created ON news_items(created_at); -- Full text search CREATE INDEX idx_news_items_title_content_gin ON news_items USING GIN (to_tsvector('english', title || ' ' || COALESCE(clean_content, ''))); -- DayBrief queries CREATE INDEX idx_day_briefs_user_status ON day_briefs(user_id, status); CREATE INDEX idx_day_briefs_schedule ON day_briefs(status, schedule_time); -- Report queries CREATE INDEX idx_daily_reports_daybrief ON daily_reports(day_brief_id, generated_at DESC); CREATE INDEX idx_report_items_report ON report_items(report_id, position); ``` ### 7.2 Job Configuration ```yaml application: jobs: feed-ingestion: interval: 900000 # 15 minutes batch-size: 50 news-processing: interval: 60000 # 1 minute batch-size: 10 briefing-execution: cron: "0 * * * * *" # Every minute check ``` ### 7.3 Connection Pooling ```yaml spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 ``` ## 8. Security Considerations ### 8.1 URL Validation (SSRF Prevention) ```java // core/util/UrlValidator.java @Component public class UrlValidator { private static final Set ALLOWED_PROTOCOLS = Set.of("http", "https"); private static final Set BLOCKED_HOSTS = Set.of( "localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254" ); public void validate(String urlString) { try { URL url = new URL(urlString); if (!ALLOWED_PROTOCOLS.contains(url.getProtocol().toLowerCase())) { throw new SourceValidationException("Only HTTP/HTTPS protocols allowed"); } String host = url.getHost().toLowerCase(); if (BLOCKED_HOSTS.contains(host) || host.endsWith(".internal")) { throw new SourceValidationException("Invalid host"); } // Check for private IP ranges InetAddress address = InetAddress.getByName(host); if (address.isLoopbackAddress() || address.isSiteLocalAddress()) { throw new SourceValidationException("Private IP addresses not allowed"); } } catch (MalformedURLException | UnknownHostException e) { throw new SourceValidationException("Invalid URL: " + e.getMessage()); } } } ``` ### 8.2 Ownership Enforcement All service methods that access user data must verify ownership: ```java private Source getSourceWithOwnershipCheck(UUID userId, UUID sourceId) { Source source = sourceRepository.findById(sourceId) .orElseThrow(() -> new ResourceNotFoundException("Source not found")); if (!source.getUserId().equals(userId)) { throw new AccessDeniedException("Access denied to source"); } return source; } ``` ### 8.3 Rate Limiting ```java // config/RateLimitConfig.java @Configuration public class RateLimitConfig { @Bean public RateLimiter sourceCreationLimiter() { return RateLimiter.of("source-creation", RateLimiterConfig.custom() .limitForPeriod(50) .limitRefreshPeriod(Duration.ofDays(1)) .build()); } } ``` ## 9. Monitoring and Observability ### 9.1 Metrics ```java // core/metrics/CoreEngineMetrics.java @Component @RequiredArgsConstructor public class CoreEngineMetrics { private final MeterRegistry registry; public void recordFeedFetch(String sourceId, boolean success, long durationMs) { registry.counter("feed.fetch", "source_id", sourceId, "success", String.valueOf(success)) .increment(); registry.timer("feed.fetch.duration").record(durationMs, TimeUnit.MILLISECONDS); } public void recordNewsProcessing(String stage, boolean success) { registry.counter("news.processing", "stage", stage, "success", String.valueOf(success)) .increment(); } public void recordBriefingExecution(String dayBriefId, int itemCount) { registry.counter("briefing.execution", "day_brief_id", dayBriefId).increment(); registry.gauge("briefing.items", itemCount); } } ``` ### 9.2 Logging ```java // Structured logging for jobs @Slf4j public class FeedIngestionJob { private void fetchSource(Source source) { log.info("Fetching source source_id={} url={}", source.getId(), source.getUrl()); // ... fetch logic log.info("Fetched source source_id={} items_count={}", source.getId(), items.size()); } } ``` ### 9.3 Health Checks ```java // config/HealthConfig.java @Component public class FeedIngestionHealthIndicator implements HealthIndicator { @Autowired private SourceRepository sourceRepository; @Override public Health health() { long errorSources = sourceRepository.countByStatus(SourceStatus.ERROR); long totalSources = sourceRepository.count(); if (errorSources > totalSources * 0.5) { return Health.down() .withDetail("error_sources", errorSources) .withDetail("total_sources", totalSources) .build(); } return Health.up() .withDetail("error_sources", errorSources) .withDetail("total_sources", totalSources) .build(); } } ``` ## 10. Configuration ### 10.1 Application Properties ```yaml # application.yml additions application: ai: provider: openai # or anthropic jobs: feed-ingestion: enabled: true interval: 900000 news-processing: enabled: true interval: 60000 briefing-execution: enabled: true spring: ai: openai: api-key: ${OPENAI_API_KEY} chat: model: gpt-4o-mini ``` ### 10.2 Dependencies (pom.xml additions) ```xml com.rometools rome 2.1.0 org.springframework.ai spring-ai-openai-spring-boot-starter org.springframework.boot spring-boot-starter-quartz ``` ## 11. Migration Script ```sql -- V4__Core_engine_init.sql CREATE TABLE sources ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id), name VARCHAR(255) NOT NULL, url VARCHAR(2048) NOT NULL, type VARCHAR(50) NOT NULL, status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', tags TEXT[], last_fetched_at TIMESTAMP, last_error VARCHAR(1024), etag VARCHAR(255), last_modified VARCHAR(255), created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP, UNIQUE(user_id, url) ); CREATE TABLE news_items ( id UUID PRIMARY KEY, source_id UUID NOT NULL REFERENCES sources(id), guid VARCHAR(512) NOT NULL, title VARCHAR(1024) NOT NULL, link VARCHAR(2048) NOT NULL, author VARCHAR(255), published_at TIMESTAMP, raw_content TEXT, clean_content TEXT, summary TEXT, tags JSONB, status VARCHAR(50) NOT NULL DEFAULT 'PENDING', error_message VARCHAR(1024), retry_count INTEGER DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP, UNIQUE(source_id, guid) ); CREATE TABLE day_briefs ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id), title VARCHAR(255) NOT NULL, description VARCHAR(1024), briefing TEXT NOT NULL, frequency VARCHAR(50) NOT NULL, schedule_time TIME NOT NULL, timezone VARCHAR(100) NOT NULL DEFAULT 'UTC', status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', last_executed_at TIMESTAMP, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP ); CREATE TABLE day_brief_sources ( day_brief_id UUID NOT NULL REFERENCES day_briefs(id) ON DELETE CASCADE, source_id UUID NOT NULL REFERENCES sources(id) ON DELETE CASCADE, PRIMARY KEY (day_brief_id, source_id) ); CREATE TABLE daily_reports ( id UUID PRIMARY KEY, day_brief_id UUID NOT NULL REFERENCES day_briefs(id), generated_at TIMESTAMP NOT NULL, status VARCHAR(50) NOT NULL DEFAULT 'PENDING', created_at TIMESTAMP NOT NULL ); CREATE TABLE report_items ( id UUID PRIMARY KEY, report_id UUID NOT NULL REFERENCES daily_reports(id) ON DELETE CASCADE, news_item_id UUID NOT NULL REFERENCES news_items(id), score INTEGER NOT NULL, position INTEGER NOT NULL ); -- Indexes CREATE INDEX idx_sources_user_status ON sources(user_id, status); CREATE INDEX idx_sources_status ON sources(status); CREATE INDEX idx_news_items_source_guid ON news_items(source_id, guid); CREATE INDEX idx_news_items_source_published ON news_items(source_id, published_at DESC); CREATE INDEX idx_news_items_status ON news_items(status); CREATE INDEX idx_news_items_created ON news_items(created_at); CREATE INDEX idx_day_briefs_user_status ON day_briefs(user_id, status); CREATE INDEX idx_day_briefs_schedule ON day_briefs(status, schedule_time); CREATE INDEX idx_daily_reports_daybrief ON daily_reports(day_brief_id, generated_at DESC); CREATE INDEX idx_report_items_report ON report_items(report_id, position); ```