cksleigen commited on
Commit
c2f0e66
ยท
1 Parent(s): 36696b3

add files

Browse files
.gitignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Keys
2
+ .env
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+ .Python
10
+ venv/
11
+ ENV/
12
+ env/
13
+
14
+ # Data
15
+ data/uploads/*.pdf
16
+ data/chroma_db/
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+ *.swo
23
+
24
+ # OS
25
+ .DS_Store
26
+ Thumbs.db
27
+
config/__init__.py ADDED
File without changes
config/settings.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # config/settings.py
2
+ """์„ค์ • ํŒŒ์ผ"""
3
+ import os
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+ # โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
9
+ # ๋ธŒ๋žœ๋”ฉ (โœ… PROBIN)
10
+ # โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
11
+ APP_NAME = "PROBIN"
12
+ APP_SUBTITLE = "Experience Intelligent Document Analysis with AI"
13
+ APP_ICON = "๐Ÿ”ฎ" # ์ˆ˜์ •๊ตฌ์Šฌ
14
+
15
+ # UI ์„ค์ •
16
+ SHOW_STATS = False # ํ†ต๊ณ„ ์ˆจ๊น€
17
+ PDF_HEIGHT = "80vh"
18
+
19
+ # API Keys
20
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
21
+ XAI_API_KEY = os.getenv("XAI_API_KEY")
22
+
23
+ if not OPENAI_API_KEY or not XAI_API_KEY:
24
+ raise ValueError("โŒ .env ํŒŒ์ผ์— API Keys๋ฅผ ์„ค์ •ํ•˜์„ธ์š”!")
25
+
26
+ # ์ž„๋ฒ ๋”ฉ ์„ค์ •
27
+ EMBEDDING_MODEL = "text-embedding-3-small"
28
+ EMBEDDING_DIMENSION = 1536
29
+
30
+ # ์ฒญํ‚น ์„ค์ •
31
+ CHUNK_SIZE = 800
32
+ CHUNK_OVERLAP = 150
33
+
34
+ # ๊ฒ€์ƒ‰ ์„ค์ •
35
+ TOP_K = 10
36
+
37
+ # ChromaDB ์„ค์ •
38
+ CHROMA_PATH = "./data/chroma_db"
39
+ COLLECTION_NAME = "rfp_documents"
40
+
41
+ # Grok ์„ค์ •
42
+ GROK_MODEL = "grok-3"
43
+ GROK_BASE_URL = "https://api.x.ai/v1"
core/__init__.py ADDED
File without changes
core/chunker.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/chunker.py
2
+ """ํ…์ŠคํŠธ ์ฒญํ‚น"""
3
+ from typing import List, Dict
4
+ from config.settings import CHUNK_SIZE, CHUNK_OVERLAP
5
+
6
+
7
+ def chunk_text(pages: List[Dict], chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[Dict]:
8
+ """
9
+ ํ…์ŠคํŠธ๋ฅผ ์ฒญํฌ๋กœ ๋ถ„ํ• 
10
+
11
+ Args:
12
+ pages: ํŽ˜์ด์ง€๋ณ„ ํ…์ŠคํŠธ
13
+ chunk_size: ์ฒญํฌ ํฌ๊ธฐ
14
+ overlap: ์˜ค๋ฒ„๋žฉ ํฌ๊ธฐ
15
+
16
+ Returns:
17
+ List[Dict]: ์ฒญํฌ ๋ฆฌ์ŠคํŠธ
18
+ [
19
+ {
20
+ "chunk_id": "chunk_0",
21
+ "text": "...",
22
+ "page_num": 1,
23
+ "start_char": 0,
24
+ "end_char": 800
25
+ },
26
+ ...
27
+ ]
28
+ """
29
+ print(f"โœ‚๏ธ ์ฒญํ‚น ์‹œ์ž‘ (ํฌ๊ธฐ: {chunk_size}, ์˜ค๋ฒ„๋žฉ: {overlap})")
30
+
31
+ chunks = []
32
+ chunk_id = 0
33
+
34
+ for page in pages:
35
+ page_num = page["page_num"]
36
+ text = page["text"]
37
+
38
+ # ํŽ˜์ด์ง€ ํ…์ŠคํŠธ๋ฅผ ์ฒญํฌ๋กœ ๋ถ„ํ• 
39
+ start = 0
40
+ while start < len(text):
41
+ end = start + chunk_size
42
+ chunk_text = text[start:end]
43
+
44
+ # ๋นˆ ์ฒญํฌ ์ œ์™ธ
45
+ if chunk_text.strip():
46
+ chunks.append({
47
+ "chunk_id": f"chunk_{chunk_id}",
48
+ "text": chunk_text,
49
+ "page_num": page_num,
50
+ "start_char": start,
51
+ "end_char": end
52
+ })
53
+ chunk_id += 1
54
+
55
+ # ๋‹ค์Œ ์ฒญํฌ ์‹œ์ž‘ ์œ„์น˜ (์˜ค๋ฒ„๋žฉ ๊ณ ๋ ค)
56
+ start += (chunk_size - overlap)
57
+
58
+ print(f"โœ… {len(chunks)}๊ฐœ ์ฒญํฌ ์ƒ์„ฑ")
59
+
60
+ return chunks
61
+
62
+
63
+ if __name__ == "__main__":
64
+ # ํ…Œ์ŠคํŠธ
65
+ test_pages = [
66
+ {"page_num": 1, "text": "ํ…Œ์ŠคํŠธ " * 500},
67
+ {"page_num": 2, "text": "๋ฌธ์„œ " * 500}
68
+ ]
69
+
70
+ chunks = chunk_text(test_pages)
71
+ print(f"์ƒ์„ฑ๋œ ์ฒญํฌ ์ˆ˜: {len(chunks)}")
72
+ print(f"์ฒซ ์ฒญํฌ: {chunks[0]}")
73
+
core/embedder.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/embedder.py
2
+ """์ž„๋ฒ ๋”ฉ ์ƒ์„ฑ"""
3
+ from openai import OpenAI
4
+ from typing import List, Dict
5
+ from config.settings import OPENAI_API_KEY, EMBEDDING_MODEL
6
+ import time
7
+
8
+ client = OpenAI(api_key=OPENAI_API_KEY)
9
+
10
+
11
+ def embed_chunks(chunks: List[Dict]) -> List[Dict]:
12
+ """
13
+ ์ฒญํฌ ๋ฆฌ์ŠคํŠธ๋ฅผ ์ž„๋ฒ ๋”ฉ
14
+
15
+ Args:
16
+ chunks: ์ฒญํฌ ๋ฆฌ์ŠคํŠธ
17
+
18
+ Returns:
19
+ List[Dict]: ์ž„๋ฒ ๋”ฉ์ด ์ถ”๊ฐ€๋œ ์ฒญํฌ
20
+ [
21
+ {
22
+ "chunk_id": "...",
23
+ "text": "...",
24
+ "embedding": [0.1, 0.2, ...],
25
+ ...
26
+ },
27
+ ...
28
+ ]
29
+ """
30
+ print(f"๐Ÿ”ข ์ž„๋ฒ ๋”ฉ ์‹œ์ž‘ ({len(chunks)}๊ฐœ ์ฒญํฌ)")
31
+ start_time = time.time()
32
+
33
+ # ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ (OpenAI๋Š” ํ•œ ๋ฒˆ์— ์—ฌ๋Ÿฌ ๊ฐœ ๊ฐ€๋Šฅ)
34
+ texts = [chunk["text"] for chunk in chunks]
35
+
36
+ response = client.embeddings.create(
37
+ model=EMBEDDING_MODEL,
38
+ input=texts
39
+ )
40
+
41
+ # ์ž„๋ฒ ๋”ฉ ์ถ”๊ฐ€
42
+ for i, chunk in enumerate(chunks):
43
+ chunk["embedding"] = response.data[i].embedding
44
+
45
+ elapsed = time.time() - start_time
46
+ print(f"โœ… ์ž„๋ฒ ๋”ฉ ์™„๋ฃŒ ({elapsed:.2f}์ดˆ)")
47
+ print(f" - ์†๋„: {len(chunks)/elapsed:.2f} chunks/sec")
48
+
49
+ return chunks
50
+
51
+
52
+ if __name__ == "__main__":
53
+ # ํ…Œ์ŠคํŠธ
54
+ test_chunks = [
55
+ {"chunk_id": "chunk_0", "text": "ํ…Œ์ŠคํŠธ ๋ฌธ์„œ์ž…๋‹ˆ๋‹ค."}
56
+ ]
57
+
58
+ embedded = embed_chunks(test_chunks)
59
+ print(f"์ž„๋ฒ ๋”ฉ ์ฐจ์›: {len(embedded[0]['embedding'])}")
60
+
core/generator.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/generator.py
2
+ """Grok ๋‹ต๋ณ€ ์ƒ์„ฑ"""
3
+ from openai import OpenAI
4
+ from typing import List, Dict
5
+ from config.settings import XAI_API_KEY, GROK_MODEL, GROK_BASE_URL
6
+
7
+ # Grok ํด๋ผ์ด์–ธํŠธ ์ดˆ๊ธฐํ™”
8
+ client = OpenAI(
9
+ api_key=XAI_API_KEY,
10
+ base_url=GROK_BASE_URL
11
+ )
12
+
13
+
14
+ class Generator:
15
+ """๋‹ต๋ณ€ ์ƒ์„ฑ ํด๋ž˜์Šค"""
16
+
17
+ def __init__(self):
18
+ """์ƒ์„ฑ๊ธฐ ์ดˆ๊ธฐํ™”"""
19
+ pass
20
+
21
+ def generate_answer(self, query: str, retrieved_chunks: List[Dict]) -> Dict:
22
+ """
23
+ ๊ฒ€์ƒ‰๋œ ์ฒญํฌ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ๋‹ต๋ณ€ ์ƒ์„ฑ
24
+
25
+ Args:
26
+ query: ์‚ฌ์šฉ์ž ์งˆ๋ฌธ
27
+ retrieved_chunks: ๊ฒ€์ƒ‰๋œ ์ฒญํฌ ๋ฆฌ์ŠคํŠธ
28
+
29
+ Returns:
30
+ Dict: {
31
+ "answer": "๋‹ต๋ณ€ ํ…์ŠคํŠธ",
32
+ "sources": [
33
+ {"page_num": 1, "text": "...", "chunk_id": "..."},
34
+ ...
35
+ ]
36
+ }
37
+ """
38
+ print(f"๐Ÿค– Grok ๋‹ต๋ณ€ ์ƒ์„ฑ ์ค‘...")
39
+
40
+ # ์ปจํ…์ŠคํŠธ ๊ตฌ์„ฑ
41
+ context = self._build_context(retrieved_chunks)
42
+
43
+ # ํ”„๋กฌํ”„ํŠธ ๊ตฌ์„ฑ
44
+ prompt = self._build_prompt(query, context)
45
+
46
+ # Grok ํ˜ธ์ถœ
47
+ response = client.chat.completions.create(
48
+ model=GROK_MODEL,
49
+ messages=[
50
+ {
51
+ "role": "system",
52
+ "content": """๋‹น์‹ ์€ RFP(์ œ์•ˆ์š”์ฒญ์„œ) ๋ฌธ์„œ ๋ถ„์„ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
53
+ ์ œ๊ณต๋œ ๋ฌธ์„œ ๋‚ด์šฉ๋งŒ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ •ํ™•ํ•˜๊ณ  ๊ตฌ์ฒด์ ์œผ๋กœ ๋‹ต๋ณ€ํ•˜์„ธ์š”.
54
+ ๋ฌธ์„œ์— ์—†๋Š” ๋‚ด์šฉ์€ ์ถ”์ธกํ•˜์ง€ ๋ง๊ณ  ์†”์งํžˆ "๋ฌธ์„œ์—์„œ ํ•ด๋‹น ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"๋ผ๊ณ  ๋‹ต๋ณ€ํ•˜์„ธ์š”.
55
+ ๋‹ต๋ณ€ ์‹œ ๋ฐ˜๋“œ์‹œ ์ถœ์ฒ˜ ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ๋ฅผ [ํŽ˜์ด์ง€ X] ํ˜•์‹์œผ๋กœ ๋ช…์‹œํ•˜์„ธ์š”."""
56
+ },
57
+ {
58
+ "role": "user",
59
+ "content": prompt
60
+ }
61
+ ],
62
+ temperature=0.3,
63
+ max_tokens=1000
64
+ )
65
+
66
+ answer = response.choices[0].message.content
67
+
68
+ # ๋‹ต๋ณ€์—์„œ ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ ์ถ”์ถœ (์˜ˆ: [ํŽ˜์ด์ง€ 7])
69
+ import re
70
+ page_matches = re.findall(r'\[ํŽ˜์ด์ง€\s*(\d+)\]', answer)
71
+ mentioned_page = int(page_matches[0]) if page_matches else None
72
+
73
+ # ์†Œ์Šค ์ •๋ฆฌ (๋‹ต๋ณ€์— ์–ธ๊ธ‰๋œ ํŽ˜์ด์ง€ ์šฐ์„ )
74
+ sources = self._extract_sources(retrieved_chunks, mentioned_page)
75
+
76
+ print(f"โœ… ๋‹ต๋ณ€ ์ƒ์„ฑ ์™„๋ฃŒ ({len(answer)}์ž)")
77
+ if mentioned_page:
78
+ print(f" ๐Ÿ“ ๋‹ต๋ณ€ ์ถœ์ฒ˜: ํŽ˜์ด์ง€ {mentioned_page}")
79
+
80
+ return {
81
+ "answer": answer,
82
+ "sources": sources
83
+ }
84
+
85
+ def _build_context(self, chunks: List[Dict]) -> str:
86
+ """
87
+ ์ฒญํฌ๋ฅผ ์ปจํ…์ŠคํŠธ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜
88
+
89
+ Args:
90
+ chunks: ์ฒญํฌ ๋ฆฌ์ŠคํŠธ
91
+
92
+ Returns:
93
+ str: ์ปจํ…์ŠคํŠธ ๋ฌธ์ž์—ด
94
+ """
95
+ context_parts = []
96
+ for i, chunk in enumerate(chunks, 1):
97
+ context_parts.append(
98
+ f"[๋ฌธ์„œ {i} - ํŽ˜์ด์ง€ {chunk['page_num']}]\n{chunk['text']}\n"
99
+ )
100
+
101
+ return "\n".join(context_parts)
102
+
103
+ def _build_prompt(self, query: str, context: str) -> str:
104
+ """
105
+ ํ”„๋กฌํ”„ํŠธ ๊ตฌ์„ฑ (ํ•˜์ด๋ผ์ดํŠธ์šฉ ์ธ์šฉ๋ฌธ ํฌํ•จ)
106
+
107
+ Args:
108
+ query: ์‚ฌ์šฉ์ž ์งˆ๋ฌธ
109
+ context: ์ปจํ…์ŠคํŠธ
110
+
111
+ Returns:
112
+ str: ํ”„๋กฌํ”„ํŠธ
113
+ """
114
+ prompt = f"""๋‹น์‹ ์€ RFP(์ œ์•ˆ์š”์ฒญ์„œ) ๋ฌธ์„œ ๋ถ„์„ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
115
+ ๋‹ค์Œ ๋ฌธ์„œ ๋‚ด์šฉ์„ ๋ฐ”ํƒ•์œผ๋กœ ์งˆ๋ฌธ์— ๋‹ต๋ณ€ํ•˜์„ธ์š”.
116
+
117
+ # ๋ฌธ์„œ ๋‚ด์šฉ
118
+ {context}
119
+
120
+ # ์งˆ๋ฌธ
121
+ {query}
122
+
123
+ # ๋‹ต๋ณ€ ๊ทœ์น™
124
+ 1. ๋ฐ˜๋“œ์‹œ ์ œ๊ณต๋œ ๋ฌธ์„œ ๋‚ด์šฉ๋งŒ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ๋‹ต๋ณ€ํ•˜์„ธ์š”
125
+ 2. ๋ฌธ์„œ์— ์—†๋Š” ๋‚ด์šฉ์ด๋ฉด "์ œ๊ณต๋œ ๋ฌธ์„œ์—์„œ ํ•ด๋‹น ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"๋ผ๊ณ  ๋‹ต๋ณ€ํ•˜์„ธ์š”
126
+ 3. ๋‹ต๋ณ€ ์‹œ ์ถœ์ฒ˜ ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ๋ฅผ [ํŽ˜์ด์ง€ X] ํ˜•์‹์œผ๋กœ ๋ช…์‹œํ•˜์„ธ์š”
127
+ 4. **์ค‘์š”**: ๋‹ต๋ณ€์˜ ํ•ต์‹ฌ ๊ทผ๊ฑฐ๊ฐ€ ๋˜๋Š” ๋ฌธ์„œ์˜ ์›๋ฌธ์„ ๊ทธ๋Œ€๋กœ ์ธ์šฉํ•˜์„ธ์š”
128
+ - ๋‹ต๋ณ€ ๋์— "---ํ•ต์‹ฌ ์ธ์šฉ---" ๋‹ค์Œ์— PDF์—์„œ ํ•˜์ผ๋ผ์ดํŠธํ•  ์ •ํ™•ํ•œ ๋ฌธ์žฅ์„ ์ ์–ด์ฃผ์„ธ์š”
129
+ - ์ธ์šฉ๋ฌธ์€ ๋ฌธ์„œ ์›๋ฌธ ๊ทธ๋Œ€๋กœ, 10-30๋‹จ์–ด ์ •๋„๋กœ ์ ์–ด์ฃผ์„ธ์š”
130
+ 5. ๋ช…ํ™•ํ•˜๊ณ  ๊ฐ„๊ฒฐํ•˜๊ฒŒ ๋‹ต๋ณ€ํ•˜์„ธ์š”
131
+
132
+ # ๋‹ต๋ณ€ ํ˜•์‹ ์˜ˆ์‹œ
133
+ (๋‹ต๋ณ€ ๋‚ด์šฉ)... [ํŽ˜์ด์ง€ 7]
134
+
135
+ ---ํ•ต์‹ฌ ์ธ์šฉ---
136
+ ์ฐจ์ฒด ๋ฐ ์ผ๋ฐ˜๋ถ€ํ’ˆ์˜ ๋ณด์ฆ๊ธฐ๊ฐ„: 3๋…„/6๋งŒkm ์ด๋‚ด
137
+ """
138
+
139
+ return prompt
140
+
141
+ def _extract_sources(self, chunks: List[Dict], mentioned_page: int = None) -> List[Dict]:
142
+ """
143
+ ์†Œ์Šค ์ •๋ณด ์ถ”์ถœ (๋‹ต๋ณ€์— ์–ธ๊ธ‰๋œ ํŽ˜์ด์ง€ ์šฐ์„ )
144
+
145
+ Args:
146
+ chunks: ์ฒญํฌ ๋ฆฌ์ŠคํŠธ (์ด๋ฏธ ๊ด€๋ จ๋„ ์ˆœ์œผ๋กœ ์ •๋ ฌ๋จ)
147
+ mentioned_page: AI ๋‹ต๋ณ€์— ์–ธ๊ธ‰๋œ ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ
148
+
149
+ Returns:
150
+ List[Dict]: ์†Œ์Šค ์ •๋ณด (mentioned_page๊ฐ€ ์žˆ๏ฟฝ๏ฟฝ๋ฉด ํ•ด๋‹น ํŽ˜์ด์ง€ ์ฒญํฌ๋ฅผ 1์œ„๋กœ)
151
+ """
152
+ # mentioned_page๊ฐ€ ์žˆ์œผ๋ฉด ํ•ด๋‹น ํŽ˜์ด์ง€ ์ฒญํฌ๋ฅผ ์ตœ์šฐ์„ ์œผ๋กœ
153
+ if mentioned_page:
154
+ # ํ•ด๋‹น ํŽ˜์ด์ง€์˜ ์ฒญํฌ๋ฅผ ์ฐพ์•„์„œ ๋งจ ์•ž์œผ๋กœ
155
+ reordered_chunks = []
156
+ mentioned_chunks = [c for c in chunks if c["page_num"] == mentioned_page]
157
+ other_chunks = [c for c in chunks if c["page_num"] != mentioned_page]
158
+
159
+ reordered_chunks = mentioned_chunks + other_chunks
160
+ chunks = reordered_chunks
161
+
162
+ sources = []
163
+ seen_pages = set()
164
+
165
+ for chunk in chunks[:5]: # ์ƒ์œ„ 5๊ฐœ๋งŒ
166
+ page_num = chunk["page_num"]
167
+ if page_num not in seen_pages:
168
+ sources.append({
169
+ "page_num": page_num,
170
+ "text": chunk["text"][:200] + "...", # ๋ฏธ๋ฆฌ๋ณด๊ธฐ
171
+ "chunk_id": chunk["chunk_id"]
172
+ })
173
+ seen_pages.add(page_num)
174
+
175
+ return sources
176
+
177
+
178
+ if __name__ == "__main__":
179
+ # ํ…Œ์ŠคํŠธ
180
+ generator = Generator()
181
+
182
+ test_chunks = [
183
+ {
184
+ "chunk_id": "chunk_0",
185
+ "text": "์ด ํ”„๋กœ์ ํŠธ์˜ ์˜ˆ์‚ฐ์€ 1์–ต์›์ž…๋‹ˆ๋‹ค.",
186
+ "page_num": 1
187
+ }
188
+ ]
189
+
190
+ result = generator.generate_answer("์˜ˆ์‚ฐ์ด ์–ผ๋งˆ์ธ๊ฐ€์š”?", test_chunks)
191
+ print(f"๋‹ต๋ณ€: {result['answer']}")
192
+ print(f"์ถœ์ฒ˜: {len(result['sources'])}๊ฐœ")
193
+
core/pdf_loader.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/pdf_loader.py
2
+ """PDF ํ…์ŠคํŠธ ์ถ”์ถœ"""
3
+ import pymupdf4llm
4
+ import fitz # PyMuPDF
5
+ from typing import Dict, List
6
+
7
+
8
+ def load_pdf(pdf_path: str) -> Dict:
9
+ """
10
+ PDF ํŒŒ์ผ ๋กœ๋“œ ๋ฐ ํ…์ŠคํŠธ ์ถ”์ถœ
11
+
12
+ Args:
13
+ pdf_path: PDF ํŒŒ์ผ ๊ฒฝ๋กœ
14
+
15
+ Returns:
16
+ Dict: {
17
+ "text": "์ „์ฒด ํ…์ŠคํŠธ",
18
+ "pages": [
19
+ {"page_num": 1, "text": "..."},
20
+ ...
21
+ ]
22
+ }
23
+ """
24
+ print(f"๐Ÿ“„ PDF ๋กœ๋“œ ์ค‘: {pdf_path}")
25
+
26
+ # pymupdf4llm์œผ๋กœ ํ…์ŠคํŠธ ์ถ”์ถœ (markdown ํ˜•์‹)
27
+ md_text = pymupdf4llm.to_markdown(pdf_path)
28
+
29
+ # ํŽ˜์ด์ง€๋ณ„๋กœ ๋ถ„๋ฆฌ
30
+ doc = fitz.open(pdf_path)
31
+
32
+ pages = []
33
+ for page_num, page in enumerate(doc, start=1):
34
+ page_text = page.get_text()
35
+ pages.append({
36
+ "page_num": page_num,
37
+ "text": page_text
38
+ })
39
+
40
+ doc.close()
41
+
42
+ print(f"โœ… {len(pages)}ํŽ˜์ด์ง€ ์ถ”์ถœ ์™„๋ฃŒ")
43
+
44
+ return {
45
+ "text": md_text,
46
+ "pages": pages,
47
+ "total_pages": len(pages)
48
+ }
49
+
50
+
51
+ if __name__ == "__main__":
52
+ # ํ…Œ์ŠคํŠธ
53
+ import sys
54
+ if len(sys.argv) > 1:
55
+ result = load_pdf(sys.argv[1])
56
+ print(f"์ด ํŽ˜์ด์ง€: {result['total_pages']}")
57
+ print(f"์ฒซ ํŽ˜์ด์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ: {result['pages'][0]['text'][:200]}")
58
+
core/retriever.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/retriever.py
2
+ """๊ฒ€์ƒ‰ ๋กœ์ง - ๊ด€๋ จ๋„ ์ ์ˆ˜ ํฌํ•จ"""
3
+ from openai import OpenAI
4
+ from typing import List, Dict
5
+ from config.settings import OPENAI_API_KEY, EMBEDDING_MODEL, TOP_K
6
+
7
+ client = OpenAI(api_key=OPENAI_API_KEY)
8
+
9
+
10
+ class Retriever:
11
+ """RAG ๊ฒ€์ƒ‰ ํด๋ž˜์Šค (1D ์ „์šฉ ๋ฒ„์ „)"""
12
+
13
+ def __init__(self, vectordb):
14
+ self.vectordb = vectordb
15
+
16
+ def retrieve(self, query: str, top_k: int = TOP_K) -> List[Dict]:
17
+ """
18
+ ์งˆ๋ฌธ(query)์— ๊ฐ€์žฅ ์œ ์‚ฌํ•œ ์ฒญํฌ๋ฅผ ๋ฒกํ„ฐDB์—์„œ ๊ฒ€์ƒ‰ํ•˜์—ฌ
19
+ ๊ด€๋ จ๋„ ์ ์ˆ˜(relevance_score)๊นŒ์ง€ ๊ณ„์‚ฐํ•œ ํ›„ ๋ฐ˜ํ™˜.
20
+
21
+ **์ค‘์š”**: distance๊ฐ€ ์ž‘์„์ˆ˜๋ก ๊ด€๋ จ๋„๊ฐ€ ๋†’์Œ โ†’ ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ
22
+ """
23
+ print(f"๐Ÿ” ๊ฒ€์ƒ‰ ์ค‘: '{query[:50]}...'")
24
+
25
+ # --- 1. ์งˆ๋ฌธ ์ž„๋ฒ ๋”ฉ ์ƒ์„ฑ ---
26
+ embedding_response = client.embeddings.create(
27
+ model=EMBEDDING_MODEL,
28
+ input=[query]
29
+ )
30
+ query_embedding = embedding_response.data[0].embedding
31
+
32
+ # --- 2. ๋ฒกํ„ฐ ๊ฒ€์ƒ‰ (1D ๊ตฌ์กฐ) ---
33
+ results = self.vectordb.search(query_embedding, top_k)
34
+
35
+ ids = results["ids"]
36
+ docs = results["documents"]
37
+ metas = results["metadatas"]
38
+ dists = results["distances"]
39
+
40
+ chunks = []
41
+
42
+ # --- 3. ํฌ๋งทํŒ… + ๊ด€๋ จ๋„ ์ ์ˆ˜ ๊ณ„์‚ฐ ---
43
+ for i in range(len(ids)):
44
+ distance = dists[i] if i < len(dists) else 1.0
45
+ relevance_score = round(1.0 - distance, 4)
46
+
47
+ meta = metas[i] if isinstance(metas[i], dict) else {}
48
+
49
+ chunks.append({
50
+ "chunk_id": ids[i],
51
+ "text": docs[i],
52
+ "page_num": meta.get("page_num", 0),
53
+ "start_char": meta.get("start_char", 0),
54
+ "end_char": meta.get("end_char", 0),
55
+ "distance": distance,
56
+ "relevance_score": relevance_score
57
+ })
58
+
59
+ # --- 4. โœ… distance ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ (์ž‘์„์ˆ˜๋ก ๊ด€๋ จ๋„ ๋†’์Œ) ---
60
+ chunks.sort(key=lambda x: x["distance"])
61
+
62
+ # ๋””๋ฒ„๊ทธ: ์ƒ์œ„ 3๊ฐœ ์ถœ๋ ฅ
63
+ print(f"โœ… {len(chunks)}๊ฐœ ์ฒญํฌ ๊ฒ€์ƒ‰ ์™„๋ฃŒ")
64
+ if chunks:
65
+ print(f" ๐ŸŽฏ ์ตœ๊ณ  ๊ด€๋ จ๋„: p.{chunks[0]['page_num']} (distance: {chunks[0]['distance']:.4f})")
66
+
67
+ return chunks
core/vectordb.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/vectordb.py
2
+ """ChromaDB ๊ด€๋ฆฌ (ํ”„๋กœ๋•์…˜ ๋ฒ„์ „)"""
3
+ import chromadb
4
+ import uuid
5
+ from pathlib import Path
6
+ from typing import List, Dict
7
+ from config.settings import CHROMA_PATH
8
+
9
+
10
+ class VectorDB:
11
+ """ChromaDB ๊ด€๋ฆฌ ํด๋ž˜์Šค (ํ”„๋กœ๋•์…˜ ์•ˆ์ •ํ™”)"""
12
+
13
+ def __init__(self, persist_directory: str = CHROMA_PATH, session_id: str = None):
14
+ """
15
+ ChromaDB ์ดˆ๊ธฐํ™” (์„ธ์…˜๋ณ„ ์ปฌ๋ ‰์…˜)
16
+
17
+ Args:
18
+ persist_directory: ์ €์žฅ ๊ฒฝ๋กœ
19
+ session_id: ์„ธ์…˜ ID (์—†์œผ๋ฉด ์ž๋™ ์ƒ์„ฑ)
20
+ """
21
+ print(f"๐Ÿ—„๏ธ ChromaDB ์ดˆ๊ธฐํ™” ์ค‘...")
22
+
23
+ self.persist_directory = persist_directory
24
+
25
+ # ์„ธ์…˜ ID ๊ธฐ๋ฐ˜ ์ปฌ๋ ‰์…˜ ์ด๋ฆ„
26
+ if session_id is None:
27
+ session_id = str(uuid.uuid4())[:8]
28
+
29
+ self.collection_name = f"session_{session_id}"
30
+ print(f" ๐Ÿ“› ์ปฌ๋ ‰์…˜ ์ด๋ฆ„: {self.collection_name}")
31
+
32
+ # PersistentClient ์ƒ์„ฑ
33
+ self.client = chromadb.PersistentClient(path=persist_directory)
34
+
35
+ # โœ… get_or_create ์‚ฌ์šฉ (ํ”„๋กœ๋•์…˜ ํ‘œ์ค€)
36
+ self.collection = self.client.get_or_create_collection(
37
+ name=self.collection_name,
38
+ metadata={"description": "RFP ๋ฌธ์„œ ์ž„๋ฒ ๋”ฉ"}
39
+ )
40
+
41
+ print(f"โœ… ChromaDB ์ค€๋น„ ์™„๋ฃŒ (ํ˜„์žฌ {self.collection.count()}๊ฐœ ์ฒญํฌ)")
42
+
43
+ def add_chunks(self, chunks: List[Dict]):
44
+ """์ฒญํฌ ์ €์žฅ"""
45
+ print(f"๐Ÿ’พ {len(chunks)}๊ฐœ ์ฒญํฌ ์ €์žฅ ์ค‘...")
46
+
47
+ ids = [chunk["chunk_id"] for chunk in chunks]
48
+ embeddings = [chunk["embedding"] for chunk in chunks]
49
+ documents = [chunk["text"] for chunk in chunks]
50
+
51
+ metadatas = []
52
+ for chunk in chunks:
53
+ metadatas.append({
54
+ "page_num": chunk.get("page_num", 0),
55
+ "start_char": chunk.get("start_char", 0),
56
+ "end_char": chunk.get("end_char", 0)
57
+ })
58
+
59
+ self.collection.add(
60
+ ids=ids,
61
+ embeddings=embeddings,
62
+ documents=documents,
63
+ metadatas=metadatas
64
+ )
65
+
66
+ print("โœ… ์ €์žฅ ์™„๋ฃŒ")
67
+
68
+ def search(self, query_embedding: List[float], top_k: int = 10) -> Dict:
69
+ """๋ฒกํ„ฐ ๊ฒ€์ƒ‰"""
70
+ results = self.collection.query(
71
+ query_embeddings=[query_embedding],
72
+ n_results=top_k
73
+ )
74
+
75
+ return {
76
+ "ids": results["ids"][0],
77
+ "documents": results["documents"][0],
78
+ "metadatas": results["metadatas"][0],
79
+ "distances": results["distances"][0]
80
+ }
81
+
82
+ def delete_collection(self):
83
+ """ํ˜„์žฌ ์ปฌ๋ ‰์…˜ ์‚ญ์ œ"""
84
+ print(f"๐Ÿ—‘๏ธ ์ปฌ๋ ‰์…˜ ์‚ญ์ œ ์ค‘: {self.collection_name}")
85
+ try:
86
+ self.client.delete_collection(name=self.collection_name)
87
+ print(" โœ… ์‚ญ์ œ ์™„๋ฃŒ")
88
+ except Exception as e:
89
+ print(f" โš ๏ธ ์‚ญ์ œ ์˜ค๋ฅ˜: {e}")
90
+
91
+ def count(self) -> int:
92
+ """ํ˜„์žฌ ์ฒญํฌ ๊ฐœ์ˆ˜"""
93
+ return self.collection.count()
94
+
95
+ def get_stats(self) -> Dict:
96
+ """ํ†ต๊ณ„"""
97
+ return {
98
+ "total_chunks": self.collection.count(),
99
+ "collection_name": self.collection_name
100
+ }
ui/__init__.py ADDED
File without changes
ui/components.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ui/components.py
2
+ """PROBIN UI ์ปดํฌ๋„ŒํŠธ"""
3
+ import streamlit as st
4
+ from typing import List, Dict
5
+
6
+
7
+ def render_header():
8
+ """ํ—ค๋” ๋ Œ๋”๋ง (PROBIN ๋ฒ„์ „)"""
9
+ st.markdown(
10
+ """
11
+ <div style="text-align: center; padding: 2rem 0;">
12
+ <h1 style="color: #2196F3; font-size: 3rem;">๐Ÿง  PROBIN</h1>
13
+ <p style="font-size: 1.2rem; color: #666;">Intelligent Document Analysis System</p>
14
+ <p style="font-size: 0.9rem; color: #999;">์ •ํ™•๋„ ์šฐ์„  RAG ์‹œ์Šคํ…œ</p>
15
+ </div>
16
+ """,
17
+ unsafe_allow_html=True
18
+ )
19
+
20
+
21
+ def render_sources_with_relevance(sources: List[Dict], message_idx: int, move_to_page_callback):
22
+ """
23
+ ์ถœ์ฒ˜ ๋ Œ๋”๋ง (๊ฐ€์žฅ ๊ด€๋ จ๋„ ๋†’์€ ๊ฒƒ ์šฐ์„ )
24
+
25
+ Args:
26
+ sources: ์ถœ์ฒ˜ ๋ฆฌ์ŠคํŠธ (์ด๋ฏธ ๊ด€๋ จ๋„ ์ˆœ์œผ๋กœ ์ •๋ ฌ๋จ)
27
+ message_idx: ๋ฉ”์‹œ์ง€ ์ธ๋ฑ์Šค (ํ‚ค ์ค‘๋ณต ๋ฐฉ์ง€์šฉ)
28
+ move_to_page_callback: ํŽ˜์ด์ง€ ์ด๋™ ์ฝœ๋ฐฑ ํ•จ์ˆ˜
29
+
30
+ ๊ตฌ์กฐ:
31
+ ๐ŸŽฏ ํ•ต์‹ฌ ๊ทผ๊ฑฐ (1๊ฐœ) - sources[0]: ๊ฐ€์žฅ ๊ด€๋ จ๋„ ๋†’์€ ์ถœ์ฒ˜
32
+ ๐Ÿ“š ์ถ”๊ฐ€ ์ฐธ๊ณ  ๋ฌธ์„œ (๋‚˜๋จธ์ง€) - Expander ๋‚ด๋ถ€
33
+ """
34
+ if not sources:
35
+ return
36
+
37
+ # ์ฒซ ๋ฒˆ์งธ ์ถœ์ฒ˜ = ๊ฐ€์žฅ ๊ด€๋ จ๋„ ๋†’์Œ
38
+ st.markdown("---")
39
+ st.markdown("**๐ŸŽฏ ํ•ต์‹ฌ ๊ทผ๊ฑฐ:**")
40
+
41
+ primary_source = sources[0]
42
+
43
+ if st.button(
44
+ f"๐Ÿ“ ํŽ˜์ด์ง€ {primary_source['page_num']} ํ™•์ธ",
45
+ key=f"primary_{message_idx}",
46
+ type="primary",
47
+ use_container_width=True
48
+ ):
49
+ move_to_page_callback(primary_source['page_num'], primary_source['text'])
50
+
51
+ # ๋ฏธ๋ฆฌ๋ณด๊ธฐ
52
+ st.caption(f"๐Ÿ’ฌ \"{primary_source['text'][:100]}...\"")
53
+
54
+ # ์ถ”๊ฐ€ ์ฐธ๊ณ  ๋ฌธ์„œ (๋‚˜๋จธ์ง€)
55
+ if len(sources) > 1:
56
+ additional_sources = sources[1:]
57
+
58
+ with st.expander(f"๐Ÿ“š ์ถ”๊ฐ€ ์ฐธ๊ณ  ๋ฌธ์„œ ({min(2, len(additional_sources))}๊ฐœ)"):
59
+ cols = st.columns(2)
60
+
61
+ for i, src in enumerate(additional_sources[:2]): # ์ตœ๋Œ€ 2๊ฐœ๋งŒ
62
+ with cols[i]:
63
+ if st.button(
64
+ f"p.{src['page_num']}",
65
+ key=f"additional_{message_idx}_{i}",
66
+ use_container_width=True
67
+ ):
68
+ move_to_page_callback(src['page_num'], src['text'])
69
+
70
+ st.caption(f"\"{src['text'][:60]}...\"")
71
+
72
+
73
+
74
+ def render_answer(answer: str, sources: List[Dict]):
75
+ """๋‹ต๋ณ€ ๋ฐ ์ถœ์ฒ˜ ๋ Œ๋”๋ง (Legacy - ์‚ฌ์šฉ ์•ˆ ํ•จ)"""
76
+ # ๋‹ต๋ณ€
77
+ st.markdown("### ๐Ÿ’ก ๋‹ต๋ณ€")
78
+ st.markdown(
79
+ f"""
80
+ <div class="answer-box">
81
+ {answer}
82
+ </div>
83
+ """,
84
+ unsafe_allow_html=True
85
+ )
86
+
87
+ # ์ถœ์ฒ˜
88
+ if sources:
89
+ st.markdown("### ๐Ÿ“„ ์ถœ์ฒ˜")
90
+ for i, source in enumerate(sources, 1):
91
+ with st.expander(f"์ถœ์ฒ˜ {i} - ํŽ˜์ด์ง€ {source['page_num']}"):
92
+ st.text(source['text'])
93
+
94
+
95
+ def render_file_uploader():
96
+ """ํŒŒ์ผ ์—…๋กœ๋” ๋ Œ๋”๋ง"""
97
+ st.markdown("### ๐Ÿ“ค PDF ์—…๋กœ๋“œ")
98
+
99
+ uploaded_file = st.file_uploader(
100
+ "RFP PDF ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜์„ธ์š”",
101
+ type=["pdf"],
102
+ help="PDF ํ˜•์‹์˜ RFP ๋ฌธ์„œ๋ฅผ ์—…๋กœ๋“œํ•˜์„ธ์š”"
103
+ )
104
+
105
+ return uploaded_file
106
+
107
+
108
+ def render_query_input():
109
+ """์งˆ๋ฌธ ์ž…๋ ฅ ๋ Œ๋”๋ง (Deprecated: st.chat_input ์‚ฌ์šฉ ๊ถŒ์žฅ)"""
110
+ st.markdown("### ๐Ÿ’ฌ ์งˆ๋ฌธํ•˜๊ธฐ")
111
+
112
+ query = st.text_input(
113
+ "์งˆ๋ฌธ์„ ์ž…๋ ฅํ•˜์„ธ์š”",
114
+ placeholder="์˜ˆ: ์ด ํ”„๋กœ์ ํŠธ์˜ ์˜ˆ์‚ฐ์€ ์–ผ๋งˆ์ธ๊ฐ€์š”?",
115
+ help="RFP ๋ฌธ์„œ์— ๋Œ€ํ•ด ์งˆ๋ฌธํ•˜์„ธ์š”"
116
+ )
117
+
118
+ return query
119
+
120
+
121
+ def render_chat_history(messages: list):
122
+ """์ฑ„ํŒ… ํžˆ์Šคํ† ๋ฆฌ ๋ Œ๋”๋ง (ํ˜„๋Œ€์  ๋ฐฉ์‹)"""
123
+ for message in messages:
124
+ with st.chat_message(message["role"]):
125
+ st.markdown(message["content"])
126
+
127
+ # ์ถœ์ฒ˜ ํ‘œ์‹œ
128
+ if message["role"] == "assistant" and "sources" in message:
129
+ with st.expander("๐Ÿ“š ์ถœ์ฒ˜ ๋ณด๊ธฐ"):
130
+ for i, src in enumerate(message["sources"], 1):
131
+ st.markdown(f"**์ถœ์ฒ˜ {i} - [ํŽ˜์ด์ง€ {src['page_num']}]**")
132
+ st.caption(src["text"])
133
+
134
+
135
+ def render_sidebar():
136
+ """์‚ฌ์ด๋“œ๋ฐ” ๋ Œ๋”๋ง"""
137
+ with st.sidebar:
138
+ st.markdown("## โš™๏ธ ์„ค์ •")
139
+
140
+ # ๊ฒ€์ƒ‰ ์„ค์ •
141
+ st.markdown("### ๐Ÿ” ๊ฒ€์ƒ‰ ์„ค์ •")
142
+ top_k = st.slider("๊ฒ€์ƒ‰ํ•  ์ฒญํฌ ์ˆ˜", 5, 20, 10)
143
+
144
+ # ์ฒญํ‚น ์„ค์ •
145
+ st.markdown("### โœ‚๏ธ ์ฒญํ‚น ์„ค์ •")
146
+ chunk_size = st.number_input("์ฒญํฌ ํฌ๊ธฐ", 400, 1200, 800, step=100)
147
+ chunk_overlap = st.number_input("์˜ค๋ฒ„๋žฉ ํฌ๊ธฐ", 50, 300, 150, step=50)
148
+
149
+ st.markdown("---")
150
+
151
+ # ์ •๋ณด
152
+ st.markdown("### โ„น๏ธ ์ •๋ณด")
153
+ st.info(
154
+ """
155
+ **PROBIN v2.0**
156
+
157
+ - PDF ์—…๋กœ๋“œ โœ…
158
+ - ์งˆ๋ฌธ-๋‹ต๋ณ€ โœ…
159
+ - ์ถœ์ฒ˜ ํ‘œ์‹œ โœ…
160
+ - ๋ฒกํ„ฐ ๊ฒ€์ƒ‰ โœ…
161
+ - ํ•˜์ด๋ผ์ดํŠธ โœ…
162
+ """
163
+ )
164
+
165
+ # ์ดˆ๊ธฐํ™” ๋ฒ„ํŠผ
166
+ if st.button("๐Ÿ—‘๏ธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™”", type="secondary"):
167
+ return True, top_k, chunk_size, chunk_overlap
168
+
169
+ return False, top_k, chunk_size, chunk_overlap
170
+
171
+
172
+ def render_processing_status(status: str):
173
+ """์ฒ˜๋ฆฌ ์ƒํƒœ ๋ Œ๋”๋ง"""
174
+ status_icons = {
175
+ "uploading": "๐Ÿ“ค",
176
+ "extracting": "๐Ÿ“„",
177
+ "chunking": "โœ‚๏ธ",
178
+ "embedding": "๐Ÿ”ข",
179
+ "storing": "๐Ÿ’พ",
180
+ "complete": "โœ…"
181
+ }
182
+
183
+ icon = status_icons.get(status, "โณ")
184
+ st.info(f"{icon} {status}")
185
+
186
+
187
+ def render_welcome_message():
188
+ """์›ฐ์ปด ๋ฉ”์‹œ์ง€ (์‚ฌ์šฉ ์•ˆ๋‚ด)"""
189
+ st.markdown("""
190
+ <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 12px; margin-bottom: 20px; text-align: center;">
191
+ <h2 style="margin: 0 0 10px 0;">๐Ÿง  PROBIN์— ์˜ค์‹  ๊ฒƒ์„ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค!</h2>
192
+ <p style="margin: 0; opacity: 0.9;">Intelligent Document Analysis System</p>
193
+ </div>
194
+
195
+ <div style="background-color: #f0f7ff; border-left: 4px solid #2196F3; padding: 15px; border-radius: 8px; margin-bottom: 15px;">
196
+ <h4 style="margin-top: 0; color: #1976D2;">๐Ÿ“– ์ด์šฉ ๋ฐฉ๋ฒ•</h4>
197
+ <ol style="line-height: 1.8; margin: 0;">
198
+ <li><strong>PDF ์—…๋กœ๋“œ:</strong> ์™ผ์ชฝ ์‚ฌ์ด๋“œ๋ฐ”์—์„œ ๋ฌธ์„œ๋ฅผ ์—…๋กœ๋“œํ•˜์„ธ์š”</li>
199
+ <li><strong>๋ฌธ์„œ ์ฒ˜๋ฆฌ:</strong> 30์ดˆ~1๋ถ„ ์ •๋„ ๊ธฐ๋‹ค๋ฆฝ๋‹ˆ๋‹ค</li>
200
+ <li><strong>์งˆ๋ฌธ ์ž…๋ ฅ:</strong> ์ฑ„ํŒ…์ฐฝ์— ์งˆ๋ฌธ์„ ์ž…๋ ฅํ•˜์„ธ์š”</li>
201
+ <li><strong>๊ทผ๊ฑฐ ํ™•์ธ:</strong> <span style="background-color: rgba(255,255,0,0.5); padding: 2px 6px; border-radius: 3px;">๋…ธ๋ž€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ</span>๋กœ ๊ทผ๊ฑฐ๋ฅผ ํ™•์ธํ•˜์„ธ์š”</li>
202
+ </ol>
203
+ </div>
204
+
205
+ <div style="text-align: center; margin-top: 20px;">
206
+ <span style="display: inline-block; margin: 5px; padding: 8px 16px; background-color: #e8f5e9; border-radius: 20px; font-size: 0.9rem;">๐Ÿ“บ Split View</span>
207
+ <span style="display: inline-block; margin: 5px; padding: 8px 16px; background-color: #fff3e0; border-radius: 20px; font-size: 0.9rem;">๐Ÿ–๏ธ Highlighting</span>
208
+ <span style="display: inline-block; margin: 5px; padding: 8px 16px; background-color: #f3e5f5; border-radius: 20px; font-size: 0.9rem;">๐ŸŽฏ High Accuracy</span>
209
+ </div>
210
+ """, unsafe_allow_html=True)
ui/styles.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ui/styles.py
2
+ """UI ์Šคํƒ€์ผ ์ •์˜ - Claude ์Šคํƒ€์ผ ๋‹คํฌ ๋ฐฐ๊ฒฝ"""
3
+
4
+ def get_custom_css():
5
+ """์ปค์Šคํ…€ CSS ๋ฐ˜ํ™˜"""
6
+ return """
7
+ <style>
8
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
9
+ ๐ŸŒŠ ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋ฐฐ๊ฒฝ (Pure CSS)
10
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
11
+ .animated-background {
12
+ position: fixed;
13
+ width: 100%;
14
+ height: 100%;
15
+ top: 0;
16
+ left: 0;
17
+ z-index: -1;
18
+ background: linear-gradient(
19
+ 45deg,
20
+ #667eea 0%,
21
+ #764ba2 25%,
22
+ #f093fb 50%,
23
+ #4facfe 75%,
24
+ #667eea 100%
25
+ );
26
+ background-size: 400% 400%;
27
+ animation: gradientFlow 15s ease infinite;
28
+ }
29
+
30
+ /* ๋ฐฐ๊ฒฝ ์œ„์— ๋– ๋‹ค๋‹ˆ๋Š” ์›๋“ค */
31
+ .animated-background::before,
32
+ .animated-background::after {
33
+ content: '';
34
+ position: absolute;
35
+ border-radius: 50%;
36
+ filter: blur(80px);
37
+ opacity: 0.2;
38
+ animation: float 25s ease-in-out infinite;
39
+ }
40
+
41
+ .animated-background::before {
42
+ width: 600px;
43
+ height: 600px;
44
+ background: radial-gradient(circle, rgba(255,255,255,0.2), transparent);
45
+ top: 10%;
46
+ left: 20%;
47
+ animation-delay: 0s;
48
+ }
49
+
50
+ .animated-background::after {
51
+ width: 500px;
52
+ height: 500px;
53
+ background: radial-gradient(circle, rgba(255,255,255,0.15), transparent);
54
+ bottom: 15%;
55
+ right: 10%;
56
+ animation-delay: 7s;
57
+ }
58
+
59
+ @keyframes gradientFlow {
60
+ 0% { background-position: 0% 50%; }
61
+ 50% { background-position: 100% 50%; }
62
+ 100% { background-position: 0% 50%; }
63
+ }
64
+
65
+ @keyframes float {
66
+ 0%, 100% { transform: translate(0, 0) scale(1); }
67
+ 25% { transform: translate(40px, -40px) scale(1.1); }
68
+ 50% { transform: translate(-30px, 30px) scale(0.9); }
69
+ 75% { transform: translate(50px, 15px) scale(1.05); }
70
+ }
71
+
72
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
73
+ ๐Ÿ  Hero Container (์ดˆ๊ธฐ ํ™”๋ฉด)
74
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
75
+ .hero-container {
76
+ position: relative;
77
+ text-align: center;
78
+ padding: 10rem 2rem;
79
+ min-height: 80vh;
80
+ display: flex;
81
+ flex-direction: column;
82
+ justify-content: center;
83
+ align-items: center;
84
+ background: transparent;
85
+ z-index: 1;
86
+ }
87
+
88
+ .hero-title {
89
+ font-size: 5rem;
90
+ font-weight: 900;
91
+ color: white;
92
+ margin-bottom: 1.5rem;
93
+ text-shadow:
94
+ 0 0 30px rgba(255,255,255,0.6),
95
+ 0 0 50px rgba(102,126,234,0.4),
96
+ 3px 3px 40px rgba(0,0,0,0.4);
97
+ animation: titlePulse 4s ease-in-out infinite;
98
+ letter-spacing: 4px;
99
+ }
100
+
101
+ @keyframes titlePulse {
102
+ 0%, 100% {
103
+ transform: scale(1);
104
+ text-shadow:
105
+ 0 0 30px rgba(255,255,255,0.6),
106
+ 0 0 50px rgba(102,126,234,0.4);
107
+ }
108
+ 50% {
109
+ transform: scale(1.03);
110
+ text-shadow:
111
+ 0 0 40px rgba(255,255,255,0.8),
112
+ 0 0 70px rgba(102,126,234,0.6),
113
+ 0 0 100px rgba(118,75,162,0.4);
114
+ }
115
+ }
116
+
117
+ .hero-subtitle {
118
+ font-size: 1.9rem;
119
+ color: rgba(255,255,255,0.95);
120
+ font-weight: 300;
121
+ letter-spacing: 4px;
122
+ text-shadow:
123
+ 0 0 15px rgba(255,255,255,0.4),
124
+ 2px 2px 25px rgba(0,0,0,0.3);
125
+ animation: subtitleFade 2.5s ease-out;
126
+ }
127
+
128
+ @keyframes subtitleFade {
129
+ from {
130
+ opacity: 0;
131
+ transform: translateY(30px);
132
+ }
133
+ to {
134
+ opacity: 1;
135
+ transform: translateY(0);
136
+ }
137
+ }
138
+
139
+ /* ๋ชจ๋ฐ”์ผ ๋ฐ˜์‘ํ˜• */
140
+ @media (max-width: 768px) {
141
+ .hero-title {
142
+ font-size: 2.5rem;
143
+ letter-spacing: 2px;
144
+ }
145
+ .hero-subtitle {
146
+ font-size: 1.1rem;
147
+ letter-spacing: 2px;
148
+ }
149
+ .hero-container {
150
+ padding: 5rem 1rem;
151
+ }
152
+ }
153
+
154
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
155
+ ๐Ÿ’ฌ Chat Placeholder (๊ฐ€์ด๋“œ ํ™”๋ฉด) - ๋‹คํฌ ๋ชจ๋“œ
156
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
157
+ .chat-placeholder {
158
+ background: linear-gradient(135deg,
159
+ rgba(30,30,35,0.95) 0%,
160
+ rgba(25,25,30,0.95) 100%);
161
+ border-radius: 20px;
162
+ padding: 3rem;
163
+ text-align: left;
164
+ box-shadow:
165
+ 0 15px 50px rgba(0,0,0,0.4),
166
+ 0 0 0 1px rgba(102,126,234,0.2) inset;
167
+ animation: slideIn 0.7s ease-out;
168
+ backdrop-filter: blur(15px);
169
+ border: 1px solid rgba(102,126,234,0.2);
170
+ }
171
+
172
+ @keyframes slideIn {
173
+ from {
174
+ opacity: 0;
175
+ transform: translateY(40px);
176
+ }
177
+ to {
178
+ opacity: 1;
179
+ transform: translateY(0);
180
+ }
181
+ }
182
+
183
+ .placeholder-title {
184
+ font-size: 2rem;
185
+ font-weight: 900;
186
+ background: linear-gradient(135deg, #667eea, #f093fb);
187
+ -webkit-background-clip: text;
188
+ -webkit-text-fill-color: transparent;
189
+ background-clip: text;
190
+ margin-bottom: 2.5rem;
191
+ text-align: center;
192
+ }
193
+
194
+ .placeholder-steps {
195
+ margin: 2.5rem 0;
196
+ padding-left: 0;
197
+ line-height: 2.5;
198
+ color: rgba(255,255,255,0.9);
199
+ font-size: 1.1rem;
200
+ list-style: none;
201
+ }
202
+
203
+ .placeholder-steps li {
204
+ margin-bottom: 1.5rem;
205
+ padding-left: 3rem;
206
+ position: relative;
207
+ }
208
+
209
+ .placeholder-steps li::before {
210
+ content: 'โœ“';
211
+ position: absolute;
212
+ left: 0;
213
+ top: 0;
214
+ width: 35px;
215
+ height: 35px;
216
+ background: linear-gradient(135deg, #667eea, #764ba2);
217
+ color: white;
218
+ border-radius: 50%;
219
+ display: flex;
220
+ align-items: center;
221
+ justify-content: center;
222
+ font-weight: bold;
223
+ font-size: 1.3rem;
224
+ box-shadow: 0 4px 15px rgba(102,126,234,0.4);
225
+ }
226
+
227
+ .placeholder-steps strong {
228
+ color: #7c92ff;
229
+ font-weight: 800;
230
+ }
231
+
232
+ .highlight-box {
233
+ background: linear-gradient(135deg,
234
+ rgba(255, 215, 0, 0.4),
235
+ rgba(255, 193, 7, 0.4));
236
+ padding: 4px 12px;
237
+ border-radius: 6px;
238
+ font-weight: 800;
239
+ color: #fff;
240
+ box-shadow: 0 2px 10px rgba(255, 215, 0, 0.3);
241
+ }
242
+
243
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
244
+ ๐Ÿ”„ ๋กœ๋”ฉ ๋ฉ”์‹œ์ง€
245
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
246
+ .loading-message {
247
+ background: linear-gradient(135deg,
248
+ rgba(30,30,35,0.95) 0%,
249
+ rgba(25,25,30,0.95) 100%);
250
+ border-radius: 20px;
251
+ padding: 2rem;
252
+ text-align: center;
253
+ box-shadow:
254
+ 0 10px 40px rgba(0,0,0,0.4),
255
+ 0 0 0 1px rgba(102,126,234,0.2) inset;
256
+ animation: pulse 2s ease-in-out infinite;
257
+ backdrop-filter: blur(15px);
258
+ border: 1px solid rgba(102,126,234,0.2);
259
+ }
260
+
261
+ @keyframes pulse {
262
+ 0%, 100% {
263
+ opacity: 1;
264
+ transform: scale(1);
265
+ }
266
+ 50% {
267
+ opacity: 0.8;
268
+ transform: scale(1.02);
269
+ }
270
+ }
271
+
272
+ .loading-message-text {
273
+ font-size: 1.3rem;
274
+ font-weight: 600;
275
+ background: linear-gradient(135deg, #667eea, #f093fb);
276
+ -webkit-background-clip: text;
277
+ -webkit-text-fill-color: transparent;
278
+ background-clip: text;
279
+ margin-bottom: 1rem;
280
+ }
281
+
282
+ .loading-dots {
283
+ display: inline-block;
284
+ animation: dots 1.5s steps(4, end) infinite;
285
+ }
286
+
287
+ @keyframes dots {
288
+ 0%, 20% { content: '.'; }
289
+ 40% { content: '..'; }
290
+ 60%, 100% { content: '...'; }
291
+ }
292
+
293
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
294
+ ๐Ÿ“š ์ถœ์ฒ˜ ๋ฒ„ํŠผ ์Šคํƒ€์ผ
295
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
296
+ .stButton button {
297
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
298
+ border-radius: 10px;
299
+ }
300
+
301
+ .stButton button:hover {
302
+ transform: translateY(-3px);
303
+ box-shadow: 0 6px 20px rgba(0,0,0,0.2);
304
+ }
305
+
306
+ .stButton button:active {
307
+ transform: translateY(-1px);
308
+ box-shadow: 0 3px 10px rgba(0,0,0,0.15);
309
+ }
310
+
311
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
312
+ ๐ŸŽจ ์‚ฌ์ด๋“œ๋ฐ” ์Šคํƒ€์ผ
313
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
314
+ [data-testid="stSidebar"] {
315
+ background: linear-gradient(180deg,
316
+ #667eea 0%,
317
+ #764ba2 100%);
318
+ box-shadow: 4px 0 30px rgba(0,0,0,0.2);
319
+ }
320
+
321
+ [data-testid="stSidebar"] h1 {
322
+ color: white !important;
323
+ text-shadow: 2px 2px 15px rgba(0,0,0,0.4);
324
+ }
325
+
326
+ [data-testid="stSidebar"] .stButton button {
327
+ background: rgba(255,255,255,0.2);
328
+ color: white;
329
+ border: 2px solid rgba(255,255,255,0.3);
330
+ font-weight: 700;
331
+ backdrop-filter: blur(10px);
332
+ }
333
+
334
+ [data-testid="stSidebar"] .stButton button:hover {
335
+ background: rgba(255,255,255,0.3);
336
+ border-color: rgba(255,255,255,0.5);
337
+ transform: translateY(-2px);
338
+ }
339
+
340
+ /* ํŒŒ์ผ ์—…๋กœ๋” ์Šคํƒ€์ผ */
341
+ [data-testid="stSidebar"] [data-testid="stFileUploader"] {
342
+ background: rgba(255,255,255,0.15);
343
+ border-radius: 15px;
344
+ padding: 1.5rem;
345
+ border: 3px dashed rgba(255,255,255,0.4);
346
+ transition: all 0.3s ease;
347
+ backdrop-filter: blur(10px);
348
+ }
349
+
350
+ [data-testid="stSidebar"] [data-testid="stFileUploader"]:hover {
351
+ background: rgba(255,255,255,0.25);
352
+ border-color: rgba(255,255,255,0.6);
353
+ transform: scale(1.02);
354
+ }
355
+
356
+ [data-testid="stSidebar"] [data-testid="stFileUploader"] label {
357
+ color: white !important;
358
+ font-weight: 600;
359
+ }
360
+
361
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
362
+ ๐Ÿ“„ PDF ํ•˜์ด๋ผ์ดํŠธ (๋งค์šฐ ์ง„ํ•œ ๊ณจ๋“œ)
363
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
364
+ .highlight-text {
365
+ background: linear-gradient(135deg,
366
+ rgba(255, 193, 7, 0.95),
367
+ rgba(255, 152, 0, 0.95)) !important;
368
+ padding: 4px 8px;
369
+ border-radius: 5px;
370
+ font-weight: 800;
371
+ color: #1a1a1a !important;
372
+ box-shadow:
373
+ 0 0 15px rgba(255, 193, 7, 0.7),
374
+ 0 3px 8px rgba(0,0,0,0.3);
375
+ animation: highlightPulse 2.5s ease-in-out infinite;
376
+ }
377
+
378
+ @keyframes highlightPulse {
379
+ 0%, 100% {
380
+ box-shadow:
381
+ 0 0 15px rgba(255, 193, 7, 0.7),
382
+ 0 3px 8px rgba(0,0,0,0.3);
383
+ }
384
+ 50% {
385
+ box-shadow:
386
+ 0 0 25px rgba(255, 193, 7, 0.9),
387
+ 0 5px 15px rgba(0,0,0,0.4);
388
+ }
389
+ }
390
+
391
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
392
+ ๐Ÿ’ฌ ์ฑ„ํŒ… ๋ฉ”์‹œ์ง€ ์Šคํƒ€์ผ - ๋‹คํฌ ๋ชจ๋“œ
393
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
394
+ [data-testid="stChatMessage"] {
395
+ border-radius: 15px;
396
+ padding: 1.5rem;
397
+ margin-bottom: 1.2rem;
398
+ animation: messageSlide 0.4s ease-out;
399
+ background: rgba(30,30,35,0.6) !important;
400
+ border: 1px solid rgba(102,126,234,0.15);
401
+ }
402
+
403
+ @keyframes messageSlide {
404
+ from {
405
+ opacity: 0;
406
+ transform: translateX(-30px);
407
+ }
408
+ to {
409
+ opacity: 1;
410
+ transform: translateX(0);
411
+ }
412
+ }
413
+
414
+ /* ์‚ฌ์šฉ์ž ๋ฉ”์‹œ์ง€ */
415
+ [data-testid="stChatMessage"][data-testid*="user"] {
416
+ background: rgba(102,126,234,0.15) !important;
417
+ }
418
+
419
+ /* AI ๋ฉ”์‹œ์ง€ */
420
+ [data-testid="stChatMessage"][data-testid*="assistant"] {
421
+ background: rgba(30,30,35,0.8) !important;
422
+ }
423
+
424
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
425
+ ๐Ÿ”„ ์Šคํฌ๋กค๋ฐ” ์Šคํƒ€์ผ
426
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
427
+ ::-webkit-scrollbar {
428
+ width: 12px;
429
+ height: 12px;
430
+ }
431
+
432
+ ::-webkit-scrollbar-track {
433
+ background: rgba(0,0,0,0.2);
434
+ border-radius: 10px;
435
+ }
436
+
437
+ ::-webkit-scrollbar-thumb {
438
+ background: linear-gradient(135deg, #667eea, #764ba2);
439
+ border-radius: 10px;
440
+ border: 2px solid rgba(0,0,0,0.2);
441
+ }
442
+
443
+ ::-webkit-scrollbar-thumb:hover {
444
+ background: linear-gradient(135deg, #764ba2, #f093fb);
445
+ }
446
+
447
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
448
+ ๐ŸŽฏ ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ ์ปค์Šคํ…€
449
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
450
+ .stSpinner > div {
451
+ border-top-color: #667eea !important;
452
+ border-right-color: #764ba2 !important;
453
+ }
454
+
455
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
456
+ โœจ ๋ฉ”์ธ ์ปจํ…์ธ  ์˜์—ญ - ๋‹คํฌ ๋ฐฐ๊ฒฝ
457
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
458
+ .main .block-container {
459
+ padding-top: 1rem !important;
460
+ padding-bottom: 1rem !important;
461
+ background: transparent;
462
+ max-width: 100% !important;
463
+ }
464
+
465
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
466
+ ๐ŸŽฏ Streamlit ๊ธฐ๋ณธ ์ปจํ…Œ์ด๋„ˆ ํ…Œ๋‘๋ฆฌ ์ œ๊ฑฐ
467
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”๏ฟฝ๏ฟฝโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
468
+ [data-testid="stVerticalBlock"],
469
+ [data-testid="stHorizontalBlock"],
470
+ [data-testid="column"] {
471
+ border: none !important;
472
+ box-shadow: none !important;
473
+ background: transparent !important;
474
+ padding: 0 !important;
475
+ }
476
+
477
+ /* PDF ๋ทฐ์–ด - ๊น”๋”ํ•œ ๋””์ž์ธ */
478
+ [data-testid="stVerticalBlock"] iframe {
479
+ border-radius: 12px !important;
480
+ box-shadow: 0 8px 30px rgba(0,0,0,0.3) !important;
481
+ border: 1px solid rgba(102,126,234,0.2) !important;
482
+ }
483
+
484
+ /* ์ฑ„ํŒ… ์˜์—ญ - ํ…Œ๋‘๋ฆฌ ์—†์ด ๊น”๋”ํ•˜๊ฒŒ */
485
+ [data-testid="column"]:last-child {
486
+ background: transparent !important;
487
+ border: none !important;
488
+ padding: 0.5rem !important;
489
+ }
490
+
491
+ /* โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
492
+ ๐Ÿ“ ํŒŒ์ผ ์—…๋กœ๋” ๋ฒ„ํŠผ ํ…์ŠคํŠธ ๋ณ€๊ฒฝ
493
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” */
494
+ [data-testid="stFileUploader"] button[kind="secondary"] {
495
+ font-size: 0 !important;
496
+ }
497
+
498
+ [data-testid="stFileUploader"] button[kind="secondary"]::after {
499
+ content: "ํŒŒ์ผ ์ฐพ๊ธฐ";
500
+ font-size: 1rem;
501
+ font-weight: 600;
502
+ }
503
+
504
+
505
+ </style>
506
+
507
+ <!-- ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋ฐฐ๊ฒฝ -->
508
+ <div class="animated-background"></div>
509
+ """
utils/__init__.py ADDED
File without changes
utils/export_utils.py ADDED
File without changes
utils/helpers.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # utils/helpers.py
2
+ """์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋“ค"""
3
+ import os
4
+ from typing import Optional
5
+
6
+
7
+ def ensure_dir(directory: str):
8
+ """
9
+ ๋””๋ ‰ํ† ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ƒ์„ฑ
10
+
11
+ Args:
12
+ directory: ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ
13
+ """
14
+ if not os.path.exists(directory):
15
+ os.makedirs(directory)
16
+ print(f"๐Ÿ“ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ: {directory}")
17
+
18
+
19
+ def format_file_size(size_bytes: int) -> str:
20
+ """
21
+ ํŒŒ์ผ ํฌ๊ธฐ๋ฅผ ์ฝ๊ธฐ ์‰ฌ์šด ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜
22
+
23
+ Args:
24
+ size_bytes: ๋ฐ”์ดํŠธ ๋‹จ์œ„ ํฌ๊ธฐ
25
+
26
+ Returns:
27
+ str: ํฌ๋งท๋œ ํฌ๊ธฐ (์˜ˆ: "1.5 MB")
28
+ """
29
+ for unit in ['B', 'KB', 'MB', 'GB']:
30
+ if size_bytes < 1024.0:
31
+ return f"{size_bytes:.1f} {unit}"
32
+ size_bytes /= 1024.0
33
+ return f"{size_bytes:.1f} TB"
34
+
35
+
36
+ def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
37
+ """
38
+ ํ…์ŠคํŠธ๋ฅผ ์ง€์ •๋œ ๊ธธ์ด๋กœ ์ž๋ฅด๊ธฐ
39
+
40
+ Args:
41
+ text: ์›๋ณธ ํ…์ŠคํŠธ
42
+ max_length: ์ตœ๋Œ€ ๊ธธ์ด
43
+ suffix: ์ž˜๋ฆฐ ๊ฒฝ์šฐ ์ถ”๊ฐ€ํ•  ์ ‘๋ฏธ์‚ฌ
44
+
45
+ Returns:
46
+ str: ์ž˜๋ฆฐ ํ…์ŠคํŠธ
47
+ """
48
+ if len(text) <= max_length:
49
+ return text
50
+ return text[:max_length - len(suffix)] + suffix
51
+
52
+
53
+ def safe_get(dictionary: dict, key: str, default: Optional[any] = None):
54
+ """
55
+ ์•ˆ์ „ํ•˜๊ฒŒ ๋”•์…”๋„ˆ๋ฆฌ ๊ฐ’ ๊ฐ€์ ธ์˜ค๊ธฐ
56
+
57
+ Args:
58
+ dictionary: ๋”•์…”๋„ˆ๋ฆฌ
59
+ key: ํ‚ค
60
+ default: ๊ธฐ๋ณธ๊ฐ’
61
+
62
+ Returns:
63
+ ๊ฐ’ ๋˜๋Š” ๊ธฐ๋ณธ๊ฐ’
64
+ """
65
+ try:
66
+ return dictionary.get(key, default)
67
+ except:
68
+ return default
69
+
utils/logger.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # utils/logger.py
2
+ """๋กœ๊น… ์œ ํ‹ธ๋ฆฌํ‹ฐ"""
3
+ import logging
4
+ from datetime import datetime
5
+
6
+
7
+ def setup_logger(name: str = "TEAM_EA", level: int = logging.INFO) -> logging.Logger:
8
+ """
9
+ ๋กœ๊ฑฐ ์„ค์ •
10
+
11
+ Args:
12
+ name: ๋กœ๊ฑฐ ์ด๋ฆ„
13
+ level: ๋กœ๊น… ๋ ˆ๋ฒจ
14
+
15
+ Returns:
16
+ logging.Logger: ์„ค์ •๋œ ๋กœ๊ฑฐ
17
+ """
18
+ logger = logging.getLogger(name)
19
+ logger.setLevel(level)
20
+
21
+ # ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ์ด๋ฏธ ์žˆ์œผ๋ฉด ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์Œ
22
+ if not logger.handlers:
23
+ # ์ฝ˜์†” ํ•ธ๋“ค๋Ÿฌ
24
+ console_handler = logging.StreamHandler()
25
+ console_handler.setLevel(level)
26
+
27
+ # ํฌ๋งทํ„ฐ
28
+ formatter = logging.Formatter(
29
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
30
+ datefmt='%Y-%m-%d %H:%M:%S'
31
+ )
32
+ console_handler.setFormatter(formatter)
33
+
34
+ logger.addHandler(console_handler)
35
+
36
+ return logger
37
+
38
+
39
+ # ์ „์—ญ ๋กœ๊ฑฐ
40
+ logger = setup_logger()
41
+
utils/pdf_utils.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # utils/pdf_utils.py
2
+ """PDF ํ…์ŠคํŠธ ์ขŒํ‘œ ์ถ”์ถœ ์œ ํ‹ธ"""
3
+ import fitz # PyMuPDF
4
+ from typing import List, Dict
5
+
6
+
7
+ def get_text_coordinates(pdf_path: str, page_num: int, search_text: str) -> List[Dict]:
8
+ """
9
+ PDF์—์„œ ํŠน์ • ํ…์ŠคํŠธ์˜ ์ขŒํ‘œ๋ฅผ ์ฐพ์•„ ํ•˜์ด๋ผ์ดํŠธ ์ •๋ณด ๋ฐ˜ํ™˜
10
+
11
+ Args:
12
+ pdf_path: PDF ํŒŒ์ผ ๊ฒฝ๋กœ
13
+ page_num: ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (1-based)
14
+ search_text: ๊ฒ€์ƒ‰ํ•  ํ…์ŠคํŠธ
15
+
16
+ Returns:
17
+ ํ•˜์ด๋ผ์ดํŠธ ์ •๋ณด ๋ฆฌ์ŠคํŠธ
18
+ """
19
+ try:
20
+ doc = fitz.open(pdf_path)
21
+ page = doc[page_num - 1]
22
+
23
+ # ๊ฒ€์ƒ‰ ํ…์ŠคํŠธ ์ •๋ฆฌ (๋„ˆ๋ฌด ๊ธธ๋ฉด ์•ž๋ถ€๋ถ„๋งŒ)
24
+ search_query = search_text[:100].strip()
25
+ text_instances = page.search_for(search_query)
26
+
27
+ annotations = []
28
+
29
+ for rect in text_instances:
30
+ # streamlit-pdf-viewer ํ˜•์‹ - ํ˜•๊ด‘ํŽœ ์Šคํƒ€์ผ
31
+ annotations.append({
32
+ "page": page_num, # 1-based index
33
+ "x": rect.x0,
34
+ "y": rect.y0,
35
+ "width": rect.x1 - rect.x0,
36
+ "height": rect.y1 - rect.y0,
37
+ "color": "#FFFF00", # ๋ฐ์€ ๋…ธ๋ž€์ƒ‰
38
+ "opacity": 0.4 # 40% ํˆฌ๋ช…๋„ (ํ˜•๊ด‘ํŽœ ํšจ๊ณผ)
39
+ })
40
+
41
+ doc.close()
42
+
43
+ if annotations:
44
+ print(f" โœ… {len(annotations)}๊ฐœ ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ (ํŽ˜์ด์ง€ {page_num})")
45
+ else:
46
+ print(f" โš ๏ธ ํ…์ŠคํŠธ '{search_query[:30]}...' ์ฐพ์ง€ ๋ชปํ•จ")
47
+
48
+ return annotations
49
+
50
+ except Exception as e:
51
+ print(f"โŒ ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ ์˜ค๋ฅ˜: {e}")
52
+ return []
53
+
54
+
55
+ if __name__ == "__main__":
56
+ result = get_text_coordinates("test.pdf", 1, "sample text")
57
+ print(f"ํ•˜์ด๋ผ์ดํŠธ ๊ฐœ์ˆ˜: {len(result)}")
utils/session_manager.py ADDED
File without changes
utils/sidebar_components.py ADDED
File without changes