diff --git a/MtdrSpring/backend/pom.xml b/MtdrSpring/backend/pom.xml index 11df5cca9..4d62155e7 100644 --- a/MtdrSpring/backend/pom.xml +++ b/MtdrSpring/backend/pom.xml @@ -89,6 +89,17 @@ 1.18.30 provided + + com.oracle.oci.sdk + oci-java-sdk-generativeaiinference + 3.46.1 + + + com.oracle.oci.sdk + oci-java-sdk-common-httpclient-jersey3 + 3.46.1 + runtime + diff --git a/MtdrSpring/backend/src/main/frontend/src/AIInsightsPanel.js b/MtdrSpring/backend/src/main/frontend/src/AIInsightsPanel.js new file mode 100644 index 000000000..03c83a432 --- /dev/null +++ b/MtdrSpring/backend/src/main/frontend/src/AIInsightsPanel.js @@ -0,0 +1,163 @@ +/* + * AI Insights chat panel — RAG-backed Q&A over project data. + * Calls POST /api/ai/ask and renders answer + source chips. + */ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Snackbar, + Stack, + TextField, + Typography +} from '@mui/material'; +import { API_AI } from './API'; + +const SUGGESTIONS = [ + '¿Cuál es el sprint activo?', + '¿Quién tiene más tareas BLOCKED?', + 'Resumen de KPIs del proyecto', + '¿Cuántos incidentes CRITICAL hay este mes?' +]; + +function AIInsightsPanel() { + const [input, setInput] = useState(''); + const [messages, setMessages] = useState([]); // {role: 'user'|'assistant', content, sources?} + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const send = (q) => { + const question = (q !== undefined ? q : input).trim(); + if (!question || loading) return; + setMessages((m) => [...m, { role: 'user', content: question }]); + setInput(''); + setLoading(true); + fetch(API_AI + '/ask', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ question }) + }) + .then((r) => r.json().then((body) => ({ ok: r.ok, body }))) + .then(({ ok, body }) => { + setLoading(false); + if (!ok) { + setError(body.error || 'Error desconocido'); + return; + } + setMessages((m) => [ + ...m, + { role: 'assistant', content: body.answer, sources: body.sources || [] } + ]); + }) + .catch((err) => { + setLoading(false); + setError(err.message || 'Network error'); + }); + }; + + const onKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }; + + return ( + + + + Preguntale al proyecto + + + Haz preguntas en lenguaje natural sobre sprints, tareas, miembros, KPIs, deploys e incidentes. + + + + {SUGGESTIONS.map((s) => ( + send(s)} + /> + ))} + + + + {messages.length === 0 && ( + + No has hecho ninguna pregunta todavia. + + )} + {messages.map((m, i) => ( + + + {m.role === 'user' ? 'Tu' : 'IA'} + + + {m.content} + + {m.sources && m.sources.length > 0 && ( + + {m.sources.map((s, j) => ( + + ))} + + )} + + ))} + {loading && ( + + + pensando... + + )} + + + + setInput(e.target.value)} + onKeyDown={onKeyDown} + disabled={loading} + /> + + + + + setError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setError(null)}> + {error} + + + + ); +} + +export default AIInsightsPanel; diff --git a/MtdrSpring/backend/src/main/frontend/src/API.js b/MtdrSpring/backend/src/main/frontend/src/API.js index 655277bbf..e2101cfde 100644 --- a/MtdrSpring/backend/src/main/frontend/src/API.js +++ b/MtdrSpring/backend/src/main/frontend/src/API.js @@ -17,4 +17,5 @@ // Example: const API_LIST = 'https://di2eyonlz5s7kmuektcddaw5zq.apigateway..oci.customer-oci.com/todolist'; // const API_LIST = 'https://di2eyonlz5s7kmuektcddaw5zq.apigateway.eu-frankfurt-1.oci.customer-oci.com/todolist'; const API_LIST = '/todolist'; +export const API_AI = '/api/ai'; export default API_LIST; \ No newline at end of file diff --git a/MtdrSpring/backend/src/main/frontend/src/App.js b/MtdrSpring/backend/src/main/frontend/src/App.js index 21462dd91..7491e7357 100644 --- a/MtdrSpring/backend/src/main/frontend/src/App.js +++ b/MtdrSpring/backend/src/main/frontend/src/App.js @@ -12,6 +12,7 @@ */ import React, { useState, useEffect } from 'react'; import NewItem from './NewItem'; +import AIInsightsPanel from './AIInsightsPanel'; import API_LIST from './API'; import DeleteIcon from '@mui/icons-material/Delete'; import { Button, TableBody, CircularProgress } from '@mui/material'; @@ -186,6 +187,7 @@ function App() { return (

MY TODO LIST

+ { error &&

Error: {error.message}

diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/config/DeepSeekConfig.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/config/DeepSeekConfig.java index 976bd6120..46819881b 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/config/DeepSeekConfig.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/config/DeepSeekConfig.java @@ -1,28 +1,15 @@ package com.springboot.MyTodoList.config; -import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class DeepSeekConfig { - - @Value("${deepseek.api.key}") - private String apiKey; @Bean public CloseableHttpClient httpClient() { return HttpClients.createDefault(); } - - @Bean - public HttpPost deepSeekRequest(@Value("${deepseek.api.url}") String apiUrl) { - HttpPost request = new HttpPost(apiUrl); - request.addHeader("Content-Type", "application/json"); - request.addHeader("Authorization", "Bearer " + apiKey); - return request; - } } diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/config/OCIEmbeddingConfig.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/config/OCIEmbeddingConfig.java new file mode 100644 index 000000000..476bb6cae --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/config/OCIEmbeddingConfig.java @@ -0,0 +1,45 @@ +package com.springboot.MyTodoList.config; + +import com.oracle.bmc.Region; +import com.oracle.bmc.auth.BasicAuthenticationDetailsProvider; +import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider; +import com.oracle.bmc.auth.InstancePrincipalsAuthenticationDetailsProvider; +import com.oracle.bmc.generativeaiinference.GenerativeAiInferenceClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; + +@Configuration +public class OCIEmbeddingConfig { + + private static final Logger LOG = LoggerFactory.getLogger(OCIEmbeddingConfig.class); + + @Value("${oci.auth.mode:CONFIG_FILE}") + private String authMode; + + @Value("${oci.auth.profile:DEFAULT}") + private String authProfile; + + @Value("${oci.genai.region:us-chicago-1}") + private String regionId; + + @Bean + public GenerativeAiInferenceClient generativeAiInferenceClient() throws IOException { + BasicAuthenticationDetailsProvider provider; + if ("INSTANCE_PRINCIPAL".equalsIgnoreCase(authMode)) { + LOG.info("OCI auth: Instance Principals"); + provider = InstancePrincipalsAuthenticationDetailsProvider.builder().build(); + } else { + LOG.info("OCI auth: ConfigFile profile={}", authProfile); + provider = new ConfigFileAuthenticationDetailsProvider(authProfile); + } + GenerativeAiInferenceClient client = GenerativeAiInferenceClient.builder() + .region(Region.fromRegionId(regionId)) + .build(provider); + return client; + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/AIInsightsController.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/AIInsightsController.java new file mode 100644 index 000000000..86fae995b --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/controller/AIInsightsController.java @@ -0,0 +1,77 @@ +package com.springboot.MyTodoList.controller; + +import com.springboot.MyTodoList.dto.AIQueryRequest; +import com.springboot.MyTodoList.dto.AIQueryResponse; +import com.springboot.MyTodoList.service.AIInsightsService; +import com.springboot.MyTodoList.service.rag.DocumentIngestionService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.util.Map; + +@RestController +@RequestMapping("/api/ai") +public class AIInsightsController { + + private static final Logger LOG = LoggerFactory.getLogger(AIInsightsController.class); + + private final AIInsightsService insights; + private final DocumentIngestionService ingestion; + + @Autowired + public AIInsightsController(AIInsightsService insights, DocumentIngestionService ingestion) { + this.insights = insights; + this.ingestion = ingestion; + } + + @PostMapping("/ask") + public ResponseEntity ask(@RequestBody AIQueryRequest req) { + if (req == null || req.getQuestion() == null || req.getQuestion().trim().isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "question is required")); + } + try { + AIQueryResponse resp = insights.ask(req.getQuestion()); + return ResponseEntity.ok(resp); + } catch (IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage())); + } catch (IOException ex) { + LOG.error("AI ask failed: {}", ex.getMessage(), ex); + return ResponseEntity.status(502).body(Map.of("error", "upstream LLM error: " + ex.getMessage())); + } catch (RuntimeException ex) { + LOG.error("AI ask failed: {}", ex.getMessage(), ex); + return ResponseEntity.status(500).body(Map.of("error", ex.getMessage())); + } + } + + @PostMapping("/reindex") + public ResponseEntity reindexAll() { + try { + Map totals = ingestion.reindexAll(); + return ResponseEntity.ok(Map.of("ok", true, "indexed", totals)); + } catch (RuntimeException ex) { + LOG.error("Reindex failed: {}", ex.getMessage(), ex); + return ResponseEntity.status(500).body(Map.of("error", ex.getMessage())); + } + } + + @PostMapping("/reindex/{sourceType}") + public ResponseEntity reindexOne(@PathVariable String sourceType) { + try { + Map totals = ingestion.reindex(sourceType); + return ResponseEntity.ok(Map.of("ok", true, "indexed", totals)); + } catch (IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage())); + } catch (RuntimeException ex) { + LOG.error("Reindex failed: {}", ex.getMessage(), ex); + return ResponseEntity.status(500).body(Map.of("error", ex.getMessage())); + } + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/dto/AIQueryRequest.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/dto/AIQueryRequest.java new file mode 100644 index 000000000..ed3e30472 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/dto/AIQueryRequest.java @@ -0,0 +1,8 @@ +package com.springboot.MyTodoList.dto; + +public class AIQueryRequest { + private String question; + + public String getQuestion() { return question; } + public void setQuestion(String question) { this.question = question; } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/dto/AIQueryResponse.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/dto/AIQueryResponse.java new file mode 100644 index 000000000..68eeb22b0 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/dto/AIQueryResponse.java @@ -0,0 +1,39 @@ +package com.springboot.MyTodoList.dto; + +import java.util.List; + +public class AIQueryResponse { + + public static class Source { + private String sourceType; + private long sourceId; + private double score; + + public Source() {} + public Source(String sourceType, long sourceId, double score) { + this.sourceType = sourceType; + this.sourceId = sourceId; + this.score = score; + } + public String getSourceType() { return sourceType; } + public void setSourceType(String sourceType) { this.sourceType = sourceType; } + public long getSourceId() { return sourceId; } + public void setSourceId(long sourceId) { this.sourceId = sourceId; } + public double getScore() { return score; } + public void setScore(double score) { this.score = score; } + } + + private String answer; + private List sources; + + public AIQueryResponse() {} + public AIQueryResponse(String answer, List sources) { + this.answer = answer; + this.sources = sources; + } + + public String getAnswer() { return answer; } + public void setAnswer(String answer) { this.answer = answer; } + public List getSources() { return sources; } + public void setSources(List sources) { this.sources = sources; } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/BotInteractionRepository.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/BotInteractionRepository.java new file mode 100644 index 000000000..489609393 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/BotInteractionRepository.java @@ -0,0 +1,23 @@ +package com.springboot.MyTodoList.repository; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class BotInteractionRepository { + + private final JdbcTemplate jdbc; + + @Autowired + public BotInteractionRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public void insert(Long userId, String message, String response) { + jdbc.update( + "INSERT INTO BOT_INTERACTIONS (USER_ID, MESSAGE, RESPONSE, CREATED_AT) " + + "VALUES (?, ?, ?, SYSTIMESTAMP)", + userId, message, response); + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/VectorStoreRepository.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/VectorStoreRepository.java new file mode 100644 index 000000000..0ffb0ca35 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/repository/VectorStoreRepository.java @@ -0,0 +1,122 @@ +package com.springboot.MyTodoList.repository; + +import com.springboot.MyTodoList.service.rag.EmbeddingEntry; +import com.springboot.MyTodoList.service.rag.RetrievedDoc; +import com.springboot.MyTodoList.service.rag.VectorMath; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Clob; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +@Repository +public class VectorStoreRepository { + + private static final Logger LOG = LoggerFactory.getLogger(VectorStoreRepository.class); + + private final JdbcTemplate jdbc; + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + @Autowired + public VectorStoreRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @PostConstruct + void warmCache() { + try { + reloadCache(); + } catch (Exception e) { + LOG.warn("Could not warm vector cache at startup (table may not exist yet): {}", e.getMessage()); + } + } + + public synchronized void reloadCache() { + cache.clear(); + jdbc.query( + "SELECT SOURCE_TYPE, SOURCE_ID, CONTENT, EMBEDDING FROM PROJECT_DOC_EMBEDDINGS", + (rs) -> { + String type = rs.getString("SOURCE_TYPE"); + long id = rs.getLong("SOURCE_ID"); + String content = readClob(rs.getClob("CONTENT")); + byte[] blob = rs.getBytes("EMBEDDING"); + float[] vec = VectorMath.bytesToFloats(blob); + EmbeddingEntry e = new EmbeddingEntry(type, id, content, vec); + cache.put(e.key(), e); + }); + LOG.info("Vector cache loaded: {} entries", cache.size()); + } + + public void upsert(String sourceType, long sourceId, String content, float[] embedding) { + byte[] blob = VectorMath.floatsToBytes(embedding); + jdbc.update((java.sql.Connection con) -> { + PreparedStatement ps = con.prepareStatement( + "MERGE INTO PROJECT_DOC_EMBEDDINGS d " + + "USING (SELECT ? AS ST, ? AS SID FROM dual) s " + + "ON (d.SOURCE_TYPE = s.ST AND d.SOURCE_ID = s.SID) " + + "WHEN MATCHED THEN UPDATE " + + " SET CONTENT = ?, EMBEDDING = ?, EMBED_DIM = ?, UPDATED_AT = SYSTIMESTAMP " + + "WHEN NOT MATCHED THEN " + + " INSERT (SOURCE_TYPE, SOURCE_ID, CONTENT, EMBEDDING, EMBED_DIM) " + + " VALUES (?, ?, ?, ?, ?)"); + ps.setString(1, sourceType); + ps.setLong(2, sourceId); + ps.setString(3, content); + ps.setBytes(4, blob); + ps.setInt(5, embedding.length); + ps.setString(6, sourceType); + ps.setLong(7, sourceId); + ps.setString(8, content); + ps.setBytes(9, blob); + ps.setInt(10, embedding.length); + return ps; + }); + EmbeddingEntry entry = new EmbeddingEntry(sourceType, sourceId, content, embedding); + cache.put(entry.key(), entry); + } + + public void deleteBySource(String sourceType, long sourceId) { + jdbc.update( + "DELETE FROM PROJECT_DOC_EMBEDDINGS WHERE SOURCE_TYPE = ? AND SOURCE_ID = ?", + sourceType, sourceId); + cache.remove(sourceType + ":" + sourceId); + } + + public void deleteAllOfType(String sourceType) { + jdbc.update("DELETE FROM PROJECT_DOC_EMBEDDINGS WHERE SOURCE_TYPE = ?", sourceType); + cache.values().removeIf(e -> e.getSourceType().equals(sourceType)); + } + + public List searchTopK(float[] query, int k) { + if (cache.isEmpty()) { + return new ArrayList<>(); + } + double normQ = VectorMath.norm(query); + List scored = new ArrayList<>(cache.size()); + for (EmbeddingEntry e : cache.values()) { + double s = VectorMath.cosine(query, normQ, e.getEmbedding(), e.getNorm()); + scored.add(new RetrievedDoc(e.getSourceType(), e.getSourceId(), e.getContent(), s)); + } + scored.sort(Comparator.comparingDouble(RetrievedDoc::getScore).reversed()); + return scored.subList(0, Math.min(k, scored.size())); + } + + public int size() { + return cache.size(); + } + + private static String readClob(Clob clob) throws SQLException { + if (clob == null) return ""; + long len = clob.length(); + return clob.getSubString(1, (int) len); + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/AIInsightsService.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/AIInsightsService.java new file mode 100644 index 000000000..06f0b9e4b --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/AIInsightsService.java @@ -0,0 +1,90 @@ +package com.springboot.MyTodoList.service; + +import com.springboot.MyTodoList.dto.AIQueryResponse; +import com.springboot.MyTodoList.repository.BotInteractionRepository; +import com.springboot.MyTodoList.service.rag.RAGRetrievalService; +import com.springboot.MyTodoList.service.rag.RetrievedDoc; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Service +public class AIInsightsService { + + private static final Logger LOG = LoggerFactory.getLogger(AIInsightsService.class); + + private static final String DEFAULT_SYSTEM_PROMPT = + "Eres un asistente de gestión de proyectos. Responde en español usando " + + "EXCLUSIVAMENTE la información del contexto provisto. Si la información no está en " + + "el contexto, responde \"No tengo esa información en los datos del proyecto\". " + + "Sé conciso. Cuando uses información de un documento, cita el tipo y ID al final " + + "entre paréntesis, por ejemplo (PROJECT #12, TASK #234)."; + + private final RAGRetrievalService retrieval; + private final DeepSeekService deepSeek; + private final BotInteractionRepository chatLog; + private final int topK; + private final String systemPromptBase; + + @Autowired + public AIInsightsService(RAGRetrievalService retrieval, + DeepSeekService deepSeek, + BotInteractionRepository chatLog, + @Value("${ai.rag.top-k:5}") int topK, + @Value("${ai.rag.system-prompt-base:}") String systemPromptBase) { + this.retrieval = retrieval; + this.deepSeek = deepSeek; + this.chatLog = chatLog; + this.topK = topK; + this.systemPromptBase = (systemPromptBase == null || systemPromptBase.isEmpty()) + ? DEFAULT_SYSTEM_PROMPT : systemPromptBase; + } + + public AIQueryResponse ask(String question) throws IOException { + if (question == null || question.trim().isEmpty()) { + throw new IllegalArgumentException("question must not be empty"); + } + + List docs = retrieval.retrieve(question, topK); + String contextBlock = buildContextBlock(docs); + String systemPrompt = systemPromptBase + "\n\nContexto disponible:\n" + contextBlock; + + LOG.info("AI ask: question='{}' retrieved={} docs", truncate(question, 80), docs.size()); + String answer = deepSeek.chat(systemPrompt, question); + + try { + chatLog.insert(null, question, answer); + } catch (RuntimeException ex) { + LOG.warn("Could not persist BOT_INTERACTIONS row: {}", ex.getMessage()); + } + + List sources = new ArrayList<>(docs.size()); + for (RetrievedDoc d : docs) { + sources.add(new AIQueryResponse.Source(d.getSourceType(), d.getSourceId(), d.getScore())); + } + return new AIQueryResponse(answer, sources); + } + + private String buildContextBlock(List docs) { + if (docs.isEmpty()) return "(sin documentos relevantes en el índice)"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < docs.size(); i++) { + RetrievedDoc d = docs.get(i); + if (i > 0) sb.append("\n---\n"); + sb.append("[").append(d.getSourceType()).append(" #").append(d.getSourceId()).append("]\n"); + sb.append(d.getContent()); + } + return sb.toString(); + } + + private static String truncate(String s, int max) { + if (s == null) return ""; + return s.length() <= max ? s : s.substring(0, max) + "…"; + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/DeepSeekService.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/DeepSeekService.java index 0e6a3b7aa..7589a2bcd 100644 --- a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/DeepSeekService.java +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/DeepSeekService.java @@ -1,32 +1,112 @@ package com.springboot.MyTodoList.service; -import java.io.IOException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + @Service -public class DeepSeekService{ +public class DeepSeekService { + + private static final Logger LOG = LoggerFactory.getLogger(DeepSeekService.class); + private final CloseableHttpClient httpClient; - private final HttpPost httpPost; + private final String apiUrl; + private final String apiKey; + private final String model; + private final int timeoutMs; + private final ObjectMapper mapper = new ObjectMapper(); - public DeepSeekService(CloseableHttpClient httpClient, HttpPost httpPost) { + public DeepSeekService(CloseableHttpClient httpClient, + @Value("${deepseek.api.url}") String apiUrl, + @Value("${deepseek.api.key}") String apiKey, + @Value("${deepseek.api.model:deepseek-chat}") String model, + @Value("${deepseek.api.timeout-ms:30000}") int timeoutMs) { this.httpClient = httpClient; - this.httpPost = httpPost; + this.apiUrl = apiUrl; + this.apiKey = apiKey; + this.model = model; + this.timeoutMs = timeoutMs; + } + + public String generateText(String prompt) throws IOException { + Map userMsg = new LinkedHashMap<>(); + userMsg.put("role", "user"); + userMsg.put("content", prompt); + return chat(Collections.singletonList(userMsg)); } - public String generateText(String prompt) throws IOException, org.apache.hc.core5.http.ParseException { - String requestBody = String.format("{\"model\": \"deepseek-chat\",\"messages\": [{\"role\": \"user\", \"content\": \"%s\"}]}", prompt); + public String chat(String systemPrompt, String userPrompt) throws IOException { + List> msgs = new ArrayList<>(2); + if (systemPrompt != null && !systemPrompt.isEmpty()) { + Map s = new LinkedHashMap<>(); + s.put("role", "system"); + s.put("content", systemPrompt); + msgs.add(s); + } + Map u = new LinkedHashMap<>(); + u.put("role", "user"); + u.put("content", userPrompt); + msgs.add(u); + return chat(msgs); + } + + public String chat(List> messages) throws IOException { + Map body = new LinkedHashMap<>(); + body.put("model", model); + body.put("messages", messages); + + String json = mapper.writeValueAsString(body); - try { - httpPost.setEntity(new StringEntity(requestBody)); - CloseableHttpResponse response = httpClient.execute(httpPost); - return EntityUtils.toString(response.getEntity()); - } catch (IOException e) { - throw e; + HttpPost post = new HttpPost(apiUrl); + post.addHeader("Authorization", "Bearer " + apiKey); + post.setEntity(new StringEntity(json, ContentType.APPLICATION_JSON)); + post.setConfig(RequestConfig.custom() + .setResponseTimeout(Timeout.ofMilliseconds(timeoutMs)) + .setConnectionRequestTimeout(Timeout.ofMilliseconds(timeoutMs)) + .build()); + + try (CloseableHttpResponse resp = httpClient.execute(post)) { + int status = resp.getCode(); + String respBody = EntityUtils.toString(resp.getEntity()); + if (status < 200 || status >= 300) { + LOG.warn("DeepSeek HTTP {}: {}", status, truncate(respBody)); + throw new IOException("DeepSeek error " + status + ": " + truncate(respBody)); + } + JsonNode root = mapper.readTree(respBody); + JsonNode choices = root.path("choices"); + if (!choices.isArray() || choices.isEmpty()) { + throw new IOException("DeepSeek response missing choices: " + truncate(respBody)); + } + JsonNode content = choices.get(0).path("message").path("content"); + if (content.isMissingNode() || content.isNull()) { + throw new IOException("DeepSeek response missing content: " + truncate(respBody)); + } + return content.asText(); + } catch (org.apache.hc.core5.http.ParseException e) { + throw new IOException("Could not read DeepSeek response", e); } } + + private static String truncate(String s) { + if (s == null) return ""; + return s.length() <= 500 ? s : s.substring(0, 500) + "…"; + } } diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/embedding/OCIEmbeddingService.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/embedding/OCIEmbeddingService.java new file mode 100644 index 000000000..b17f79b73 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/embedding/OCIEmbeddingService.java @@ -0,0 +1,76 @@ +package com.springboot.MyTodoList.service.embedding; + +import com.oracle.bmc.generativeaiinference.GenerativeAiInferenceClient; +import com.oracle.bmc.generativeaiinference.model.EmbedTextDetails; +import com.oracle.bmc.generativeaiinference.model.OnDemandServingMode; +import com.oracle.bmc.generativeaiinference.requests.EmbedTextRequest; +import com.oracle.bmc.generativeaiinference.responses.EmbedTextResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Service +public class OCIEmbeddingService { + + private static final Logger LOG = LoggerFactory.getLogger(OCIEmbeddingService.class); + private static final int MAX_BATCH = 96; + + private final GenerativeAiInferenceClient client; + private final String compartmentId; + private final String modelId; + + public OCIEmbeddingService(GenerativeAiInferenceClient client, + @Value("${oci.genai.compartment-ocid}") String compartmentId, + @Value("${oci.genai.embedding-model:cohere.embed-multilingual-v3.0}") String modelId) { + this.client = client; + this.compartmentId = compartmentId; + this.modelId = modelId; + } + + public float[] embed(String text) { + List out = embedBatch(Collections.singletonList(text)); + return out.get(0); + } + + public List embedBatch(List texts) { + List result = new ArrayList<>(texts.size()); + for (int i = 0; i < texts.size(); i += MAX_BATCH) { + int end = Math.min(i + MAX_BATCH, texts.size()); + List chunk = texts.subList(i, end); + result.addAll(callEmbed(chunk)); + } + return result; + } + + private List callEmbed(List inputs) { + EmbedTextDetails details = EmbedTextDetails.builder() + .compartmentId(compartmentId) + .servingMode(OnDemandServingMode.builder().modelId(modelId).build()) + .inputs(inputs) + .truncate(EmbedTextDetails.Truncate.End) + .build(); + + EmbedTextRequest req = EmbedTextRequest.builder() + .embedTextDetails(details) + .build(); + + EmbedTextResponse resp = client.embedText(req); + List> raw = resp.getEmbedTextResult().getEmbeddings(); + + List out = new ArrayList<>(raw.size()); + for (List v : raw) { + float[] arr = new float[v.size()]; + for (int j = 0; j < v.size(); j++) arr[j] = v.get(j); + out.add(arr); + } + if (LOG.isDebugEnabled()) { + LOG.debug("Embedded {} inputs ({} dims)", out.size(), out.isEmpty() ? 0 : out.get(0).length); + } + return out; + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/DocumentIngestionService.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/DocumentIngestionService.java new file mode 100644 index 000000000..8c386ef9a --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/DocumentIngestionService.java @@ -0,0 +1,93 @@ +package com.springboot.MyTodoList.service.rag; + +import com.springboot.MyTodoList.repository.VectorStoreRepository; +import com.springboot.MyTodoList.service.embedding.OCIEmbeddingService; +import com.springboot.MyTodoList.service.rag.source.DocumentRow; +import com.springboot.MyTodoList.service.rag.source.SourceReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class DocumentIngestionService { + + private static final Logger LOG = LoggerFactory.getLogger(DocumentIngestionService.class); + + private final List readers; + private final OCIEmbeddingService embeddings; + private final VectorStoreRepository store; + + @Autowired + public DocumentIngestionService(List readers, + OCIEmbeddingService embeddings, + VectorStoreRepository store) { + this.readers = readers; + this.embeddings = embeddings; + this.store = store; + } + + public Map reindexAll() { + Map totals = new HashMap<>(); + for (SourceReader r : readers) { + int n = reindexOne(r); + totals.put(r.sourceType(), n); + } + LOG.info("Reindex completed: {}", totals); + return totals; + } + + public Map reindex(String sourceType) { + SourceReader reader = readers.stream() + .filter(r -> r.sourceType().equalsIgnoreCase(sourceType)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown sourceType: " + sourceType)); + Map totals = new HashMap<>(); + totals.put(reader.sourceType(), reindexOne(reader)); + return totals; + } + + private int reindexOne(SourceReader reader) { + long t0 = System.currentTimeMillis(); + List rows = reader.readAll(); + if (rows.isEmpty()) { + LOG.info("[{}] no rows to index", reader.sourceType()); + store.deleteAllOfType(reader.sourceType()); + return 0; + } + + List texts = rows.stream() + .map(DocumentRow::getContent) + .collect(Collectors.toList()); + + List vectors = embeddings.embedBatch(texts); + if (vectors.size() != rows.size()) { + throw new IllegalStateException( + "Embedding count mismatch: rows=" + rows.size() + " vectors=" + vectors.size()); + } + + // Replace-set semantics: delete all of this type, then upsert current rows. + // For tiny datasets this is fine; for larger ones, use diffing later. + store.deleteAllOfType(reader.sourceType()); + List failed = new ArrayList<>(); + for (int i = 0; i < rows.size(); i++) { + DocumentRow row = rows.get(i); + try { + store.upsert(row.getSourceType(), row.getSourceId(), row.getContent(), vectors.get(i)); + } catch (RuntimeException ex) { + LOG.warn("Failed to upsert {}#{}: {}", row.getSourceType(), row.getSourceId(), ex.getMessage()); + failed.add(new long[]{row.getSourceId()}); + } + } + long elapsed = System.currentTimeMillis() - t0; + LOG.info("[{}] indexed {} rows ({} failed) in {} ms", + reader.sourceType(), rows.size() - failed.size(), failed.size(), elapsed); + return rows.size() - failed.size(); + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/EmbeddingEntry.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/EmbeddingEntry.java new file mode 100644 index 000000000..f6ccc7e98 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/EmbeddingEntry.java @@ -0,0 +1,25 @@ +package com.springboot.MyTodoList.service.rag; + +public final class EmbeddingEntry { + private final String sourceType; + private final long sourceId; + private final String content; + private final float[] embedding; + private final double norm; + + public EmbeddingEntry(String sourceType, long sourceId, String content, float[] embedding) { + this.sourceType = sourceType; + this.sourceId = sourceId; + this.content = content; + this.embedding = embedding; + this.norm = VectorMath.norm(embedding); + } + + public String getSourceType() { return sourceType; } + public long getSourceId() { return sourceId; } + public String getContent() { return content; } + public float[] getEmbedding() { return embedding; } + public double getNorm() { return norm; } + + public String key() { return sourceType + ":" + sourceId; } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/RAGRetrievalService.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/RAGRetrievalService.java new file mode 100644 index 000000000..91ee198b5 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/RAGRetrievalService.java @@ -0,0 +1,26 @@ +package com.springboot.MyTodoList.service.rag; + +import com.springboot.MyTodoList.repository.VectorStoreRepository; +import com.springboot.MyTodoList.service.embedding.OCIEmbeddingService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class RAGRetrievalService { + + private final OCIEmbeddingService embeddings; + private final VectorStoreRepository store; + + @Autowired + public RAGRetrievalService(OCIEmbeddingService embeddings, VectorStoreRepository store) { + this.embeddings = embeddings; + this.store = store; + } + + public List retrieve(String question, int k) { + float[] qvec = embeddings.embed(question); + return store.searchTopK(qvec, k); + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/RetrievedDoc.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/RetrievedDoc.java new file mode 100644 index 000000000..b5d89b5a8 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/RetrievedDoc.java @@ -0,0 +1,20 @@ +package com.springboot.MyTodoList.service.rag; + +public final class RetrievedDoc { + private final String sourceType; + private final long sourceId; + private final String content; + private final double score; + + public RetrievedDoc(String sourceType, long sourceId, String content, double score) { + this.sourceType = sourceType; + this.sourceId = sourceId; + this.content = content; + this.score = score; + } + + public String getSourceType() { return sourceType; } + public long getSourceId() { return sourceId; } + public String getContent() { return content; } + public double getScore() { return score; } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/VectorMath.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/VectorMath.java new file mode 100644 index 000000000..13ba6c96c --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/VectorMath.java @@ -0,0 +1,43 @@ +package com.springboot.MyTodoList.service.rag; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public final class VectorMath { + + private VectorMath() {} + + public static byte[] floatsToBytes(float[] vec) { + ByteBuffer bb = ByteBuffer.allocate(vec.length * 4).order(ByteOrder.LITTLE_ENDIAN); + bb.asFloatBuffer().put(vec); + return bb.array(); + } + + public static float[] bytesToFloats(byte[] bytes) { + if (bytes.length % 4 != 0) { + throw new IllegalArgumentException("BLOB length not divisible by 4: " + bytes.length); + } + ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + float[] out = new float[bytes.length / 4]; + bb.asFloatBuffer().get(out); + return out; + } + + public static double norm(float[] v) { + double s = 0.0; + for (float x : v) s += (double) x * x; + return Math.sqrt(s); + } + + public static double cosine(float[] a, double normA, float[] b, double normB) { + if (a.length != b.length) { + throw new IllegalArgumentException("Dim mismatch: " + a.length + " vs " + b.length); + } + if (normA == 0.0 || normB == 0.0) return 0.0; + double dot = 0.0; + for (int i = 0; i < a.length; i++) { + dot += (double) a[i] * b[i]; + } + return dot / (normA * normB); + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/DeploymentSource.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/DeploymentSource.java new file mode 100644 index 000000000..0c51e7b97 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/DeploymentSource.java @@ -0,0 +1,56 @@ +package com.springboot.MyTodoList.service.rag.source; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +public class DeploymentSource implements SourceReader { + + private final JdbcTemplate jdbc; + + @Autowired + public DeploymentSource(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public String sourceType() { return "DEPLOYMENT"; } + + @Override + public List readAll() { + List> rows = jdbc.queryForList( + "SELECT d.DEPLOYMENT_ID, d.VERSION, d.ENVIRONMENT, d.STATUS, " + + " d.RECOVERY_TIME_MIN, d.DEPLOYED_AT, " + + " p.NAME AS PROJECT_NAME " + + "FROM DEPLOYMENTS d " + + "LEFT JOIN PROJECTS p ON p.PROJECT_ID = d.PROJECT_ID"); + + List docs = new ArrayList<>(rows.size()); + for (Map r : rows) { + long id = ((Number) r.get("DEPLOYMENT_ID")).longValue(); + docs.add(new DocumentRow(sourceType(), id, render(r))); + } + return docs; + } + + private String render(Map r) { + StringBuilder sb = new StringBuilder(); + sb.append("Deployment #").append(r.get("DEPLOYMENT_ID")); + if (r.get("VERSION") != null) sb.append(" — versión ").append(r.get("VERSION")); + sb.append("\n"); + if (r.get("PROJECT_NAME") != null) sb.append("Proyecto: ").append(r.get("PROJECT_NAME")).append(" | "); + sb.append("Ambiente: ").append(r.get("ENVIRONMENT")) + .append(" | Status: ").append(r.get("STATUS")); + if (r.get("DEPLOYED_AT") != null) sb.append(" | Fecha: ").append(r.get("DEPLOYED_AT")); + sb.append("\n"); + if (r.get("RECOVERY_TIME_MIN") != null) { + sb.append("Tiempo de recuperación: ").append(r.get("RECOVERY_TIME_MIN")).append(" min"); + } + return sb.toString(); + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/DocumentRow.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/DocumentRow.java new file mode 100644 index 000000000..f60011e7d --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/DocumentRow.java @@ -0,0 +1,17 @@ +package com.springboot.MyTodoList.service.rag.source; + +public final class DocumentRow { + private final String sourceType; + private final long sourceId; + private final String content; + + public DocumentRow(String sourceType, long sourceId, String content) { + this.sourceType = sourceType; + this.sourceId = sourceId; + this.content = content; + } + + public String getSourceType() { return sourceType; } + public long getSourceId() { return sourceId; } + public String getContent() { return content; } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/IncidentSource.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/IncidentSource.java new file mode 100644 index 000000000..abcf1792b --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/IncidentSource.java @@ -0,0 +1,59 @@ +package com.springboot.MyTodoList.service.rag.source; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +public class IncidentSource implements SourceReader { + + private final JdbcTemplate jdbc; + + @Autowired + public IncidentSource(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public String sourceType() { return "INCIDENT"; } + + @Override + public List readAll() { + List> rows = jdbc.queryForList( + "SELECT i.INCIDENT_ID, i.TYPE, i.SEVERITY, i.OCCURRED_AT, i.RESOLVED_AT, " + + " i.DESCRIPTION, p.NAME AS PROJECT_NAME " + + "FROM INCIDENTS i " + + "LEFT JOIN PROJECTS p ON p.PROJECT_ID = i.PROJECT_ID " + + "WHERE NVL(i.IS_DELETED, 'N') = 'N'"); + + List docs = new ArrayList<>(rows.size()); + for (Map r : rows) { + long id = ((Number) r.get("INCIDENT_ID")).longValue(); + docs.add(new DocumentRow(sourceType(), id, render(r))); + } + return docs; + } + + private String render(Map r) { + StringBuilder sb = new StringBuilder(); + sb.append("Incidente #").append(r.get("INCIDENT_ID")); + if (r.get("TYPE") != null) sb.append(" — ").append(r.get("TYPE")); + sb.append("\n"); + if (r.get("PROJECT_NAME") != null) sb.append("Proyecto: ").append(r.get("PROJECT_NAME")).append(" | "); + sb.append("Severidad: ").append(r.get("SEVERITY")); + if (r.get("OCCURRED_AT") != null) sb.append(" | Ocurrió: ").append(r.get("OCCURRED_AT")); + if (r.get("RESOLVED_AT") != null) sb.append(" | Resuelto: ").append(r.get("RESOLVED_AT")); + else sb.append(" | Resuelto: NO"); + sb.append("\n"); + Object desc = r.get("DESCRIPTION"); + if (desc != null) { + String d = desc.toString().trim(); + if (!d.isEmpty()) sb.append("Descripción: ").append(d); + } + return sb.toString(); + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/KpiValueSource.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/KpiValueSource.java new file mode 100644 index 000000000..c475345c7 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/KpiValueSource.java @@ -0,0 +1,73 @@ +package com.springboot.MyTodoList.service.rag.source; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +public class KpiValueSource implements SourceReader { + + private final JdbcTemplate jdbc; + + @Autowired + public KpiValueSource(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public String sourceType() { return "KPI_VALUE"; } + + @Override + public List readAll() { + List> rows = jdbc.queryForList( + "SELECT KPI_VALUE_ID, KPI_TYPE_NAME, KPI_CATEGORY, KPI_UNIT, " + + " SCOPE_TYPE, USER_NAME, PROJECT_NAME, SPRINT_NAME, " + + " VALUE, RECORDED_AT " + + "FROM ( " + + " SELECT kv.KPI_VALUE_ID, kt.NAME AS KPI_TYPE_NAME, " + + " kt.CATEGORY AS KPI_CATEGORY, kt.UNIT AS KPI_UNIT, " + + " kv.SCOPE_TYPE, " + + " u.FULL_NAME AS USER_NAME, " + + " p.NAME AS PROJECT_NAME, " + + " s.NAME AS SPRINT_NAME, " + + " kv.VALUE, kv.RECORDED_AT, " + + " ROW_NUMBER() OVER ( " + + " PARTITION BY kv.KPI_TYPE_ID, kv.SCOPE_TYPE, kv.USER_ID, kv.PROJECT_ID, kv.SPRINT_ID " + + " ORDER BY kv.RECORDED_AT DESC NULLS LAST " + + " ) AS RN " + + " FROM KPI_VALUES kv " + + " JOIN KPI_TYPES kt ON kt.KPI_TYPE_ID = kv.KPI_TYPE_ID " + + " LEFT JOIN USERS u ON u.USER_ID = kv.USER_ID " + + " LEFT JOIN PROJECTS p ON p.PROJECT_ID = kv.PROJECT_ID " + + " LEFT JOIN SPRINTS s ON s.SPRINT_ID = kv.SPRINT_ID " + + ") " + + "WHERE RN = 1"); + + List docs = new ArrayList<>(rows.size()); + for (Map r : rows) { + long id = ((Number) r.get("KPI_VALUE_ID")).longValue(); + docs.add(new DocumentRow(sourceType(), id, render(r))); + } + return docs; + } + + private String render(Map r) { + StringBuilder sb = new StringBuilder(); + sb.append("KPI \"").append(r.get("KPI_TYPE_NAME")).append("\""); + if (r.get("KPI_CATEGORY") != null) sb.append(" (categoría: ").append(r.get("KPI_CATEGORY")).append(")"); + sb.append("\n"); + sb.append("Valor: ").append(r.get("VALUE")); + if (r.get("KPI_UNIT") != null) sb.append(" ").append(r.get("KPI_UNIT")); + if (r.get("RECORDED_AT") != null) sb.append(" (registrado ").append(r.get("RECORDED_AT")).append(")"); + sb.append("\n"); + sb.append("Scope: ").append(r.get("SCOPE_TYPE")); + if (r.get("USER_NAME") != null) sb.append(" | Usuario: ").append(r.get("USER_NAME")); + if (r.get("PROJECT_NAME") != null) sb.append(" | Proyecto: ").append(r.get("PROJECT_NAME")); + if (r.get("SPRINT_NAME") != null) sb.append(" | Sprint: ").append(r.get("SPRINT_NAME")); + return sb.toString(); + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/ProjectSource.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/ProjectSource.java new file mode 100644 index 000000000..97cd35455 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/ProjectSource.java @@ -0,0 +1,78 @@ +package com.springboot.MyTodoList.service.rag.source; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +public class ProjectSource implements SourceReader { + + private final JdbcTemplate jdbc; + + @Autowired + public ProjectSource(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public String sourceType() { return "PROJECT"; } + + @Override + public List readAll() { + List> rows = jdbc.queryForList( + "SELECT p.PROJECT_ID, p.NAME, p.STATUS, p.TOTAL_HOURS, p.DESCRIPTION, " + + " u.FULL_NAME AS MANAGER_NAME " + + "FROM PROJECTS p " + + "LEFT JOIN USERS u ON u.USER_ID = p.MANAGER_ID " + + "WHERE NVL(p.IS_DELETED, 'N') = 'N'"); + + List docs = new ArrayList<>(rows.size()); + for (Map r : rows) { + long pid = ((Number) r.get("PROJECT_ID")).longValue(); + String members = loadMembers(pid); + String content = render(r, members); + docs.add(new DocumentRow(sourceType(), pid, content)); + } + return docs; + } + + private String loadMembers(long projectId) { + List> rows = jdbc.queryForList( + "SELECT u.FULL_NAME, pm.ROLE_IN_PROJECT " + + "FROM PROJECT_MEMBERS pm " + + "JOIN USERS u ON u.USER_ID = pm.USER_ID " + + "WHERE pm.PROJECT_ID = ? AND NVL(pm.IS_DELETED, 'N') = 'N'", + projectId); + if (rows.isEmpty()) return "(sin miembros)"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < rows.size(); i++) { + if (i > 0) sb.append(", "); + Map m = rows.get(i); + sb.append(m.get("FULL_NAME")); + Object role = m.get("ROLE_IN_PROJECT"); + if (role != null) sb.append(" (").append(role).append(")"); + } + return sb.toString(); + } + + private String render(Map r, String members) { + StringBuilder sb = new StringBuilder(); + sb.append("Proyecto #").append(r.get("PROJECT_ID")) + .append(" — \"").append(r.get("NAME")).append("\"\n"); + sb.append("Status: ").append(r.get("STATUS")); + if (r.get("MANAGER_NAME") != null) sb.append(" | Manager: ").append(r.get("MANAGER_NAME")); + if (r.get("TOTAL_HOURS") != null) sb.append(" | Horas estimadas: ").append(r.get("TOTAL_HOURS")); + sb.append("\n"); + Object desc = r.get("DESCRIPTION"); + if (desc != null) { + String d = desc.toString().trim(); + if (!d.isEmpty()) sb.append("Descripción: ").append(d).append("\n"); + } + sb.append("Miembros: ").append(members); + return sb.toString(); + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/SourceReader.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/SourceReader.java new file mode 100644 index 000000000..46b71d48d --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/SourceReader.java @@ -0,0 +1,8 @@ +package com.springboot.MyTodoList.service.rag.source; + +import java.util.List; + +public interface SourceReader { + String sourceType(); + List readAll(); +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/SprintSource.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/SprintSource.java new file mode 100644 index 000000000..db08257fe --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/SprintSource.java @@ -0,0 +1,106 @@ +package com.springboot.MyTodoList.service.rag.source; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class SprintSource implements SourceReader { + + private final JdbcTemplate jdbc; + + @Autowired + public SprintSource(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public String sourceType() { return "SPRINT"; } + + @Override + public List readAll() { + List> rows = jdbc.queryForList( + "SELECT s.SPRINT_ID, s.NAME, s.START_DATE, s.END_DATE, s.STATUS, s.TOTAL_HOURS " + + "FROM SPRINTS s " + + "WHERE NVL(s.IS_DELETED, 'N') = 'N'"); + + Map projectsBySprint = loadProjectNames(); + Map> taskCountsBySprint = loadTaskCounts(); + + List docs = new ArrayList<>(rows.size()); + for (Map r : rows) { + long sid = ((Number) r.get("SPRINT_ID")).longValue(); + String projects = projectsBySprint.getOrDefault(sid, "(sin proyectos)"); + Map counts = taskCountsBySprint.getOrDefault(sid, new HashMap<>()); + docs.add(new DocumentRow(sourceType(), sid, render(r, projects, counts))); + } + return docs; + } + + private Map loadProjectNames() { + Map tmp = new HashMap<>(); + jdbc.query( + "SELECT ps.SPRINT_ID, p.NAME " + + "FROM PROJECT_SPRINTS ps " + + "JOIN PROJECTS p ON p.PROJECT_ID = ps.PROJECT_ID " + + "WHERE NVL(p.IS_DELETED, 'N') = 'N'", + (rs) -> { + long sid = rs.getLong("SPRINT_ID"); + String name = rs.getString("NAME"); + tmp.computeIfAbsent(sid, k -> new StringBuilder()) + .append(tmp.get(sid).length() == 0 ? "" : ", ") + .append(name); + }); + Map out = new HashMap<>(); + for (Map.Entry e : tmp.entrySet()) { + out.put(e.getKey(), e.getValue().toString()); + } + return out; + } + + private Map> loadTaskCounts() { + Map> out = new HashMap<>(); + jdbc.query( + "SELECT t.SPRINT_ID, t.STATUS, COUNT(*) AS C " + + "FROM TASKS t " + + "WHERE t.SPRINT_ID IS NOT NULL AND NVL(t.IS_DELETED, 'N') = 'N' " + + "GROUP BY t.SPRINT_ID, t.STATUS", + (rs) -> { + long sid = rs.getLong("SPRINT_ID"); + String status = rs.getString("STATUS"); + long c = rs.getLong("C"); + out.computeIfAbsent(sid, k -> new HashMap<>()).put(status, c); + }); + return out; + } + + private String render(Map r, String projects, Map counts) { + StringBuilder sb = new StringBuilder(); + sb.append("Sprint #").append(r.get("SPRINT_ID")) + .append(" — \"").append(r.get("NAME")).append("\"\n"); + sb.append("Periodo: ").append(r.get("START_DATE")).append(" a ").append(r.get("END_DATE")) + .append(" | Status: ").append(r.get("STATUS")); + if (r.get("TOTAL_HOURS") != null) sb.append(" | Horas: ").append(r.get("TOTAL_HOURS")); + sb.append("\n"); + sb.append("Proyectos: ").append(projects).append("\n"); + if (counts.isEmpty()) { + sb.append("Tareas: 0"); + } else { + long total = counts.values().stream().mapToLong(Long::longValue).sum(); + sb.append("Tareas: ").append(total).append(" ("); + boolean first = true; + for (Map.Entry e : counts.entrySet()) { + if (!first) sb.append(", "); + sb.append(e.getKey()).append(": ").append(e.getValue()); + first = false; + } + sb.append(")"); + } + return sb.toString(); + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/TaskSource.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/TaskSource.java new file mode 100644 index 000000000..f0d0540f3 --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/TaskSource.java @@ -0,0 +1,71 @@ +package com.springboot.MyTodoList.service.rag.source; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +public class TaskSource implements SourceReader { + + private final JdbcTemplate jdbc; + + @Autowired + public TaskSource(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public String sourceType() { return "TASK"; } + + @Override + public List readAll() { + List> rows = jdbc.queryForList( + "SELECT t.TASK_ID, t.TITLE, t.DESCRIPTION, t.STATUS, t.TASK_STAGE, t.PRIORITY, " + + " t.TYPE, t.ESTIMATED_HOURS, t.ACTUAL_HOURS, t.DUE_DATE, " + + " p.NAME AS PROJECT_NAME, s.NAME AS SPRINT_NAME, " + + " ua.FULL_NAME AS ASSIGNEE_NAME, uc.FULL_NAME AS CREATOR_NAME " + + "FROM TASKS t " + + "LEFT JOIN PROJECTS p ON p.PROJECT_ID = t.PROJECT_ID " + + "LEFT JOIN SPRINTS s ON s.SPRINT_ID = t.SPRINT_ID " + + "LEFT JOIN USERS ua ON ua.USER_ID = t.ASSIGNED_TO " + + "LEFT JOIN USERS uc ON uc.USER_ID = t.CREATED_BY " + + "WHERE NVL(t.IS_DELETED, 'N') = 'N'"); + + List docs = new ArrayList<>(rows.size()); + for (Map r : rows) { + long id = ((Number) r.get("TASK_ID")).longValue(); + docs.add(new DocumentRow(sourceType(), id, render(r))); + } + return docs; + } + + private String render(Map r) { + StringBuilder sb = new StringBuilder(); + sb.append("Task #").append(r.get("TASK_ID")) + .append(" — \"").append(r.get("TITLE")).append("\"\n"); + if (r.get("PROJECT_NAME") != null) sb.append("Proyecto: ").append(r.get("PROJECT_NAME")); + if (r.get("SPRINT_NAME") != null) sb.append(" | Sprint: ").append(r.get("SPRINT_NAME")); + sb.append(" | Stage: ").append(r.get("TASK_STAGE")).append("\n"); + sb.append("Status: ").append(r.get("STATUS")) + .append(" | Priority: ").append(r.get("PRIORITY")); + if (r.get("TYPE") != null) sb.append(" | Tipo: ").append(r.get("TYPE")); + sb.append("\n"); + if (r.get("ASSIGNEE_NAME") != null) sb.append("Asignado a: ").append(r.get("ASSIGNEE_NAME")); + if (r.get("CREATOR_NAME") != null) sb.append(" | Creado por: ").append(r.get("CREATOR_NAME")); + sb.append("\n"); + if (r.get("ESTIMATED_HOURS") != null) sb.append("Estimadas: ").append(r.get("ESTIMATED_HOURS")).append("h"); + if (r.get("ACTUAL_HOURS") != null) sb.append(" | Reales: ").append(r.get("ACTUAL_HOURS")).append("h"); + if (r.get("DUE_DATE") != null) sb.append(" | Due: ").append(r.get("DUE_DATE")); + sb.append("\n"); + Object desc = r.get("DESCRIPTION"); + if (desc != null) { + String d = desc.toString().trim(); + if (!d.isEmpty()) sb.append("Descripción: ").append(d); + } + return sb.toString(); + } +} diff --git a/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/UserSource.java b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/UserSource.java new file mode 100644 index 000000000..ad7f160ee --- /dev/null +++ b/MtdrSpring/backend/src/main/java/com/springboot/MyTodoList/service/rag/source/UserSource.java @@ -0,0 +1,59 @@ +package com.springboot.MyTodoList.service.rag.source; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +public class UserSource implements SourceReader { + + private final JdbcTemplate jdbc; + + @Autowired + public UserSource(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public String sourceType() { return "USER"; } + + @Override + public List readAll() { + List> rows = jdbc.queryForList( + "SELECT u.USER_ID, u.FULL_NAME, u.EMAIL, u.STATUS, " + + " r.ROLE_NAME, t.NAME AS TEAM_NAME, " + + " (SELECT COUNT(*) FROM TASKS tk " + + " WHERE tk.ASSIGNED_TO = u.USER_ID " + + " AND NVL(tk.IS_DELETED,'N')='N' " + + " AND tk.STATUS IN ('PENDING','IN_PROGRESS','BLOCKED')) AS OPEN_TASKS " + + "FROM USERS u " + + "LEFT JOIN ROLES r ON r.ROLE_ID = u.ROLE_ID " + + "LEFT JOIN TEAMS t ON t.TEAM_ID = u.TEAM_ID " + + "WHERE NVL(u.IS_DELETED, 'N') = 'N'"); + + List docs = new ArrayList<>(rows.size()); + for (Map r : rows) { + long id = ((Number) r.get("USER_ID")).longValue(); + docs.add(new DocumentRow(sourceType(), id, render(r))); + } + return docs; + } + + private String render(Map r) { + StringBuilder sb = new StringBuilder(); + sb.append("Usuario #").append(r.get("USER_ID")) + .append(" — ").append(r.get("FULL_NAME")).append("\n"); + sb.append("Email: ").append(r.get("EMAIL")) + .append(" | Status: ").append(r.get("STATUS")); + if (r.get("ROLE_NAME") != null) sb.append(" | Rol: ").append(r.get("ROLE_NAME")); + if (r.get("TEAM_NAME") != null) sb.append(" | Equipo: ").append(r.get("TEAM_NAME")); + sb.append("\n"); + Object open = r.get("OPEN_TASKS"); + sb.append("Tareas abiertas asignadas: ").append(open != null ? open : 0); + return sb.toString(); + } +} diff --git a/MtdrSpring/backend/src/main/resources/application.properties b/MtdrSpring/backend/src/main/resources/application.properties index 3bf494812..276838910 100644 --- a/MtdrSpring/backend/src/main/resources/application.properties +++ b/MtdrSpring/backend/src/main/resources/application.properties @@ -27,6 +27,19 @@ logging.level.org.hibernate.type=TRACE deepseek.api.key=sk-test deepseek.api.url=https://api.deepseek.com/v1/chat/completions +deepseek.api.model=deepseek-chat +deepseek.api.timeout-ms=30000 + +# OCI Generative AI (embeddings) -- override via env vars in deployment +oci.auth.mode=CONFIG_FILE +oci.auth.profile=DEFAULT +oci.genai.region=us-chicago-1 +oci.genai.compartment-ocid= +oci.genai.embedding-model=cohere.embed-multilingual-v3.0 + +# RAG settings +ai.rag.top-k=5 +ai.rag.system-prompt-base= #spring.security.user.name=psong #spring.security.user.password=WELcome__12345 diff --git a/MtdrSpring/backend/src/main/resources/sql/01_create_project_doc_embeddings.sql b/MtdrSpring/backend/src/main/resources/sql/01_create_project_doc_embeddings.sql new file mode 100644 index 000000000..76807de3a --- /dev/null +++ b/MtdrSpring/backend/src/main/resources/sql/01_create_project_doc_embeddings.sql @@ -0,0 +1,18 @@ +-- ============================================================ +-- RAG embeddings store (Oracle 19c-compatible) +-- Run manually against the ADB before first use of /api/ai/* +-- ============================================================ +CREATE TABLE PROJECT_DOC_EMBEDDINGS ( + ID NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + SOURCE_TYPE VARCHAR2(20) NOT NULL, + SOURCE_ID NUMBER NOT NULL, + CONTENT CLOB NOT NULL, + EMBEDDING BLOB NOT NULL, + EMBED_DIM NUMBER DEFAULT 1024 NOT NULL, + UPDATED_AT TIMESTAMP DEFAULT SYSTIMESTAMP, + CONSTRAINT UK_DOC_EMB UNIQUE (SOURCE_TYPE, SOURCE_ID), + CONSTRAINT CHK_DOC_SRC CHECK (SOURCE_TYPE IN + ('PROJECT','SPRINT','TASK','USER','KPI_VALUE','DEPLOYMENT','INCIDENT')) +); + +CREATE INDEX IDX_PROJ_DOC_TYPE ON PROJECT_DOC_EMBEDDINGS(SOURCE_TYPE); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..879c67eb1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "oci_devops_project", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}