Skip to content
Merged

. #6

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/db/migrations/20260213120000_document_content.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- migrate:up

ALTER TABLE documents
ADD COLUMN content_text TEXT;

-- migrate:down

ALTER TABLE documents
DROP COLUMN content_text;
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
70 changes: 70 additions & 0 deletions backend/src/routes/chat.ts
Original file line number Diff line number Diff line change
@@ -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;
54 changes: 54 additions & 0 deletions backend/src/routes/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
152 changes: 152 additions & 0 deletions backend/src/services/chat.service.ts
Original file line number Diff line number Diff line change
@@ -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<ChatThread | null> {
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<ChatThread> {
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<string, unknown> | 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<void> {
if (params.sources.length === 0) {
return;
}

const values: Array<string | null> = [];
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<ChatMessageRecord[]> {
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<string, ChatMessageRecord>();

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());
}
18 changes: 13 additions & 5 deletions backend/src/services/document_rag.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export async function indexDocument(params: {
mimeType: string;
}): Promise<void> {
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ı.');
Expand All @@ -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;
Expand Down Expand Up @@ -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.');
Expand Down
Loading
Loading