From 9b74682261be79de23be7d6c7da195fd2798fdfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=BCneyt=20=C5=9Eahin?= Date: Mon, 16 Feb 2026 09:50:14 +0300 Subject: [PATCH] . --- .../20260213120000_document_content.sql | 9 ++ backend/src/app.ts | 2 + backend/src/routes/chat.ts | 70 ++++++++ backend/src/routes/documents.ts | 54 +++++++ backend/src/services/chat.service.ts | 152 ++++++++++++++++++ backend/src/services/document_rag.service.ts | 18 ++- backend/src/services/llm_backend.service.ts | 20 +++ .../presentation/document_chat_screen.dart | 44 ++++- lib/shared/data/api_chat_repository.dart | 62 ++++++- lib/shared/data/api_document_repository.dart | 40 +++++ lib/shared/data/providers.dart | 32 +++- lib/shared/services/api_service.dart | 62 ++----- 12 files changed, 499 insertions(+), 66 deletions(-) create mode 100644 backend/db/migrations/20260213120000_document_content.sql create mode 100644 backend/src/routes/chat.ts create mode 100644 backend/src/services/chat.service.ts diff --git a/backend/db/migrations/20260213120000_document_content.sql b/backend/db/migrations/20260213120000_document_content.sql new file mode 100644 index 0000000..86dc435 --- /dev/null +++ b/backend/db/migrations/20260213120000_document_content.sql @@ -0,0 +1,9 @@ +-- migrate:up + +ALTER TABLE documents +ADD COLUMN content_text TEXT; + +-- migrate:down + +ALTER TABLE documents +DROP COLUMN content_text; diff --git a/backend/src/app.ts b/backend/src/app.ts index 9d1a6cb..60a8c66 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,6 +2,7 @@ import express, { Express, NextFunction, Request, Response } from 'express'; import { AppError } from './common/errors'; import authRoutes from './routes/auth'; +import chatRoutes from './routes/chat'; import debugRoutes from './routes/debug'; import documentRoutes from './routes/documents'; import goalRoutes from './routes/goals'; @@ -24,6 +25,7 @@ app.use((req: Request, res: Response, next: NextFunction) => { // Routes app.use('/api/v1', healthRoutes); app.use('/api/v1', authRoutes); +app.use('/api/v1', chatRoutes); app.use('/api/v1/goals', goalRoutes); app.use('/api/v1/study-sessions', studySessionRoutes); app.use('/api/v1/documents', documentRoutes); diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts new file mode 100644 index 0000000..746c60d --- /dev/null +++ b/backend/src/routes/chat.ts @@ -0,0 +1,70 @@ +import { NextFunction, Response, Router } from 'express'; +import { BadRequestError } from '../common/errors'; +import { authMiddleware, AuthRequest } from '../middleware/auth.middleware'; +import * as chatService from '../services/chat.service'; +import { askCoach } from '../services/llm_backend.service'; + +const router = Router(); + +router.use(authMiddleware); + +// POST /chat - general coach chat +router.post('/chat', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const { message, threadId } = req.body ?? {}; + + if (!message || typeof message !== 'string') { + throw new BadRequestError('message alanı zorunludur'); + } + + const thread = threadId + ? await chatService.getThreadById(userId, threadId) + : await chatService.getOrCreateThread(userId, null); + + if (!thread) { + throw new BadRequestError('Geçersiz threadId'); + } + + await chatService.addMessage({ + threadId: thread.id, + text: message, + isUser: true, + }); + + const answer = await askCoach(message); + + await chatService.addMessage({ + threadId: thread.id, + text: answer, + isUser: false, + }); + + res.json({ answer, threadId: thread.id }); + } catch (e) { + next(e); + } +}); + +// GET /chat/history - general coach chat history +router.get('/chat/history', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const threadId = typeof req.query.threadId === 'string' ? req.query.threadId : null; + + const thread = threadId + ? await chatService.getThreadById(userId, threadId) + : await chatService.getOrCreateThread(userId, null); + + if (!thread) { + return res.json({ threadId: null, messages: [] }); + } + + const messages = await chatService.getThreadMessages(thread.id); + res.json({ threadId: thread.id, messages }); + } catch (e) { + next(e); + } +}); + +export default router; diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index 673dcbf..91fd29d 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -4,6 +4,7 @@ import multer from 'multer'; import path from 'path'; import { BadRequestError, ConflictError } from '../common/errors'; import { authMiddleware, AuthRequest } from '../middleware/auth.middleware'; +import * as chatService from '../services/chat.service'; import * as documentService from '../services/document.service'; import * as documentRagService from '../services/document_rag.service'; @@ -108,6 +109,17 @@ router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => { } }); +// GET /documents/:id - Document detail +router.get('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const document = await documentService.getDocument(userId, req.params.id); + res.json({ document }); + } catch (e) { + next(e); + } +}); + // POST /documents/:id/chat - Ask document router.post('/:id/chat', async (req: AuthRequest, res: Response, next: NextFunction) => { try { @@ -122,21 +134,63 @@ router.post('/:id/chat', async (req: AuthRequest, res: Response, next: NextFunct throw new BadRequestError('message alanı zorunludur'); } + const thread = await chatService.getOrCreateThread(userId, doc.id); + + await chatService.addMessage({ + threadId: thread.id, + text: req.body.message, + isUser: true, + }); + const result = await documentRagService.chatWithDocument({ documentId: doc.id, question: req.body.message, docTitle: doc.title, }); + const aiMessage = await chatService.addMessage({ + threadId: thread.id, + text: result.answer, + isUser: false, + }); + + if (result.sources && result.sources.length > 0) { + await chatService.addMessageSources({ + messageId: aiMessage.id, + documentId: doc.id, + sources: result.sources.map((source) => ({ + chunkId: source.chunkId, + excerpt: source.excerpt, + pageLabel: source.pageLabel, + })), + }); + } + res.json({ answer: result.answer, sources: result.sources, + threadId: thread.id, }); } catch (e) { next(e); } }); +// GET /documents/:id/chat/history - Document chat history +router.get('/:id/chat/history', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const doc = await documentService.getDocument(userId, req.params.id); + + const thread = await chatService.getOrCreateThread(userId, doc.id); + const messages = await chatService.getThreadMessages(thread.id); + + res.json({ threadId: thread.id, messages }); + } catch (e) { + next(e); + } +}); + // POST /documents/:id/reindex - Reprocess document router.post('/:id/reindex', async (req: AuthRequest, res: Response, next: NextFunction) => { try { diff --git a/backend/src/services/chat.service.ts b/backend/src/services/chat.service.ts new file mode 100644 index 0000000..2be5fca --- /dev/null +++ b/backend/src/services/chat.service.ts @@ -0,0 +1,152 @@ +import { pool } from '../db/pool'; + +export interface ChatThread { + id: string; + user_id: string; + document_id: string | null; +} + +export interface ChatMessageSource { + docTitle: string; + excerpt: string; + pageLabel: string; +} + +export interface ChatMessageRecord { + id: string; + text: string; + isUser: boolean; + timestamp: Date; + sources?: ChatMessageSource[]; +} + +export async function getThreadById(userId: string, threadId: string): Promise { + const res = await pool.query( + `SELECT id, user_id, document_id + FROM chat_threads + WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`, + [threadId, userId] + ); + return res.rows[0] ?? null; +} + +export async function getOrCreateThread(userId: string, documentId: string | null): Promise { + const res = await pool.query( + `SELECT id, user_id, document_id + FROM chat_threads + WHERE user_id = $1 AND document_id IS NOT DISTINCT FROM $2 AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT 1`, + [userId, documentId] + ); + + if (res.rows.length > 0) { + return res.rows[0]; + } + + const insert = await pool.query( + `INSERT INTO chat_threads (user_id, document_id, created_at, updated_at) + VALUES ($1, $2, NOW(), NOW()) + RETURNING id, user_id, document_id`, + [userId, documentId] + ); + + return insert.rows[0]; +} + +export async function addMessage(params: { + threadId: string; + text: string; + isUser: boolean; + metadata?: Record | null; +}): Promise<{ id: string }> { + const res = await pool.query( + `INSERT INTO chat_messages (thread_id, text, is_user, metadata, timestamp, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW(), NOW()) + RETURNING id`, + [params.threadId, params.text, params.isUser, params.metadata ?? null] + ); + return res.rows[0]; +} + +export async function addMessageSources(params: { + messageId: string; + documentId: string; + sources: Array<{ chunkId?: string; excerpt: string; pageLabel: string }>; +}): Promise { + if (params.sources.length === 0) { + return; + } + + const values: Array = []; + const placeholders: string[] = []; + let index = 1; + + params.sources.forEach((source) => { + placeholders.push( + `($${index++}, $${index++}, $${index++}, $${index++}, $${index++})` + ); + values.push( + params.messageId, + params.documentId, + source.chunkId ?? null, + source.excerpt, + source.pageLabel + ); + }); + + await pool.query( + `INSERT INTO message_sources (message_id, document_id, chunk_id, excerpt, page_label) + VALUES ${placeholders.join(',')}`, + values + ); +} + +export async function getThreadMessages(threadId: string): Promise { + const res = await pool.query( + `SELECT + m.id as message_id, + m.text, + m.is_user, + m.timestamp, + s.excerpt as source_excerpt, + s.page_label, + d.title as doc_title + FROM chat_messages m + LEFT JOIN message_sources s ON s.message_id = m.id + LEFT JOIN documents d ON d.id = s.document_id + WHERE m.thread_id = $1 + ORDER BY m.timestamp ASC, s.created_at ASC`, + [threadId] + ); + + const byId = new Map(); + + res.rows.forEach((row) => { + const id = row.message_id as string; + const existing = byId.get(id); + const source = row.source_excerpt + ? { + docTitle: row.doc_title as string | null | undefined ?? '', + excerpt: row.source_excerpt as string, + pageLabel: row.page_label as string | null | undefined ?? '', + } + : null; + + if (!existing) { + const record: ChatMessageRecord = { + id, + text: row.text as string, + isUser: row.is_user as boolean, + timestamp: row.timestamp as Date, + sources: source ? [source] : [], + }; + byId.set(id, record); + } else if (source) { + existing.sources = existing.sources ?? []; + existing.sources.push(source); + } + }); + + return Array.from(byId.values()); +} diff --git a/backend/src/services/document_rag.service.ts b/backend/src/services/document_rag.service.ts index 4a2f8a2..7640c64 100644 --- a/backend/src/services/document_rag.service.ts +++ b/backend/src/services/document_rag.service.ts @@ -37,6 +37,12 @@ export async function indexDocument(params: { mimeType: string; }): Promise { try { + const existingDoc = await pool.query( + 'SELECT summary FROM documents WHERE id = $1', + [params.documentId] + ); + const existingSummary = existingDoc.rows[0]?.summary as string | null | undefined; + const text = await extractText(params.filePath, params.mimeType); if (!text || text.trim().length === 0) { await markDocumentFailed(params.documentId, 'Doküman metni çıkarılamadı.'); @@ -51,14 +57,16 @@ export async function indexDocument(params: { const totalChunks = chunks.length; - const summary = await generateSummary(chunks); + const summary = existingSummary && existingSummary.trim().length > 0 + ? existingSummary + : await generateSummary(chunks); await pool.query('DELETE FROM document_chunks WHERE document_id = $1', [params.documentId]); await pool.query( `UPDATE documents - SET status = 'processing', total_chunks = $2, processing_progress = 0.00, error_message = NULL, summary = $3 + SET status = 'processing', total_chunks = $2, processing_progress = 0.00, error_message = NULL, summary = $3, content_text = $4 WHERE id = $1`, - [params.documentId, totalChunks, summary] + [params.documentId, totalChunks, summary, text] ); let processed = 0; @@ -128,9 +136,9 @@ export async function indexDocument(params: { await pool.query( `UPDATE documents - SET status = 'ready', total_chunks = $2, indexed_at = NOW(), error_message = NULL, processing_progress = 1.00, summary = $3 + SET status = 'ready', total_chunks = $2, indexed_at = NOW(), error_message = NULL, processing_progress = 1.00, summary = $3, content_text = $4 WHERE id = $1`, - [params.documentId, totalChunks, summary] + [params.documentId, totalChunks, summary, text] ); } catch (e) { await markDocumentFailed(params.documentId, 'Doküman işlenirken hata oluştu.'); diff --git a/backend/src/services/llm_backend.service.ts b/backend/src/services/llm_backend.service.ts index a645db5..c55357d 100644 --- a/backend/src/services/llm_backend.service.ts +++ b/backend/src/services/llm_backend.service.ts @@ -13,6 +13,10 @@ interface RagAnswerResponse { answer: string; } +interface ChatResponse { + answer: string; +} + export async function embedTexts(texts: string[]): Promise { if (!config.OLLAMA_EMBEDDINGS_URL) { throw new Error('Ollama embeddings endpoint is not configured'); @@ -66,3 +70,19 @@ export async function answerWithContext(question: string, context: string): Prom const data = (await response.json()) as RagAnswerResponse; return data.answer; } + +export async function askCoach(message: string): Promise { + const response = await fetch(`${config.LLM_BACKEND_URL}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`LLM chat error: ${response.status} ${body}`); + } + + const data = (await response.json()) as ChatResponse; + return data.answer; +} diff --git a/lib/features/documents/presentation/document_chat_screen.dart b/lib/features/documents/presentation/document_chat_screen.dart index 133bdde..9f82ee0 100644 --- a/lib/features/documents/presentation/document_chat_screen.dart +++ b/lib/features/documents/presentation/document_chat_screen.dart @@ -18,18 +18,46 @@ class _DocumentChatScreenState extends ConsumerState { final List _messages = []; final TextEditingController _controller = TextEditingController(); bool _isSending = false; + bool _historyLoaded = false; @override void initState() { super.initState(); - // Initial greeting - _messages.add( - CoachMessage( - text: - "Merhaba! '${widget.document.title}' hakkında ne bilmek istersiniz?", - isUser: false, - ), - ); + _loadHistory(); + } + + Future _loadHistory() async { + if (_historyLoaded) return; + _historyLoaded = true; + + try { + final history = await ref + .read(apiDocumentRepositoryProvider) + .getDocumentChatHistory(widget.document.id); + + if (!mounted) return; + + if (history.isNotEmpty) { + setState(() { + _messages.addAll(history); + }); + return; + } + } catch (_) { + if (!mounted) return; + } + + if (mounted) { + setState(() { + _messages.add( + CoachMessage( + text: + "Merhaba! '${widget.document.title}' hakkında ne bilmek istersiniz?", + isUser: false, + ), + ); + }); + } } Future _sendMessage() async { diff --git a/lib/shared/data/api_chat_repository.dart b/lib/shared/data/api_chat_repository.dart index 02d37f1..3d17156 100644 --- a/lib/shared/data/api_chat_repository.dart +++ b/lib/shared/data/api_chat_repository.dart @@ -1,11 +1,69 @@ +import 'package:learning_coach/shared/models/models.dart'; import 'package:learning_coach/shared/services/api_service.dart'; +class ChatHistoryResult { + final String? threadId; + final List messages; + + ChatHistoryResult({required this.threadId, required this.messages}); +} + +class ChatSendResult { + final String answer; + final String? threadId; + + ChatSendResult({required this.answer, required this.threadId}); +} + class ApiChatRepository { final ApiService _apiService; ApiChatRepository(this._apiService); - Future sendMessage(String message) async { - return _apiService.sendChatMessage(message); + Future sendMessage(String message, {String? threadId}) async { + final data = await _apiService.sendChatMessage(message, threadId: threadId); + + return ChatSendResult( + answer: data['answer'] as String? ?? '', + threadId: data['threadId'] as String?, + ); + } + + Future getHistory({String? threadId}) async { + final data = await _apiService.getChatHistory(threadId: threadId); + final rawMessages = data['messages'] as List? ?? []; + + final messages = rawMessages.map((item) { + final json = item as Map; + final timestampRaw = json['timestamp']; + DateTime? timestamp; + if (timestampRaw is String) { + timestamp = DateTime.tryParse(timestampRaw); + } + + final sourcesRaw = json['sources'] as List?; + final sources = sourcesRaw + ?.map( + (s) => Source( + docTitle: s['docTitle'] as String? ?? '', + excerpt: s['excerpt'] as String? ?? '', + pageLabel: s['pageLabel'] as String? ?? '', + ), + ) + .toList(); + + return CoachMessage( + id: json['id'] as String?, + text: json['text'] as String? ?? '', + isUser: json['isUser'] as bool? ?? false, + timestamp: timestamp, + sources: sources, + ); + }).toList(); + + return ChatHistoryResult( + threadId: data['threadId'] as String?, + messages: messages, + ); } } diff --git a/lib/shared/data/api_document_repository.dart b/lib/shared/data/api_document_repository.dart index 6ed38ff..723a4e7 100644 --- a/lib/shared/data/api_document_repository.dart +++ b/lib/shared/data/api_document_repository.dart @@ -111,4 +111,44 @@ class ApiDocumentRepository { rethrow; } } + + Future> getDocumentChatHistory(String documentId) async { + try { + final response = await _dio.get>( + '/documents/$documentId/chat/history', + ); + + final data = response.data!['messages'] as List? ?? []; + + return data.map((item) { + final json = item as Map; + final timestampRaw = json['timestamp']; + DateTime? timestamp; + if (timestampRaw is String) { + timestamp = DateTime.tryParse(timestampRaw); + } + + final sourcesRaw = json['sources'] as List?; + final sources = sourcesRaw + ?.map( + (s) => Source( + docTitle: s['docTitle'] as String? ?? '', + excerpt: s['excerpt'] as String? ?? '', + pageLabel: s['pageLabel'] as String? ?? '', + ), + ) + .toList(); + + return CoachMessage( + id: json['id'] as String?, + text: json['text'] as String? ?? '', + isUser: json['isUser'] as bool? ?? false, + timestamp: timestamp, + sources: sources, + ); + }).toList(); + } catch (e) { + rethrow; + } + } } diff --git a/lib/shared/data/providers.dart b/lib/shared/data/providers.dart index 3cca19a..69fef51 100644 --- a/lib/shared/data/providers.dart +++ b/lib/shared/data/providers.dart @@ -7,7 +7,6 @@ import 'package:learning_coach/shared/data/api_study_session_repository.dart'; import 'package:learning_coach/shared/data/mock_data_repository.dart'; import 'package:learning_coach/shared/models/models.dart'; import 'package:learning_coach/shared/providers/auth_provider.dart'; -import 'package:learning_coach/shared/services/api_service.dart'; import 'package:learning_coach/shared/services/gamification_service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -76,9 +75,33 @@ Future> dailyStats(Ref ref) async { @Riverpod(keepAlive: true) class ChatMessages extends _$ChatMessages { + String? _threadId; + bool _loaded = false; + @override List build() { - return List.from(MockDataRepository.initialChat); + if (!_loaded) { + _loaded = true; + _loadHistory(); + } + + return []; + } + + Future _loadHistory() async { + try { + final repository = ref.read(apiChatRepositoryProvider); + final result = await repository.getHistory(); + _threadId = result.threadId; + if (result.messages.isNotEmpty) { + state = result.messages; + return; + } + state = List.from(MockDataRepository.initialChat); + } catch (e) { + debugPrint('Chat history load failed, using mock data: $e'); + state = List.from(MockDataRepository.initialChat); + } } Future sendMessage(String text) async { @@ -96,11 +119,12 @@ class ChatMessages extends _$ChatMessages { try { // 2. API üzerinden cevabı al final repository = ref.read(apiChatRepositoryProvider); - final responseText = await repository.sendMessage(text); + final response = await repository.sendMessage(text, threadId: _threadId); + _threadId = response.threadId ?? _threadId; // 3. AI Cevabını listeye ekle final aiMessage = CoachMessage( - text: responseText, + text: response.answer, isUser: false, timestamp: DateTime.now(), ); diff --git a/lib/shared/services/api_service.dart b/lib/shared/services/api_service.dart index d5b329e..6ef9825 100644 --- a/lib/shared/services/api_service.dart +++ b/lib/shared/services/api_service.dart @@ -1,10 +1,8 @@ -import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; // kIsWeb için import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; class ApiService { @@ -211,52 +209,22 @@ class ApiService { bool get isLoggedIn => _accessToken != null; - //-------------- LLM BACKEND --------------// - - static String get baseUrlLLM { - // 1. Önce .env dosyasında tanımlı mı diye bakıyoruz - final envUrl = dotenv.env['LLM_BASE_URL']; - - if (envUrl != null && envUrl.isNotEmpty) { - return envUrl; - } - - // 2. Eğer .env boşsa veya okunamazsa, senin sabit Windows sunucu IP'ni kullanıyoruz. - // Not: Sunucu farklı bilgisayarda olduğu için 'localhost' veya '10.0.2.2' işe yaramaz. - // Doğrudan o bilgisayarın ağ adresine gitmeliyiz. - return 'http://172.24.0.198:8000'; + Future> sendChatMessage( + String message, { + String? threadId, + }) async { + final response = await _dio.post( + '/chat', + data: {'message': message, if (threadId != null) 'threadId': threadId}, + ); + return response.data as Map; } - Future sendChatMessage(String message) async { - try { - final url = Uri.parse('$baseUrlLLM/chat'); - - debugPrint('İstek gönderiliyor: $url'); // Log ekledik - - final response = await http.post( - url, - headers: { - 'Content-Type': 'application/json; charset=UTF-8', // UTF-8 önemli - 'Accept': 'application/json', - }, - body: jsonEncode({'message': message}), - ); - - if (response.statusCode == 200) { - // UTF-8 decode yaparak Türkçe karakter sorununu da çözüyoruz - final data = jsonDecode(utf8.decode(response.bodyBytes)); - - return data['answer'] as String; - } else { - throw Exception( - 'Server hatası: ${response.statusCode} - ${response.body}', - ); - } - } catch (e) { - debugPrint('API Hatası: $e'); - throw Exception('Bağlantı hatası: $e'); - } + Future> getChatHistory({String? threadId}) async { + final response = await _dio.get( + '/chat/history', + queryParameters: threadId != null ? {'threadId': threadId} : null, + ); + return response.data as Map; } - - //-------------- LLM BACKEND --------------// }