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