init
This commit is contained in:
parent
d7d57b0327
commit
bc4aacea62
266
backend/services/context_retrieval.py
Normal file
266
backend/services/context_retrieval.py
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
컨텍스트 기반 검색 시스템
|
||||||
|
- 컨텍스트 임베딩: 질문과 문서를 함께 임베딩하여 더 정확한 검색
|
||||||
|
- 컨텍스트 BM25: 질문과 문서의 컨텍스트를 고려한 키워드 검색
|
||||||
|
- Reranker: 검색 결과를 재순위화하여 정확도 향상
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import numpy as np
|
||||||
|
from typing import List, Dict, Any, Tuple
|
||||||
|
from rank_bm25 import BM25Okapi
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
from sklearn.metrics.pairwise import cosine_similarity
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ContextEmbedding:
|
||||||
|
"""컨텍스트 임베딩을 통한 검색"""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str = "jhgan/ko-sroberta-multitask"):
|
||||||
|
self.model = SentenceTransformer(model_name)
|
||||||
|
logger.info(f"✅ 컨텍스트 임베딩 모델 로드 완료: {model_name}")
|
||||||
|
|
||||||
|
def create_context_embedding(self, question: str, document: str) -> np.ndarray:
|
||||||
|
"""질문과 문서를 함께 임베딩하여 컨텍스트 임베딩 생성"""
|
||||||
|
# 질문과 문서를 결합하여 컨텍스트 생성
|
||||||
|
context = f"질문: {question}\n문서: {document}"
|
||||||
|
embedding = self.model.encode(context)
|
||||||
|
return embedding
|
||||||
|
|
||||||
|
def search_with_context(self, question: str, documents: List[Dict[str, Any]], top_k: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""컨텍스트 임베딩을 사용한 검색"""
|
||||||
|
logger.info(f"🔍 컨텍스트 임베딩 검색 시작: {len(documents)}개 문서")
|
||||||
|
|
||||||
|
# 질문 임베딩 생성
|
||||||
|
question_embedding = self.model.encode(question)
|
||||||
|
|
||||||
|
# 각 문서에 대해 컨텍스트 임베딩 생성 및 유사도 계산
|
||||||
|
scored_documents = []
|
||||||
|
for doc in documents:
|
||||||
|
doc_content = doc.get('content', '')
|
||||||
|
if not doc_content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 컨텍스트 임베딩 생성
|
||||||
|
context_embedding = self.create_context_embedding(question, doc_content)
|
||||||
|
|
||||||
|
# 코사인 유사도 계산
|
||||||
|
similarity = cosine_similarity([question_embedding], [context_embedding])[0][0]
|
||||||
|
|
||||||
|
scored_documents.append({
|
||||||
|
'document': doc,
|
||||||
|
'context_score': similarity,
|
||||||
|
'content': doc_content
|
||||||
|
})
|
||||||
|
|
||||||
|
# 유사도 기준으로 정렬
|
||||||
|
scored_documents.sort(key=lambda x: x['context_score'], reverse=True)
|
||||||
|
|
||||||
|
logger.info(f"📊 컨텍스트 임베딩 검색 완료: {len(scored_documents)}개 결과")
|
||||||
|
return scored_documents[:top_k]
|
||||||
|
|
||||||
|
class ContextBM25:
|
||||||
|
"""컨텍스트 BM25를 통한 검색"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.bm25 = None
|
||||||
|
self.documents = []
|
||||||
|
logger.info("✅ 컨텍스트 BM25 초기화 완료")
|
||||||
|
|
||||||
|
def preprocess_text(self, text: str) -> List[str]:
|
||||||
|
"""텍스트 전처리 및 토큰화"""
|
||||||
|
# 한글, 영문, 숫자만 추출
|
||||||
|
text = re.sub(r'[^\w\s가-힣]', ' ', text)
|
||||||
|
# 공백으로 분리
|
||||||
|
tokens = text.split()
|
||||||
|
# 빈 토큰 제거
|
||||||
|
tokens = [token.strip() for token in tokens if token.strip()]
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def build_index(self, documents: List[Dict[str, Any]]):
|
||||||
|
"""BM25 인덱스 구축"""
|
||||||
|
self.documents = documents
|
||||||
|
corpus = []
|
||||||
|
|
||||||
|
for doc in documents:
|
||||||
|
content = doc.get('content', '')
|
||||||
|
tokens = self.preprocess_text(content)
|
||||||
|
corpus.append(tokens)
|
||||||
|
|
||||||
|
self.bm25 = BM25Okapi(corpus)
|
||||||
|
logger.info(f"📚 BM25 인덱스 구축 완료: {len(corpus)}개 문서")
|
||||||
|
|
||||||
|
def search_with_context(self, question: str, top_k: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""컨텍스트 BM25를 사용한 검색"""
|
||||||
|
if not self.bm25:
|
||||||
|
logger.warning("⚠️ BM25 인덱스가 구축되지 않았습니다.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"🔍 컨텍스트 BM25 검색 시작: {question}")
|
||||||
|
|
||||||
|
# 질문 토큰화
|
||||||
|
question_tokens = self.preprocess_text(question)
|
||||||
|
|
||||||
|
# BM25 점수 계산
|
||||||
|
scores = self.bm25.get_scores(question_tokens)
|
||||||
|
|
||||||
|
# 점수와 문서를 매핑
|
||||||
|
scored_documents = []
|
||||||
|
for i, (doc, score) in enumerate(zip(self.documents, scores)):
|
||||||
|
scored_documents.append({
|
||||||
|
'document': doc,
|
||||||
|
'bm25_score': score,
|
||||||
|
'content': doc.get('content', '')
|
||||||
|
})
|
||||||
|
|
||||||
|
# 점수 기준으로 정렬
|
||||||
|
scored_documents.sort(key=lambda x: x['bm25_score'], reverse=True)
|
||||||
|
|
||||||
|
logger.info(f"📊 컨텍스트 BM25 검색 완료: {len(scored_documents)}개 결과")
|
||||||
|
return scored_documents[:top_k]
|
||||||
|
|
||||||
|
class Reranker:
|
||||||
|
"""검색 결과 재순위화"""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str = "jhgan/ko-sroberta-multitask"):
|
||||||
|
self.model = SentenceTransformer(model_name)
|
||||||
|
logger.info(f"✅ Reranker 모델 로드 완료: {model_name}")
|
||||||
|
|
||||||
|
def calculate_relevance_score(self, question: str, document_content: str) -> float:
|
||||||
|
"""질문과 문서의 관련성 점수 계산"""
|
||||||
|
# 질문과 문서의 임베딩 생성
|
||||||
|
question_embedding = self.model.encode(question)
|
||||||
|
doc_embedding = self.model.encode(document_content)
|
||||||
|
|
||||||
|
# 코사인 유사도 계산
|
||||||
|
similarity = cosine_similarity([question_embedding], [doc_embedding])[0][0]
|
||||||
|
|
||||||
|
# 키워드 매칭 점수 추가
|
||||||
|
keyword_score = self._calculate_keyword_score(question, document_content)
|
||||||
|
|
||||||
|
# 최종 점수 (임베딩 유사도 70% + 키워드 매칭 30%)
|
||||||
|
final_score = 0.7 * similarity + 0.3 * keyword_score
|
||||||
|
|
||||||
|
return final_score
|
||||||
|
|
||||||
|
def _calculate_keyword_score(self, question: str, document: str) -> float:
|
||||||
|
"""키워드 매칭 점수 계산"""
|
||||||
|
# 질문에서 키워드 추출
|
||||||
|
question_tokens = re.findall(r'\b\w+\b', question.lower())
|
||||||
|
doc_tokens = re.findall(r'\b\w+\b', document.lower())
|
||||||
|
|
||||||
|
# 토큰 빈도 계산
|
||||||
|
question_counter = Counter(question_tokens)
|
||||||
|
doc_counter = Counter(doc_tokens)
|
||||||
|
|
||||||
|
# 공통 토큰의 가중치 계산
|
||||||
|
common_tokens = set(question_tokens) & set(doc_tokens)
|
||||||
|
if not common_tokens:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# TF-IDF 스타일 점수 계산
|
||||||
|
total_score = 0.0
|
||||||
|
for token in common_tokens:
|
||||||
|
question_freq = question_counter[token]
|
||||||
|
doc_freq = doc_counter[token]
|
||||||
|
# 간단한 TF-IDF 스타일 점수
|
||||||
|
score = (question_freq * doc_freq) / (len(question_tokens) * len(doc_tokens))
|
||||||
|
total_score += score
|
||||||
|
|
||||||
|
return min(total_score, 1.0) # 최대 1.0으로 제한
|
||||||
|
|
||||||
|
def rerank_documents(self, question: str, documents: List[Dict[str, Any]], top_k: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""문서 재순위화"""
|
||||||
|
logger.info(f"🔄 Reranker 재순위화 시작: {len(documents)}개 문서")
|
||||||
|
|
||||||
|
reranked_documents = []
|
||||||
|
for doc_info in documents:
|
||||||
|
content = doc_info.get('content', '')
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 관련성 점수 계산
|
||||||
|
relevance_score = self.calculate_relevance_score(question, content)
|
||||||
|
|
||||||
|
# 기존 점수와 재순위화 점수 결합
|
||||||
|
original_score = doc_info.get('context_score', 0) + doc_info.get('bm25_score', 0)
|
||||||
|
final_score = 0.6 * relevance_score + 0.4 * original_score
|
||||||
|
|
||||||
|
reranked_documents.append({
|
||||||
|
**doc_info,
|
||||||
|
'rerank_score': relevance_score,
|
||||||
|
'final_score': final_score
|
||||||
|
})
|
||||||
|
|
||||||
|
# 최종 점수 기준으로 정렬
|
||||||
|
reranked_documents.sort(key=lambda x: x['final_score'], reverse=True)
|
||||||
|
|
||||||
|
logger.info(f"📊 Reranker 재순위화 완료: {len(reranked_documents)}개 결과")
|
||||||
|
return reranked_documents[:top_k]
|
||||||
|
|
||||||
|
class ContextRetrieval:
|
||||||
|
"""컨텍스트 기반 검색 시스템 통합 클래스"""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str = "jhgan/ko-sroberta-multitask"):
|
||||||
|
self.context_embedding = ContextEmbedding(model_name)
|
||||||
|
self.context_bm25 = ContextBM25()
|
||||||
|
self.reranker = Reranker(model_name)
|
||||||
|
logger.info("✅ 컨텍스트 검색 시스템 초기화 완료")
|
||||||
|
|
||||||
|
def build_index(self, documents: List[Dict[str, Any]]):
|
||||||
|
"""검색 인덱스 구축"""
|
||||||
|
logger.info(f"📚 검색 인덱스 구축 시작: {len(documents)}개 문서")
|
||||||
|
self.context_bm25.build_index(documents)
|
||||||
|
logger.info("✅ 검색 인덱스 구축 완료")
|
||||||
|
|
||||||
|
def search(self, question: str, top_k: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""컨텍스트 기반 통합 검색"""
|
||||||
|
logger.info(f"🔍 컨텍스트 기반 통합 검색 시작: {question}")
|
||||||
|
|
||||||
|
# 1. 컨텍스트 임베딩 검색
|
||||||
|
embedding_results = self.context_embedding.search_with_context(question, self.context_bm25.documents, top_k * 2)
|
||||||
|
|
||||||
|
# 2. 컨텍스트 BM25 검색
|
||||||
|
bm25_results = self.context_bm25.search_with_context(question, top_k * 2)
|
||||||
|
|
||||||
|
# 3. 두 결과를 결합하여 중복 제거
|
||||||
|
combined_results = self._combine_results(embedding_results, bm25_results)
|
||||||
|
|
||||||
|
# 4. Reranker로 재순위화
|
||||||
|
final_results = self.reranker.rerank_documents(question, combined_results, top_k)
|
||||||
|
|
||||||
|
logger.info(f"📊 컨텍스트 기반 통합 검색 완료: {len(final_results)}개 결과")
|
||||||
|
return final_results
|
||||||
|
|
||||||
|
def _combine_results(self, embedding_results: List[Dict], bm25_results: List[Dict]) -> List[Dict]:
|
||||||
|
"""임베딩과 BM25 결과를 결합하여 중복 제거"""
|
||||||
|
combined = {}
|
||||||
|
|
||||||
|
# 임베딩 결과 추가
|
||||||
|
for result in embedding_results:
|
||||||
|
doc_id = id(result['document'])
|
||||||
|
if doc_id not in combined:
|
||||||
|
combined[doc_id] = result
|
||||||
|
else:
|
||||||
|
# 기존 점수와 새 점수 결합
|
||||||
|
combined[doc_id]['context_score'] = max(
|
||||||
|
combined[doc_id].get('context_score', 0),
|
||||||
|
result['context_score']
|
||||||
|
)
|
||||||
|
|
||||||
|
# BM25 결과 추가
|
||||||
|
for result in bm25_results:
|
||||||
|
doc_id = id(result['document'])
|
||||||
|
if doc_id not in combined:
|
||||||
|
combined[doc_id] = result
|
||||||
|
else:
|
||||||
|
# 기존 점수와 새 점수 결합
|
||||||
|
combined[doc_id]['bm25_score'] = max(
|
||||||
|
combined[doc_id].get('bm25_score', 0),
|
||||||
|
result['bm25_score']
|
||||||
|
)
|
||||||
|
|
||||||
|
return list(combined.values())
|
||||||
@ -6,7 +6,6 @@ LangChain v0.3 기반 AI 서비스
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from datetime import datetime
|
|
||||||
from .context_retrieval import ContextRetrieval
|
from .context_retrieval import ContextRetrieval
|
||||||
|
|
||||||
# LangChain Core
|
# LangChain Core
|
||||||
@ -16,8 +15,6 @@ from langchain_core.vectorstores import VectorStore
|
|||||||
from langchain_core.retrievers import BaseRetriever
|
from langchain_core.retrievers import BaseRetriever
|
||||||
from langchain_core.language_models import BaseLanguageModel
|
from langchain_core.language_models import BaseLanguageModel
|
||||||
from langchain_core.prompts import PromptTemplate
|
from langchain_core.prompts import PromptTemplate
|
||||||
from langchain_core.output_parsers import StrOutputParser
|
|
||||||
from langchain_core.runnables import RunnablePassthrough, RunnableParallel
|
|
||||||
|
|
||||||
# LangChain Community
|
# LangChain Community
|
||||||
from langchain_community.vectorstores import Chroma
|
from langchain_community.vectorstores import Chroma
|
||||||
@ -25,7 +22,6 @@ from langchain_community.embeddings import SentenceTransformerEmbeddings
|
|||||||
from langchain_community.llms import Ollama
|
from langchain_community.llms import Ollama
|
||||||
|
|
||||||
# LangChain Chains
|
# LangChain Chains
|
||||||
from langchain.chains import RetrievalQA
|
|
||||||
from langchain.chains.combine_documents import create_stuff_documents_chain
|
from langchain.chains.combine_documents import create_stuff_documents_chain
|
||||||
from langchain.chains import create_retrieval_chain
|
from langchain.chains import create_retrieval_chain
|
||||||
|
|
||||||
@ -696,29 +692,6 @@ class LangChainRAGService:
|
|||||||
|
|
||||||
return max(0.0, score) # 음수 점수 방지
|
return max(0.0, score) # 음수 점수 방지
|
||||||
|
|
||||||
def _verify_content_relevance(self, content: str, question: str) -> bool:
|
|
||||||
"""문서 내용이 질문과 실제로 관련이 있는지 검증 (간소화)"""
|
|
||||||
if not content or not question:
|
|
||||||
return False
|
|
||||||
|
|
||||||
content_lower = content.lower()
|
|
||||||
question_keywords = self._extract_keywords(question)
|
|
||||||
|
|
||||||
# 명백히 관련 없는 페이지 패턴들
|
|
||||||
irrelevant_patterns = [
|
|
||||||
'개정번호', '개정항목', '개정사유', '발효일자', '신규제정',
|
|
||||||
'바인더 표지', '보관번호', '보관목록', '관리부서',
|
|
||||||
'prepared by', 'format no', 'storage number'
|
|
||||||
]
|
|
||||||
|
|
||||||
if any(pattern in content_lower for pattern in irrelevant_patterns):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 키워드 매칭 확인 (완화된 버전)
|
|
||||||
keyword_matches = sum(1 for keyword in question_keywords if keyword in content_lower)
|
|
||||||
|
|
||||||
# 최소 1개 이상의 키워드가 매칭되면 관련성이 있다고 판단 (2→1로 완화)
|
|
||||||
return keyword_matches >= 1
|
|
||||||
|
|
||||||
def _verify_answer_document_relevance(self, answer: str, doc_content: str, question: str) -> bool:
|
def _verify_answer_document_relevance(self, answer: str, doc_content: str, question: str) -> bool:
|
||||||
"""답변과 문서 내용의 직접적 연관성 검증"""
|
"""답변과 문서 내용의 직접적 연관성 검증"""
|
||||||
|
|||||||
10
frontend/public/images/chatbot_icon.svg
Normal file
10
frontend/public/images/chatbot_icon.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- 노란색 원형 배경 -->
|
||||||
|
<circle cx="16" cy="16" r="16" fill="#FFD700"/>
|
||||||
|
|
||||||
|
<!-- 흰색 내부 원 (도넛 모양) -->
|
||||||
|
<circle cx="16" cy="16" r="12" fill="white"/>
|
||||||
|
|
||||||
|
<!-- 중앙에 W 문자 -->
|
||||||
|
<text x="16" y="20" font-family="Arial, sans-serif" font-size="14" font-weight="bold" text-anchor="middle" fill="#FFD700">W</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 450 B |
136
frontend/src/components/SimpleMarkdownRenderer.tsx
Normal file
136
frontend/src/components/SimpleMarkdownRenderer.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
interface DetailedReference {
|
||||||
|
filename: string;
|
||||||
|
file_id: string;
|
||||||
|
page_number: number;
|
||||||
|
chunk_index?: number;
|
||||||
|
content_preview?: string;
|
||||||
|
full_content?: string;
|
||||||
|
is_relevant?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimpleMarkdownRendererProps {
|
||||||
|
content: string;
|
||||||
|
detailedReferences?: DetailedReference[];
|
||||||
|
onReferenceClick?: (fileId: string, pageNumber: number, filename: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({
|
||||||
|
content,
|
||||||
|
detailedReferences = [],
|
||||||
|
onReferenceClick
|
||||||
|
}) => {
|
||||||
|
// 마크다운 렌더링 함수
|
||||||
|
const renderMarkdown = (text: string): React.ReactNode => {
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
// 일반 링크 처리
|
||||||
|
link: ({ href, children }) => (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 underline">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
// 헤딩 스타일
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-4 mt-6 first:mt-0">
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-3 mt-5">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="text-lg font-medium text-gray-700 mb-2 mt-4">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
// 문단 스타일
|
||||||
|
p: ({ children }) => (
|
||||||
|
<p className="text-gray-700 leading-relaxed mb-4">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
// 리스트 스타일
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul className="list-disc pl-6 text-gray-700 mb-4 space-y-2">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol className="list-decimal pl-6 text-gray-700 mb-4 space-y-2">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => (
|
||||||
|
<li className="text-gray-700 leading-relaxed ml-2">
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
// 강조 스타일
|
||||||
|
strong: ({ children }) => (
|
||||||
|
<strong className="font-semibold text-gray-900">
|
||||||
|
{children}
|
||||||
|
</strong>
|
||||||
|
),
|
||||||
|
em: ({ children }) => (
|
||||||
|
<em className="italic text-gray-800">
|
||||||
|
{children}
|
||||||
|
</em>
|
||||||
|
),
|
||||||
|
// 코드 스타일
|
||||||
|
code: ({ children }) => (
|
||||||
|
<code className="bg-gray-100 text-gray-800 px-1 py-0.5 rounded text-sm font-mono">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre className="bg-gray-100 text-gray-800 p-4 rounded-lg overflow-x-auto mb-4">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
// 인용 스타일
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="border-l-4 border-blue-500 pl-6 py-2 italic text-gray-600 mb-4 bg-blue-50 rounded-r">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
// 테이블 스타일
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="overflow-x-auto mb-4">
|
||||||
|
<table className="min-w-full border-collapse border border-gray-300">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
th: ({ children }) => (
|
||||||
|
<th className="border border-gray-300 bg-gray-100 px-4 py-2 text-left font-semibold text-gray-900">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children }) => (
|
||||||
|
<td className="border border-gray-300 px-4 py-2 text-gray-700">
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose prose-gray max-w-none">
|
||||||
|
{renderMarkdown(content)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SimpleMarkdownRenderer;
|
||||||
Loading…
x
Reference in New Issue
Block a user