dev-yuje commited on
Commit
79ef842
ยท
1 Parent(s): 78380bf

feat: implement conversational hybrid RAG fallback routing and optimize presentation layout

Browse files
AGENTS.md CHANGED
@@ -122,10 +122,10 @@ def test_4_core_scenarios():
122
  - [x] **4. ์ •์ /๋™์  ๋ฐฉ์–ด ํ…Œ์ŠคํŠธ**: Ruff/Mypy ํ†ต๊ณผ, `python -c "import app"` ์ •์ƒ ๋นŒ๋“œ, `smoke_test_rag.py` ์„ฑ๊ณต ๊ฒ€์ฆ
123
 
124
  ## ๊ฐœ๋ฐœ ์ฒดํฌ๋ฆฌ์ŠคํŠธ (Gradio UI/UX ๋””ํ…Œ์ผ ๊ฐœ์„  ๋‹จ๊ณ„)
125
- - [ ] **1. ํ™”๋ฉด ๋„ˆ๋น„ ๋Œ€ํญ ํ™•๋Œ€**: `.gradio-container` ๋ฐ ๋ธ”๋ก ๋ ˆ์ด์•„์›ƒ์˜ max-width๋ฅผ ๋Œ€ํญ ํ™•์žฅํ•˜์—ฌ ๋Œ€ํ™”๋ฉด ์ง€์›
126
- - [ ] **2. ์˜ˆ์‹œ ์งˆ๋ฌธ ์ตœ์ƒ๋‹จ(์ฑ—๋ด‡ ์œ„) ์ด๋™**: CSS Flexbox order ๋˜๋Š” Blocks ๊ตฌ์กฐ ๊ฐœํŽธ์„ ํ†ตํ•ด ์˜ˆ์‹œ ์งˆ๋ฌธ์„ ํ™”๋ฉด ๋งจ ์œ„๋กœ ๊ณ ์ •
127
- - [ ] **3. ๋ฒ„ํŠผ ํ…Œ๋‘๋ฆฌ ์–‡๊ฒŒ ๊ฐœ์„ **: ์˜ˆ์‹œ ์งˆ๋ฌธ ๋ฒ„ํŠผ์˜ ํฌ์ธํŠธ ๋ณด๋” ๋‘๊ป˜๋ฅผ ์ถ•์†Œํ•˜๊ณ  ์–‡๊ณ  ๊น”๋”ํ•˜๊ฒŒ ๋ฏธ๋‹ˆ๋ฉ€๋ฆฌ์ฆ˜ ๋””์ž์ธ ์ ์šฉ
128
- - [ ] **4. ์ •์ /๋™์  ๊ฒ€์ฆ**: Ruff/Mypy ํ†ต๊ณผ ๋ฐ `browser_subagent`๋ฅผ ํ†ตํ•œ ์‹ค์ œ ๋ Œ๋”๋ง ๋ฌด๊ฒฐ์„ฑ ์Šคํฌ๋ฆฐ์ƒท ๊ฒ€์ฆ
129
 
130
 
131
 
 
122
  - [x] **4. ์ •์ /๋™์  ๋ฐฉ์–ด ํ…Œ์ŠคํŠธ**: Ruff/Mypy ํ†ต๊ณผ, `python -c "import app"` ์ •์ƒ ๋นŒ๋“œ, `smoke_test_rag.py` ์„ฑ๊ณต ๊ฒ€์ฆ
123
 
124
  ## ๊ฐœ๋ฐœ ์ฒดํฌ๋ฆฌ์ŠคํŠธ (Gradio UI/UX ๋””ํ…Œ์ผ ๊ฐœ์„  ๋‹จ๊ณ„)
125
+ - [x] **1. ํ™”๋ฉด ๋„ˆ๋น„ ๋Œ€ํญ ํ™•๋Œ€**: `.gradio-container` ๋ฐ ๋ธ”๋ก ๋ ˆ์ด์•„์›ƒ์˜ max-width๋ฅผ ๋Œ€ํญ ํ™•์žฅํ•˜์—ฌ ๋Œ€ํ™”๋ฉด ์ง€์›
126
+ - [x] **2. ์˜ˆ์‹œ ์งˆ๋ฌธ ์ตœ์ƒ๋‹จ(์ฑ—๋ด‡ ์œ„) ์ด๋™**: CSS Flexbox order ๋˜๋Š” Blocks ๊ตฌ์กฐ ๊ฐœํŽธ์„ ํ†ตํ•ด ์˜ˆ์‹œ ์งˆ๋ฌธ์„ ํ™”๋ฉด ๋งจ ์œ„๋กœ ๊ณ ์ •
127
+ - [x] **3. ๋ฒ„ํŠผ ํ…Œ๋‘๋ฆฌ ์–‡๊ฒŒ ๊ฐœ์„ **: ์˜ˆ์‹œ ์งˆ๋ฌธ ๋ฒ„ํŠผ์˜ ํฌ์ธํŠธ ๋ณด๋” ๋‘๊ป˜๋ฅผ ์ถ•์†Œํ•˜๊ณ  ์–‡๊ณ  ๊น”๋”ํ•˜๊ฒŒ ๋ฏธ๋‹ˆ๋ฉ€๋ฆฌ์ฆ˜ ๋””์ž์ธ ์ ์šฉ
128
+ - [x] **4. ์ •์ /๋™์  ๊ฒ€์ฆ**: Ruff/Mypy ํ†ต๊ณผ ๋ฐ `browser_subagent`๋ฅผ ํ†ตํ•œ ์‹ค์ œ ๋ Œ๋”๋ง ๋ฌด๊ฒฐ์„ฑ ์Šคํฌ๋ฆฐ์ƒท ๊ฒ€์ฆ
129
 
130
 
131
 
README.md CHANGED
@@ -73,6 +73,13 @@ pinned: false
73
  - `Vector Retriever`: ๋ณธ๋ฌธ ์ฒญํฌ ์˜๋ฏธ ์œ ์‚ฌ๋„ ๊ฒ€์ƒ‰
74
  - `VectorCypher Retriever`: ๋ฒกํ„ฐ ๊ฒ€์ƒ‰ ํ›„ ํ•ด๋‹น ๊ธฐ์‚ฌ์˜ ์—ฐ๊ด€ ๊ทธ๋ž˜ํ”„(๊ธฐ์—…ยท๊ธฐ์ˆ ยท์„œ๋น„์Šค) ๋ฐ˜ํ™˜ (ํŠธ๋ Œ๋“œ ๋ถ„์„์— ์ตœ์ ํ™”)
75
  - `Text2Cypher Retriever`: ์ž์—ฐ์–ด โ†’ Cypher ์ฟผ๋ฆฌ ์ž๋™ ๋ณ€ํ™˜ ๋ฐ ๋ฐ์ดํ„ฐ ์ง‘๊ณ„
 
 
 
 
 
 
 
76
 
77
  ---
78
 
 
73
  - `Vector Retriever`: ๋ณธ๋ฌธ ์ฒญํฌ ์˜๋ฏธ ์œ ์‚ฌ๋„ ๊ฒ€์ƒ‰
74
  - `VectorCypher Retriever`: ๋ฒกํ„ฐ ๊ฒ€์ƒ‰ ํ›„ ํ•ด๋‹น ๊ธฐ์‚ฌ์˜ ์—ฐ๊ด€ ๊ทธ๋ž˜ํ”„(๊ธฐ์—…ยท๊ธฐ์ˆ ยท์„œ๋น„์Šค) ๋ฐ˜ํ™˜ (ํŠธ๋ Œ๋“œ ๋ถ„์„์— ์ตœ์ ํ™”)
75
  - `Text2Cypher Retriever`: ์ž์—ฐ์–ด โ†’ Cypher ์ฟผ๋ฆฌ ์ž๋™ ๋ณ€ํ™˜ ๋ฐ ๋ฐ์ดํ„ฐ ์ง‘๊ณ„
76
+ - **๋Œ€ํ™” ๋งฅ๋ฝ ๋ฐ˜์˜ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ RAG & ์ง€๋Šฅํ˜• Fallback ๋ผ์šฐํŒ…**:
77
+ - Neo4j์—์„œ ๊ฒ€์ƒ‰๋œ ์ง€์‹ ๊ทธ๋ž˜ํ”„ ์ •๋ณด์™€ ์‚ฌ์šฉ์ž์˜ ์งˆ๋ฌธ, ๊ทธ๋ฆฌ๊ณ  **์ตœ๊ทผ ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ(์ตœ๊ทผ 3๊ฐœ ๋ฉ”์‹œ์ง€)**๋ฅผ ์ข…ํ•ฉ ๋ถ„์„ํ•˜์—ฌ GPT-4o ๊ธฐ๋ฐ˜ ์ž๊ฐ€ ํŒ์ • ๊ฐ€๋“œ๋ ˆ์ผ(`_is_context_sufficient`)์„ ์‹ค์‹œ๊ฐ„ ๊ตฌ๋™.
78
+ - ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์งˆ๋ฌธ์— ๋‹ต๋ณ€ํ•˜๊ธฐ ์ถฉ๋ถ„ํ•œ ๊ฒฝ์šฐ `GraphRAG` ๋ชจ๋“œ๋กœ ๊ตฌ๋™ํ•˜์—ฌ ๊ตฌ์ฒด์ ์ธ ๊ธฐ์‚ฌ ์ถœ์ฒ˜ ๋งํฌ(URL, ์ œ๋ชฉ ๋“ฑ)๋ฅผ ํฌํ•จํ•œ ํŒฉํŠธ ๊ธฐ๋ฐ˜ ๋‹ต๋ณ€ ์ œ๊ณต.
79
+ - ๊ด€๋ จ ์ •๋ณด๊ฐ€ ๋ถ€์กฑํ•˜๊ฑฐ๋‚˜ ๊ธˆ์œต/IT ๋‰ด์Šค๋ฅผ ๋ฒ—์–ด๋‚œ ์ผ๋ฐ˜ ์งˆ๋ฌธ(์˜ˆ: ์ˆ˜ํ•™ ๊ณต์‹, ์ผ์ƒ ๋Œ€ํ™” ๋“ฑ), ํ˜น์€ ์ด์ „ ๋Œ€ํ™” ๋งฅ๋ฝ์— ์˜์กดํ•˜๋Š” ์งˆ๋ฌธ์— ๋Œ€ํ•ด์„œ๋„ ํžˆ์Šคํ† ๋ฆฌ๋ฅผ ์ข…ํ•ฉ ๋ถ„์„ํ•˜์—ฌ ์ผ๋ฐ˜ ์ง€์‹ ๋‹ต๋ณ€(`general` ๋ชจ๋“œ)์œผ๋กœ ์œ ์—ฐํ•˜๊ฒŒ ์Šค์œ„์นญํ•˜์—ฌ ํ™˜๊ฐ(Hallucination) ๋ฐฉ์ง€ ๋ฐ ์•ˆ์ •์„ฑ ๊ทน๋Œ€ํ™”.
80
+ - **์‚ฌ์šฉ์ž ๊ฒฝํ—˜(UX) ๋ฐ ํ”„๋ฆฌ๋ฏธ์—„ UI ์ตœ์ ํ™”**:
81
+ - **์Šฌ๋ฆผ ์ฑ— ๋ฒ„๋ธ”**: Gradio ๋งˆํฌ๋‹ค์šด ๋ Œ๋”๋Ÿฌ๊ฐ€ ๋‚ด๋ถ€ `<p>`, `<li>` ํƒœ๊ทธ ๋“ฑ์— ๋ถ€์—ฌํ•˜๋Š” ๋น„์ •์ƒ์ ์œผ๋กœ ํฐ ์ƒํ•˜ ์—ฌ๋ฐฑ๊ณผ ๋งˆ์ง„์„ ์ถ•์†Œ(`.message` ํŒจ๋”ฉ `10px 14px` ์กฐ์ • ๋ฐ ๋‚ด๋ถ€ ๋งˆ์ง„ ์ตœ์ ํ™”)ํ•˜์—ฌ ๊ฐ€๋…์„ฑ ๋†’๊ณ  ์Šฌ๋ฆผํ•œ ํ”„๋ฆฌ๋ฏธ์—„ ๋งํ’์„  UI ๊ตฌํ˜„.
82
+ - **์‹ค์‹œ๊ฐ„ ํƒ์ƒ‰ ์ง„ํ–‰์ƒํ™ฉ ์‹œ๊ฐํ™”**: LangGraph ๋Œ€ํ™” ์ŠคํŠธ๋ฆผ(Stream) ์—ฐ๋™์„ ํ†ตํ•ด `"๐Ÿ” ๊ฒ€์ƒ‰ ์ง„ํ–‰ ์ค‘..."`, `"๐Ÿ’ก ๋‹ต๋ณ€ ์ƒ์„ฑ ์ค‘..."` ๊ณผ์ •์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋…ธ์ถœํ•˜์—ฌ ๋‹ค๋‹จ๊ณ„ RAG์˜ ์ง€์—ฐ ์‹œ๊ฐ„ ๋™์•ˆ์˜ ์‚ฌ์šฉ์ž ์ฒด๊ฐ ๋Œ€๊ธฐ ์„ฑ๋Šฅ ํ˜์‹ .
83
 
84
  ---
85
 
app.py CHANGED
@@ -14,7 +14,8 @@ import dotenv
14
  import gradio as gr
15
  from langgraph.graph import END, StateGraph
16
 
17
- from src.retrieval.finRetrieval import graphrag
 
18
 
19
  dotenv.load_dotenv()
20
 
@@ -44,8 +45,9 @@ except Exception as e:
44
  class ChatState(TypedDict):
45
  question: str # ์‚ฌ์šฉ์ž ์งˆ๋ฌธ
46
  history: List[dict] # ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ [{"role": "user"/"assistant", "content": "..."}]
47
- context: str # GraphRAG ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ
48
- answer: str # ์ตœ์ข… ๋‹ต๋ณ€
 
49
 
50
 
51
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -54,16 +56,33 @@ class ChatState(TypedDict):
54
 
55
 
56
  def retrieve_node(state: ChatState) -> ChatState:
57
- """Node 1: GraphRAG๋กœ ๊ด€๋ จ ์ปจํ…์ŠคํŠธ ๊ฒ€์ƒ‰"""
58
  try:
59
- result = graphrag.search(query_text=state["question"])
60
- context = result.answer # GraphRAG๊ฐ€ ์ด๋ฏธ ๋‹ต๋ณ€์„ ์™„์„ฑํ•˜๋ฏ€๋กœ ๋ฐ”๋กœ ์‚ฌ์šฉ
61
-
62
- # ์‹ค์ œ GraphRAG ๊ฒ€์ƒ‰ ์‹œ ์‚ฌ์šฉ๋œ ์ƒ์œ„ 3๊ฐœ ๋‰ด์Šค ํ”ผ๋“œ ๋™์  ์ถ”์ถœ ๋ฐ ํฌ๋งทํŒ…
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  sources = []
64
- seen_urls = set()
65
- if hasattr(result, "retriever_result") and result.retriever_result and hasattr(result.retriever_result, "items"):
66
- for item in result.retriever_result.items:
 
 
 
67
  meta = getattr(item, "metadata", {})
68
  title = meta.get("article_title")
69
  url = meta.get("article_url")
@@ -142,17 +161,15 @@ def retrieve_node(state: ChatState) -> ChatState:
142
 
143
  except Exception as e:
144
  context = f"[๊ฒ€์ƒ‰ ์˜ค๋ฅ˜: {e}]"
145
- return {**state, "context": context}
146
 
147
 
148
  def generate_node(state: ChatState) -> ChatState:
149
  """Node 2: ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ๋ฅผ ๊ณ ๋ คํ•˜์—ฌ ์ตœ์ข… ๋‹ต๋ณ€ ์ƒ์„ฑ
150
 
151
- GraphRAG๊ฐ€ ์ด๋ฏธ ๊ฒ€์ƒ‰ + ์ƒ์„ฑ์„ ์ฒ˜๋ฆฌํ•˜๋ฏ€๋กœ,
152
- ์—ฌ๊ธฐ์„œ๋Š” ํžˆ์Šคํ† ๋ฆฌ ๊ธฐ๋ฐ˜ ํ›„์ฒ˜๋ฆฌ๋‚˜ ์ถ”๊ฐ€ ํฌ๋งทํŒ…๋งŒ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.
153
  """
154
- # GraphRAG ๊ฒฐ๊ณผ๋ฅผ ๋ฐ”๋กœ ๋‹ต๋ณ€์œผ๋กœ ์‚ฌ์šฉ
155
- # (ํžˆ์Šคํ† ๋ฆฌ ๊ธฐ๋ฐ˜ ํ›„์† ์งˆ๋ฌธ ์ฒ˜๋ฆฌ๊ฐ€ ํ•„์š”ํ•˜๋ฉด ์ด ๋…ธ๋“œ๋ฅผ ํ™•์žฅํ•˜์„ธ์š”)
156
  answer = state["context"] if state["context"] else "๊ด€๋ จ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
157
  return {**state, "answer": answer}
158
 
@@ -197,6 +214,7 @@ def chat(message: str, history: list):
197
  "history": history,
198
  "context": "",
199
  "answer": "",
 
200
  }
201
 
202
  yield "๐Ÿ” ์‹ค์‹œ๊ฐ„ ์ง€์‹ ๊ทธ๋ž˜ํ”„์—์„œ ๊ด€๋ จ ๋‰ด์Šค๋ฅผ ๊ฒ€์ƒ‰ํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค..."
@@ -205,7 +223,11 @@ def chat(message: str, history: list):
205
  # LangGraph์˜ stream์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ฐ ๋…ธ๋“œ ์‹คํ–‰ ์‹œ์ ๋งˆ๋‹ค ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›์Œ
206
  for event in chat_graph.stream(state):
207
  if "retrieve" in event:
208
- yield "๐Ÿ’ก ๊ฒ€์ƒ‰ ์™„๋ฃŒ! ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์ตœ์ข… ๋‹ต๋ณ€์„ ์ƒ์„ฑํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค..."
 
 
 
 
209
  elif "generate" in event:
210
  yield event["generate"]["answer"]
211
  except Exception as e:
@@ -273,97 +295,6 @@ def get_db_stats() -> Dict[str, Any]:
273
  return stats
274
 
275
 
276
- def build_stats_html(stats: Dict[str, Any]) -> str:
277
- """์กฐํšŒ๋œ ์ง€์‹ ๊ทธ๋ž˜ํ”„ ํ†ต๊ณ„ ์ •๋ณด๋“ค์„ ๋ฐ”ํƒ•์œผ๋กœ ๋ฏธ๋ คํ•˜๊ณ  ์ปดํŒฉํŠธํ•œ ๋Œ€์‹œ๋ณด๋“œ์šฉ HTML์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค."""
278
- # 1. ์ตœ์‹  ๋‰ด์Šค ํ‚ค์›Œ๋“œ ๋ฐฐ์ง€ HTML ์ƒ์„ฑ (๋‘ฅ๊ทผ ๋„ค๋ชจ ํ˜•ํƒœ)
279
- keyword_html: str = ""
280
- for t in stats.get("techs_list", []):
281
- keyword_html += f"""
282
- <span class="keyword-badge"># {t['name']}</span>
283
- """
284
- if not keyword_html:
285
- keyword_html = '<div style="font-size:12px; color:#94a3b8;">๋“ฑ๋ก๋œ ํ‚ค์›Œ๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</div>'
286
-
287
- # 1.5. ์ตœ์‹  ์ฃผ๋ชฉ ๊ธฐ์—… ๋ฐฐ์ง€ HTML ์ƒ์„ฑ (ํ‚ค์›Œ๋“œ ๋ฐฐ์ง€์™€ ๋™์ผ ์Šคํƒ€์ผ๋กœ ํ†ต์ผ)
288
- company_html: str = ""
289
- for c in stats.get("companies_list", []):
290
- company_html += f"""
291
- <span class="keyword-badge">๐Ÿข {c['name']}</span>
292
- """
293
- if not company_html:
294
- company_html = '<div style="font-size:12px; color:#94a3b8;">๋“ฑ๋ก๋œ ๊ธฐ์—…์ด ์—†์Šต๋‹ˆ๋‹ค.</div>'
295
-
296
- # 2. ์ตœ๊ทผ ๊ธฐ์‚ฌ ๋ฆฌ์ŠคํŠธ HTML ์ƒ์„ฑ (์ตœ๋Œ€ 4๊ฐœ) - ์ „์ฒด ์˜์—ญ ํด๋ฆญ ์‹œ ์ด๋™ํ•˜๋„๋ก a ํƒœ๊ทธ๋กœ ๋ž˜ํ•‘
297
- news_list_html: str = ""
298
- for a in stats.get("recent_articles", []):
299
- title = a["title"]
300
- url = a["url"] if a["url"] and str(a["url"]).lower() != "nan" else "#"
301
- target = 'target="_blank"' if url != "#" else ""
302
- date_str = str(a['date'])[:10] if a['date'] else ""
303
- news_list_html += f"""
304
- <a class="news-item-link" href="{url}" {target}>
305
- <div class="news-item">
306
- <div class="news-title">{title}</div>
307
- <div class="news-meta">๐Ÿ—“๏ธ {date_str}</div>
308
- </div>
309
- </a>
310
- """
311
- if not news_list_html:
312
- news_list_html = '<div style="font-size:12px; color:#94a3b8;">์ตœ๊ทผ ์ˆ˜์ง‘๋œ ๊ธฐ์‚ฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</div>'
313
-
314
- # node_count = stats['companies'] + stats['technologies']
315
-
316
- html: str = f"""
317
- <div class="dashboard-container">
318
- <!-- Ambient background elements for beautiful glass effects -->
319
- <div class="ambient-glow"></div>
320
-
321
- <div style="font-size: 16px; font-weight: 850; color: #334155; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; letter-spacing: -0.02em;">
322
- ๐Ÿ“Š <span>FinGraph AI Terminal</span>
323
- </div>
324
- <p style="font-size: 11px; color: #475569; margin-top: -2px; margin-bottom: 12px; font-weight: 600;">GraphRAG ์‹ค์‹œ๊ฐ„ ๋ถ„์„ ์—”์ง„ ์ƒํƒœ</p>
325
-
326
- <!-- ์‹ค์‹œ๊ฐ„ ์—”์ง„ ํ…”๋ ˆ๋ฉ”ํŠธ๋ฆฌ (4๊ฐœ ๋ฉ”ํŠธ๋ฆญ ํ†ตํํ•ฉ) -->
327
- <div class="stats-grid">
328
- <div class="stat-card">
329
- <div class="stat-lbl">๐Ÿ’ก ๋ถ„์„ ๋ชจ๋ธ</div>
330
- <div class="stat-val" style="font-size: 13px !important; font-weight: 800 !important; color: #334155;">GPT-4o</div>
331
- </div>
332
- <div class="stat-card">
333
- <div class="stat-lbl">๐Ÿข ๋Œ€์ƒ ํšŒ์‚ฌ</div>
334
- <div class="stat-val" style="font-size: 13px !important; font-weight: 800 !important; color: #334155;">{stats['companies']}๊ฐœ</div>
335
- </div>
336
- <div class="stat-card">
337
- <div class="stat-lbl">๐Ÿ”‘ ๋‰ด์Šค ํ‚ค์›Œ๋“œ</div>
338
- <div class="stat-val" style="font-size: 13px !important; font-weight: 800 !important; color: #334155;">{stats['technologies']}๊ฐœ</div>
339
- </div>
340
- <div class="stat-card">
341
- <div class="stat-lbl">๐Ÿ“ฐ ๋ถ„์„์šฉ ๋‰ด์Šค ๊ธฐ์‚ฌ</div>
342
- <div class="stat-val" style="font-size: 13px !important; font-weight: 800 !important; color: #334155;">{stats['articles']}๊ฑด</div>
343
- </div>
344
- </div>
345
-
346
- <div class="section-subtitle" style="color: #334155;">๐Ÿ’ก ์ตœ์‹  ๋‰ด์Šค ํ‚ค์›Œ๋“œ</div>
347
- <div class="keyword-container">
348
- {keyword_html}
349
- </div>
350
-
351
- <div class="section-subtitle" style="color: #334155;">๐Ÿข ์ตœ์‹  ์ฃผ๋ชฉ ๊ธฐ์—…</div>
352
- <div class="keyword-container">
353
- {company_html}
354
- </div>
355
-
356
- <div class="section-subtitle" style="color: #334155;">๐Ÿ“ฐ ์ตœ์‹  ๋‰ด์Šค ํ”ผ๋“œ</div>
357
- <div class="news-feed-container">
358
- <div class="news-feed">
359
- {news_list_html}
360
- </div>
361
- </div>
362
- </div>
363
- """
364
- return html
365
-
366
-
367
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
368
  # 5. Gradio UI ๊ตฌ์„ฑ
369
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -380,488 +311,6 @@ theme_obj = gr.themes.Soft(
380
  secondary_hue="slate",
381
  )
382
 
383
- custom_css: str = """
384
- body {
385
- background-color: #fbf9f6;
386
- font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
387
- color: #0f172a !important; /* ๊ธฐ๋ณธ ๊ฒ€์ •๏ฟฝ๏ฟฝ๏ฟฝ */
388
- }
389
-
390
- /* Ambient glow point backgrounds (๋ณด๋ผ์ƒ‰ ์›์ฒœ ๋ฐฐ์ œ, ์€์€ํ•œ ์Šค์นด์ด๋ธ”๋ฃจ์™€ ํ…Œ์ผ๊ทธ๋ฆฐ ํ†ค) */
391
- .ambient-glow {
392
- position: fixed;
393
- top: 0; left: 0; right: 0; bottom: 0;
394
- background: radial-gradient(circle at 85% 15%, rgba(14, 165, 233, 0.06) 0%, transparent 45%),
395
- radial-gradient(circle at 15% 85%, rgba(20, 184, 166, 0.06) 0%, transparent 45%);
396
- z-index: -1;
397
- pointer-events: none;
398
- }
399
-
400
- /* ๋Œ€์‹œ๋ณด๋“œ ํˆฌ๋ช… ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ์ปจํ…Œ์ด๋„ˆ */
401
- .dashboard-container {
402
- background: rgba(255, 255, 255, 0.8) !important;
403
- backdrop-filter: blur(24px) !important;
404
- -webkit-backdrop-filter: blur(24px) !important;
405
- border: 1px solid #cbd5e1 !important; /* ๊น”๋”ํ•œ ๋‰ดํŠธ๋Ÿด ์Šฌ๋ ˆ์ดํŠธ ํ…Œ๋‘๋ฆฌ */
406
- border-radius: 12px;
407
- padding: 16px;
408
- box-shadow: 0 4px 12px -2px rgba(15, 23, 42, 0.03) !important;
409
- font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, sans-serif;
410
- }
411
- .dark .dashboard-container {
412
- background: rgba(15, 23, 42, 0.55) !important;
413
- border-color: rgba(14, 165, 233, 0.25) !important;
414
- box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.3) !important;
415
- }
416
-
417
- /* ํ†ต๊ณ„ ๊ทธ๋ฆฌ๋“œ ๋ฐ ๊ธ€๋ž˜์Šค ์นด๋“œ */
418
- .stats-grid {
419
- display: grid;
420
- grid-template-columns: repeat(2, 1fr);
421
- gap: 10px;
422
- margin-bottom: 15px;
423
- }
424
- .stat-card {
425
- background: rgba(255, 255, 255, 0.95);
426
- border: 1px solid #cbd5e1;
427
- border-radius: 8px;
428
- padding: 10px;
429
- text-align: center;
430
- box-shadow: 0 1px 3px rgba(15, 23, 42, 0.01);
431
- transition: all 0.25s ease-in-out;
432
- }
433
- .stat-card:hover {
434
- transform: translateY(-2px);
435
- background: rgba(255, 255, 255, 1);
436
- border-color: #0ea5e9; /* ํ˜ธ๋ฒ„ ์‹œ ์Šค์นด์ด ๋ธ”๋ฃจ */
437
- box-shadow: 0 4px 12px -2px rgba(14, 165, 233, 0.1);
438
- }
439
- .dark .stat-card {
440
- background: rgba(30, 41, 59, 0.7);
441
- border-color: rgba(14, 165, 233, 0.2);
442
- color: #f1f5f9;
443
- }
444
- .dark .stat-card:hover {
445
- border-color: #38bdf8;
446
- }
447
- .stat-val {
448
- font-size: 16px !important;
449
- font-weight: 850 !important;
450
- color: #0f172a !important; /* ํ™•์‹คํ•œ ๊ณ ๋Œ€๋น„ ๊ฒ€์ •์ƒ‰ ๊ธ€์”จ */
451
- margin-top: 2px;
452
- }
453
- .dark .stat-val {
454
- color: #f8fafc !important;
455
- }
456
- .stat-lbl {
457
- font-size: 11px !important;
458
- color: #334155;
459
- font-weight: 600;
460
- }
461
- .dark .stat-lbl {
462
- color: #94a3b8;
463
- }
464
-
465
- /* ์ตœ์‹  ๋‰ด์Šค ํ‚ค์›Œ๋“œ ์ปจํ…Œ์ด๋„ˆ ๋ฐ ๋‘ฅ๊ทผ ๋ฐฐ์ง€ ์Šคํƒ€์ผ (๋ณด๋ผ์ƒ‰ ๋ฐฐ์ œ, ์Šฌ๋ ˆ์ดํŠธ ๋ฐ ๊ฒ€์ •์ƒ‰ ๊ธ€์”จ) */
466
- .keyword-container {
467
- display: flex;
468
- flex-wrap: wrap;
469
- gap: 8px;
470
- margin-bottom: 12px;
471
- }
472
- .keyword-badge {
473
- display: inline-block;
474
- background: #f1f5f9 !important; /* ์—ฐํ•œ ๋‰ดํŠธ๋Ÿด ์Šฌ๋ ˆ์ดํŠธ */
475
- border: 1px solid #cbd5e1 !important; /* ์Šฌ๋ ˆ์ดํŠธ ํ…Œ๋‘๋ฆฌ */
476
- border-radius: 8px !important;
477
- padding: 6px 12px;
478
- font-size: 11px !important;
479
- font-weight: 700;
480
- color: #0f172a !important; /* ํ™•์‹คํ•œ ๊ฒ€์ •์ƒ‰ */
481
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important;
482
- transition: all 0.2s ease-in-out;
483
- }
484
- .keyword-badge:hover {
485
- background: #e2e8f0 !important;
486
- transform: scale(1.03);
487
- }
488
- .dark .keyword-badge {
489
- background: rgba(15, 23, 42, 0.4) !important;
490
- border-color: rgba(14, 165, 233, 0.25) !important;
491
- color: #cbd5e1 !important;
492
- }
493
-
494
- /* ์ตœ๊ทผ ๋‰ด์Šค ํ”ผ๋“œ ํด๋ฆญ ๊ฐ€๋Šฅํ•œ ์นด๋“œ ๋ ˆ์ด์•„์›ƒ */
495
- .news-feed-container {
496
- max-height: 350px;
497
- overflow-y: auto;
498
- border: 1px solid #cbd5e1;
499
- border-radius: 6px;
500
- padding: 8px;
501
- background: rgba(255, 255, 255, 0.7);
502
- }
503
- .dark .news-feed-container {
504
- background: rgba(30, 41, 59, 0.5);
505
- border-color: rgba(14, 165, 233, 0.15);
506
- }
507
- /* ์Šคํฌ๋กค๋ฐ” ์ปค์Šคํ…€ */
508
- .news-feed-container::-webkit-scrollbar {
509
- width: 4px;
510
- }
511
- .news-feed-container::-webkit-scrollbar-track {
512
- background: transparent;
513
- }
514
- .news-feed-container::-webkit-scrollbar-thumb {
515
- background: rgba(148, 163, 184, 0.4);
516
- border-radius: 2px;
517
- }
518
- .dark .news-feed-container::-webkit-scrollbar-thumb {
519
- background: rgba(148, 163, 184, 0.2);
520
- }
521
-
522
- .news-item-link {
523
- text-decoration: none;
524
- display: block;
525
- margin-bottom: 8px;
526
- }
527
- .news-item-link:last-child {
528
- margin-bottom: 0;
529
- }
530
- .news-item {
531
- border-left: 3px solid #0ea5e9; /* ํŒŒ๋ž€์ƒ‰ ์˜ค์…˜ ๋ธ”๋ฃจ ํฌ์ธํŠธ */
532
- padding: 8px 10px;
533
- background: rgba(255, 255, 255, 0.8);
534
- border-radius: 0 6px 6px 0;
535
- transition: all 0.2s ease-in-out;
536
- cursor: pointer;
537
- }
538
- .news-item-link:hover .news-item {
539
- background: rgba(255, 255, 255, 1);
540
- border-left-color: #0284c7;
541
- transform: translateX(3px);
542
- box-shadow: 0 2px 6px rgba(14, 165, 233, 0.08);
543
- }
544
- .dark .news-item {
545
- background: rgba(30, 41, 59, 0.3);
546
- }
547
- .dark .news-item-link:hover .news-item {
548
- background: rgba(30, 41, 59, 0.65);
549
- border-left-color: #38bdf8;
550
- }
551
- .news-title {
552
- font-size: 12px !important;
553
- font-weight: 600;
554
- color: #1b1c1a;
555
- line-height: 1.4;
556
- white-space: nowrap;
557
- overflow: hidden;
558
- text-overflow: ellipsis;
559
- }
560
- .dark .news-title {
561
- color: #cbd5e1;
562
- }
563
- .news-meta {
564
- font-size: 10px !important;
565
- color: #94a3b8;
566
- margin-top: 2px;
567
- }
568
-
569
- /* ์„œ๋ธŒํƒ€์ดํ‹€ ํ—ค๋” ์Šคํƒ€์ผ (์—ฐํ•œ ์—๋ฉ”๋ž„๋“œ/ํ…Œ์ผ ๋ฐฐ๊ฒฝ + ๋”ฅ ํ…Œ์ผ ๊ธ€์”จ) */
570
- .section-subtitle {
571
- font-size: 13px !important;
572
- font-weight: 800;
573
- color: #0f766e !important; /* ๋”ฅ ํ…Œ์ผ ๊ธ€์”จ */
574
- background: rgba(20, 184, 166, 0.1) !important; /* ์—ฐํ•œ ํ…Œ์ผ ๊ธ€๋ž˜์Šค ๋ฐฐ๊ฒฝ */
575
- margin: 18px 0 8px 0;
576
- padding: 6px 10px !important;
577
- border-radius: 6px !important;
578
- border-left: 3px solid #14b8a6 !important; /* ์„ ๋ช…ํ•œ ํ…Œ์ผ ์„ธ๋กœ์„  */
579
- display: flex;
580
- align-items: center;
581
- gap: 6px;
582
- }
583
- .dark .section-subtitle {
584
- color: #99f6e4 !important;
585
- background: rgba(20, 184, 166, 0.2) !important;
586
- border-left-color: #2dd4bf !important;
587
- }
588
-
589
- /* โ”€โ”€ ์›ฐ์ปด ๋ณด๋“œ(์†Œ๊ฐœ๊ธ€ ์นด๋“œ) ์ƒ๋‹จ ์งค๋ฆผ ๋ฐฉ์–ด ๋ฐ ๊ฒฐํ•ฉ ์ค€๋น„ โ”€โ”€ */
590
- .placeholder, [class*="placeholder"] {
591
- justify-content: flex-start !important;
592
- padding-top: 15px !important; /* ์ƒ๋‹จ ์งค๋ฆผ ์™„์ „ ๋ฐฉ์ง€ */
593
- margin-bottom: 0 !important; /* ์•„๋ž˜์ชฝ ๊ฐ„๊ฒฉ ์™„์ „ ์ œ๊ฑฐ */
594
- padding-bottom: 0 !important;
595
- height: auto !important; /* ๊ป๋ฐ๊ธฐ ๋†’์ด ๊ณ ์ • ํ•ด์ œ */
596
- min-height: unset !important;
597
- }
598
- .placeholder .prose {
599
- background: #f8fafc !important; /* ์ฟจํ†ค ํšŒ์ƒ‰ */
600
- border: 1px solid #e2e8f0 !important;
601
- border-bottom: none !important; /* ์•„๋ž˜์ชฝ ๋‘ฅ๊ทผ ์„  ์ œ๊ฑฐ๋กœ ๊ฒฐํ•ฉ ์ค€๋น„ */
602
- border-radius: 12px 12px 0 0 !important; /* ์•„๋ž˜ ๋ชจ์„œ๋ฆฌ ์ง๊ฐ */
603
- padding: 24px 24px 10px 24px !important;
604
- max-width: 800px !important;
605
- width: 100% !important;
606
- margin: 0 auto !important;
607
- position: relative !important;
608
- z-index: 2 !important;
609
- display: block !important; /* ์€์‹  ๋งˆ๋ฒ• ๋ฐฉ์–ด */
610
- visibility: visible !important;
611
- color: #334155 !important;
612
- }
613
-
614
- /* 2x2 grid layout for chatbot example buttons (Stitch Action Grid style) */
615
- [class*="examples"], .gr-samples-wrapper, .examples-container {
616
- display: grid !important;
617
- grid-template-columns: repeat(2, 1fr) !important;
618
- gap: 10px !important;
619
-
620
- /* ๐ŸŒŸ ํ•ต์‹ฌ: ์›ฐ์ปด ๋ณด๋“œ์™€ ํ•œ ๋ชธ์ด ๋˜๊ธฐ ์œ„ํ•œ ์™„๋ฒฝ ๋ฐ€์ฐฉ ๋ฐ ๊ป๋ฐ๊ธฐ ๐ŸŒŸ */
621
- margin: -1px auto 40px auto !important; /* ์›ฐ์ปด ๋ณด๋“œ ๋ฐ”๋กœ ๋ฐ‘์— ์ฐฐ์‹น ๋ถ™์Œ */
622
- background: #f8fafc !important; /* ์›ฐ์ปด ๋ณด๋“œ์™€ ๋™์ผํ•œ ํšŒ์ƒ‰ ๋ฐฐ๊ฒฝ */
623
- border: 1px solid #e2e8f0 !important;
624
- border-top: none !important; /* ์œ—์„  ์ œ๊ฑฐ๋กœ ํ•œ ๋ชธ ๊ฒฐํ•ฉ */
625
- border-radius: 0 0 12px 12px !important; /* ์œ— ๋ชจ์„œ๋ฆฌ ์ง๊ฐ */
626
- padding: 5px 24px 24px 24px !important;
627
- max-width: 800px !important; /* ์›ฐ์ปด ๋ณด๋“œ ๋„ˆ๋น„ ์ผ์น˜ */
628
- width: 100% !important;
629
- position: relative !important;
630
- z-index: 1 !important; /* ๊ฒน์น  ๋•Œ ํฐ ์„  ์•ˆ๋ณด์ด๊ฒŒ ๋ฐ‘์œผ๋กœ */
631
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05) !important;
632
- }
633
- [class*="examples"] button {
634
- text-align: center !important; /* ์‚ฌ์šฉ์ž ์š”์ฒญ: ๊ฐ€์šด๋ฐ ์ •๋ ฌ */
635
- padding: 14px 18px !important;
636
- background: #e0f2fe !important; /* ์‚ฌ์šฉ์ž ์š”์ฒญ: ์—ฐํ•œ ํ•˜๋Š˜์ƒ‰(ํŒŒ๋ž‘) ๋ฐฐ๊ฒฝ */
637
- border: 1px solid #bae6fd !important;
638
- border-radius: 10px !important;
639
- font-size: 13px !important;
640
- font-weight: 600 !important;
641
- color: #000000 !important; /* ์‚ฌ์šฉ์ž ์š”์ฒญ: ๊ฒ€์€์ƒ‰ ํ…์ŠคํŠธ */
642
- line-height: 1.4 !important;
643
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02) !important;
644
- transition: all 0.2s ease-in-out !important;
645
- white-space: normal !important;
646
- height: auto !important;
647
- min-height: 54px !important;
648
- cursor: pointer !important;
649
- display: flex !important;
650
- align-items: center !important;
651
- justify-content: center !important;
652
- }
653
- .dark [class*="examples"] button {
654
- background: rgba(30, 41, 59, 0.5) !important;
655
- border-color: rgba(148, 163, 184, 0.2) !important;
656
- color: #e2e8f0 !important;
657
- }
658
- [class*="examples"] button:hover {
659
- transform: translateY(-1px) !important;
660
- background: #bae6fd !important; /* ์•ฝ๊ฐ„ ์ง™์€ ํ•˜๋Š˜์ƒ‰ ํ˜ธ๋ฒ„ */
661
- border-color: #7dd3fc !important;
662
- color: #1e3a8a !important; /* ์‚ฌ์šฉ์ž ์š”์ฒญ: ์ง„ํ•œ ํŒŒ๋ž‘์ƒ‰ ํ…์ŠคํŠธ */
663
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.06) !important;
664
- }
665
- .dark [class*="examples"] button:hover {
666
- background: rgba(30, 41, 59, 0.85) !important;
667
- border-color: rgba(148, 163, 184, 0.4) !important;
668
- color: #ffffff !important;
669
- }
670
-
671
- /* ์ฑ—๋ด‡ ์ „์†ก ๋ฒ„ํŠผ ํ”„๋ฆฌ๋ฏธ์—„ ๋‹คํฌ ์Šฌ๋ ˆ์ดํŠธ ์Šคํƒ€์ผ (์ด์ƒํ•œ ๊ทธ๋ผ๋ฐ์ด์…˜ ์ œ๊ฑฐ, ๋‹จ์ •ํ•˜๊ณ  ์ผ๊ด€์„ฑ ์žˆ๋Š” ์ƒ‰๊ฐ) */
672
- button.primary,
673
- .primary-btn,
674
- button.lg.primary,
675
- button.sm.primary,
676
- button.variant-primary,
677
- button[class*="submit-btn"],
678
- [data-testid="submit-button"] {
679
- background: #1e293b !important; /* ํ”„๋ฆฌ๋ฏธ์—„ ๋‹คํฌ ์Šฌ๋ ˆ์ดํŠธ ์ฐจ์ฝœ */
680
- color: white !important;
681
- font-weight: 700 !important;
682
- border: 1px solid #0f172a !important;
683
- border-radius: 8px !important;
684
- box-shadow: 0 2px 4px rgba(15, 23, 42, 0.08) !important;
685
- transition: all 0.2s ease-in-out !important;
686
- cursor: pointer !important;
687
- }
688
- button.primary:hover,
689
- .primary-btn:hover,
690
- button.variant-primary:hover,
691
- button[class*="submit-btn"]:hover,
692
- [data-testid="submit-button"]:hover {
693
- background: #0f172a !important; /* ํ˜ธ๋ฒ„ ์‹œ ๋”ฅ ๋ธ”๋ž™ */
694
- box-shadow: 0 4px 8px rgba(15, 23, 42, 0.15) !important;
695
- transform: translateY(-1px) !important;
696
- }
697
-
698
- /* secondary ๋ฐ ๊ธฐํƒ€ ์œ ํ‹ธ๋ฆฌํ‹ฐ ๋ฒ„ํŠผ ์Šคํƒ€์ผ */
699
- /* secondary ๋ฐ ๊ธฐํƒ€ ์œ ํ‹ธ๋ฆฌํ‹ฐ ๋ฒ„ํŠผ ์Šคํƒ€์ผ (๋ณด๋ผ์ƒ‰ ์ œ๊ฑฐ) */
700
- button.secondary,
701
- button.lg.secondary,
702
- button.sm.secondary,
703
- button.wrap,
704
- button.variant-secondary,
705
- .secondary-btn {
706
- background-color: rgba(255, 255, 255, 0.6) !important;
707
- color: #0f172a !important; /* ๊ธฐ๋ณธ ๊ฒ€์ •์ƒ‰ */
708
- border: 1px solid #cbd5e1 !important;
709
- font-weight: 700 !important;
710
- transition: all 0.2s ease-in-out !important;
711
- backdrop-filter: blur(8px);
712
- }
713
- .dark button.secondary,
714
- .dark button.variant-secondary,
715
- .dark .secondary-btn {
716
- background-color: rgba(30, 41, 59, 0.6) !important;
717
- color: #f1f5f9 !important;
718
- border-color: rgba(14, 165, 233, 0.2) !important;
719
- }
720
- button.secondary:hover,
721
- button.variant-secondary:hover,
722
- .secondary-btn:hover {
723
- background-color: rgba(255, 255, 255, 0.95) !important;
724
- color: #0f172a !important;
725
- border-color: #94a3b8 !important;
726
- }
727
- .dark button.secondary:hover,
728
- .dark button.variant-secondary:hover {
729
- background-color: rgba(30, 41, 59, 0.95) !important;
730
- color: white !important;
731
- border-color: #38bdf8 !important;
732
- }
733
-
734
- /* ์ฑ—๋ด‡ ๋ณด๋ผ์ƒ‰ ๋ฐฐ๊ฒฝ ์™„์ „ ์ œ๊ฑฐ ๋ฐ ๊ณ ๋Œ€๋น„ ์Šฌ๋ ˆ์ดํŠธ/ํ™”์ดํŠธ ๋ฒ„๋ธ” ๊ตฌํ˜„ */
735
- .bubble, .message {
736
- border-radius: 12px !important;
737
- }
738
-
739
- /* ์‚ฌ์šฉ์ž ๋ฒ„๋ธ” ๊ฐ€๋…์„ฑ ์™„์ „ ๊ฐœ์„  (๊ธ€์”จ์ƒ‰์„ ๊ฐ•์ œ๋กœ ๊นจ๋—ํ•œ ํฐ์ƒ‰์œผ๋กœ ๊ณ ์ •ํ•˜์—ฌ 500% ์„ ๋ช…ํ•˜๊ฒŒ ํ‘œ์‹œ) */
740
- .message.user {
741
- background-color: #334155 !important; /* ์ฐจ๋ถ„ํ•˜๊ณ  ๊ณ ๊ธ‰์Šค๋Ÿฌ์šด ๋‹คํฌ ์Šฌ๋ ˆ์ดํŠธ */
742
- border: 1px solid rgba(51, 65, 85, 0.2) !important;
743
- }
744
- .message.user p, .message.user span, .message.user li, .message.user div {
745
- color: #ffffff !important; /* ์™„์ „ ํฐ์ƒ‰ ๊ธ€์”จ */
746
- font-weight: 600 !important;
747
- }
748
-
749
- /* ๋ด‡ ๋ฒ„๋ธ” ๊ฐ€๋…์„ฑ ์™„์ „ ๊ฐœ์„  (๊ธ€์”จ์ƒ‰ ํ™•์‹คํ•œ ๊ฒ€์ •์ƒ‰, ๋ณด๋ผ์ƒ‰ ํ…Œ๋‘๋ฆฌ ์ œ๊ฑฐ) */
750
- .message.bot {
751
- background-color: rgba(255, 255, 255, 0.95) !important; /* ๋ฐ˜ํˆฌ๋ช… ๊นจ๋—ํ•œ ํ™”์ดํŠธ ๊ธ€๋ž˜์Šค */
752
- border: 1px solid #cbd5e1 !important;
753
- }
754
- .message.bot p, .message.bot span, .message.bot li, .message.bot div {
755
- color: #0f172a !important; /* ํ™•์‹คํ•œ ๊ณ ๋Œ€๋น„ ๊ฒ€์ •์ƒ‰ ๊ธ€์”จ */
756
- }
757
- .dark .message.user {
758
- background-color: #475569 !important;
759
- }
760
- .dark .message.bot {
761
- background-color: rgba(30, 41, 59, 0.85) !important;
762
- border-color: rgba(14, 165, 233, 0.2) !important;
763
- }
764
- .dark .message.bot p, .dark .message.bot span, .dark .message.bot li {
765
- color: #f1f5f9 !important;
766
- }
767
-
768
- /* Chatbot ๋ผ๋ฒจ/ํƒญ ์™„์ „ ์ˆจ๊น€ (๋ถˆํ•„์š”ํ•œ ๋ณด๋ผ์ƒ‰/ํ•˜๋Š˜์ƒ‰ ์ƒ‰์ƒ ๋ฐ ํ…Œ๋‘๋ฆฌ ์›์ฒœ ์ฐจ๋‹จ) */
769
- /* ์ฃผ์˜: .chatbot > div:first-child ๋Š” ์›ฐ์ปด์นด๋“œ๋ฅผ ์ง€์›Œ๋ฒ„๋ฆด ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์‚ญ์ œ! */
770
- .chatbot-label,
771
- div[class*="chatbot"] .label,
772
- [data-testid="chatbot"] .label,
773
- .chatbot-header,
774
- .gr-panel-title,
775
- .gr-chatbot-label,
776
- .label-wrap {
777
- display: none !important;
778
- }
779
-
780
- /* ์ฑ—๋ด‡ ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ ํˆฌ๋ช…ํ™” ๋ฐ ํ…Œ๋‘๋ฆฌ ๊น”๋”ํ™” */
781
- .chatbot, [class*="chatbot"] {
782
- background: rgba(255, 255, 255, 0.3) !important;
783
- border: 1px solid #cbd5e1 !important;
784
- border-radius: 12px !important;
785
- }
786
- .dark .chatbot {
787
- background: rgba(15, 23, 42, 0.3) !important;
788
- border-color: rgba(14, 165, 233, 0.15) !important;
789
- }
790
-
791
- /* ์ž…๋ ฅ์ฐฝ(ํ…์ŠคํŠธ์—์–ด๋ฆฌ์–ด) ์„ธ๋กœ ๋†’์ด ๋ฐ ๋‹จ์ผ ํ–‰ ์ˆ˜์ง ์ค‘์•™ ์ •๋ ฌ ์ตœ์ ํ™” */
792
- textarea,
793
- [class*="input-container"] textarea,
794
- [data-testid="textbox"] textarea {
795
- height: 48px !important;
796
- min-height: 48px !important;
797
- max-height: 48px !important;
798
- font-size: 13px !important;
799
- padding: 13px 16px !important; /* ์œ„์•„๋ž˜ ํŒจ๋”ฉ์„ ์ค„์—ฌ์„œ ๋†’์ด์— ๋งž๊ฒŒ ์กฐ์ ˆ */
800
- line-height: 1.5 !important;
801
- border-radius: 8px !important;
802
- border: 1px solid #cbd5e1 !important;
803
- background: rgba(255, 255, 255, 0.8) !important;
804
- color: #0f172a !important; /* ์ž…๋ ฅ ํ…์ŠคํŠธ ๊ฒ€์ •์ƒ‰ */
805
- resize: none !important; /* ์„ธ๋กœ ํฌ๊ธฐ ์กฐ์ ˆ ๋ฐฉ์ง€ */
806
- overflow-y: hidden !important; /* ์Šคํฌ๋กค๋ฐ” ๊ฐ์ถค */
807
- box-sizing: border-box !important;
808
- }
809
- textarea:focus {
810
- border-color: #0ea5e9 !important; /* ํฌ์ปค์Šค ์‹œ ์Šค์นด์ด ๋ธ”๋ฃจ */
811
- background: #ffffff !important;
812
- }
813
- .dark textarea {
814
- background: rgba(30, 41, 59, 0.8) !important;
815
- border-color: rgba(14, 165, 233, 0.25) !important;
816
- color: white !important;
817
- }
818
-
819
- /* ์ฑ—๋ด‡ ์ž…๋ ฅ์ฐฝ๊ณผ ์ „์†ก ๋ฒ„ํŠผ ์„ธ๋กœ ๋†’์ด ์™„๋ฒฝ ๋™๊ธฐํ™” ๋ฐ ์—ฌ๋ฐฑ ๋ถ„๋ฆฌ */
820
- button[class*="submit-btn"],
821
- [data-testid="submit-button"],
822
- #submit-btn {
823
- margin-left: 12px !important;
824
- border-radius: 8px !important;
825
- min-width: 95px !important;
826
- height: 48px !important; /* ์ž…๋ ฅ์ฐฝ์˜ height(48px)์™€ 100% ๋™์ผํ•˜๊ฒŒ ์ผ์น˜ */
827
- padding: 0 16px !important;
828
- display: flex !important;
829
- align-items: center !important;
830
- justify-content: center !important;
831
- box-sizing: border-box !important;
832
- }
833
- div:has(> button[class*="submit-btn"]),
834
- div:has(> [data-testid="submit-button"]),
835
- .input-container,
836
- [class*="input-container"] {
837
- gap: 12px !important;
838
- align-items: center !important; /* ์ˆ˜์ง์ถ• ๊ธฐ์ค€์œผ๋กœ ์ค‘์•™ ์ •๋ ฌ */
839
- }
840
-
841
- /* ์ฑ—๋ด‡ ๋‹ต๋ณ€ ๋งˆํฌ๋‹ค์šด ๊ฐ€๋…์„ฑ ๋ฐ ์ž๊ฐ„/์ค„๊ฐ„๊ฒฉ ์ตœ์ ํ™” (์ธ๋ผ์ธ ์š”์–‘์˜ ์ˆ˜์ง ์„ /๋ณด๋” ์›์ฒœ ์ฐจ๋‹จ) */
842
- .message p, .message li, [class*="message"] p, [class*="message"] li {
843
- line-height: 1.68 !important;
844
- margin-bottom: 14px !important;
845
- letter-spacing: -0.01em !important;
846
- border: none !important;
847
- border-left: none !important;
848
- border-right: none !important;
849
- box-shadow: none !important;
850
- }
851
- .message blockquote, [class*="message"] blockquote {
852
- border: none !important;
853
- border-left: none !important;
854
- border-right: none !important;
855
- padding: 0 !important;
856
- margin: 0 !important;
857
- box-shadow: none !important;
858
- }
859
- .message h3, [class*="message"] h3 {
860
- margin-top: 24px !important;
861
- margin-bottom: 12px !important;
862
- font-weight: 800 !important;
863
- }
864
- """
865
 
866
  CHATBOT_DESCRIPTION = """
867
  <div class="prose">
@@ -904,14 +353,14 @@ blocks_kwargs = {}
904
  if gradio_major < 5:
905
  interface_kwargs["theme"] = theme_obj
906
  blocks_kwargs["theme"] = theme_obj
907
- blocks_kwargs["css"] = custom_css
908
  elif gradio_major < 6:
909
  launch_kwargs["theme"] = theme_obj
910
  blocks_kwargs["theme"] = theme_obj
911
- blocks_kwargs["css"] = custom_css
912
  else:
913
  launch_kwargs["theme"] = theme_obj
914
- launch_kwargs["css"] = custom_css
915
 
916
  # Blocks๋ฅผ ํ™œ์šฉํ•œ 2์ปฌ๋Ÿผ ๋ ˆ์ด์•„์›ƒ ๋Œ€์‹œ๋ณด๋“œ ๊ฐœํŽธ
917
  with gr.Blocks(**blocks_kwargs) as demo:
 
14
  import gradio as gr
15
  from langgraph.graph import END, StateGraph
16
 
17
+ from src.retrieval.finRetrieval import HybridResult, graphrag
18
+ from src.utils.ui_templates import CUSTOM_CSS, build_stats_html
19
 
20
  dotenv.load_dotenv()
21
 
 
45
  class ChatState(TypedDict):
46
  question: str # ์‚ฌ์šฉ์ž ์งˆ๋ฌธ
47
  history: List[dict] # ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ [{"role": "user"/"assistant", "content": "..."}]
48
+ context: str # GraphRAG ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ๋˜๋Š” ์ผ๋ฐ˜ ์ง€์‹ ๋‹ต๋ณ€
49
+ answer: str # ์ตœ์ข… ๋‹ต๋ณ€
50
+ mode: str # "graph": ๊ทธ๋ž˜ํ”„ ๊ธฐ๋ฐ˜ | "general": ์ผ๋ฐ˜ ์ง€์‹ ๊ธฐ๋ฐ˜
51
 
52
 
53
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
56
 
57
 
58
  def retrieve_node(state: ChatState) -> ChatState:
59
+ """Node 1: search_with_fallback์œผ๋กœ ๊ทธ๋ž˜ํ”„ ๊ฒ€์ƒ‰ ๋˜๋Š” ์ผ๋ฐ˜ ์ง€์‹ ์‘๋‹ต ๋ผ์šฐํŒ…"""
60
  try:
61
+ hybrid: HybridResult = graphrag.search_with_fallback(
62
+ query_text=state["question"],
63
+ history=state["history"],
64
+ )
65
+
66
+ if hybrid.mode == "general":
67
+ # ์ผ๋ฐ˜ ์ง€์‹ ๋ชจ๋“œ: ๋ฐฐ๋„ˆ + GPT-4o ๋‹ต๋ณ€ ๋ฐ˜ํ™˜
68
+ disclaimer = (
69
+ "> โš ๏ธ **์ง€์‹ ๊ทธ๋ž˜ํ”„์—์„œ ๊ด€๋ จ ๋‰ด์Šค๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.**\n"
70
+ "> GPT-4o์˜ ์ผ๋ฐ˜ ํ•™์Šต ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋‹ต๋ณ€ํ•ฉ๋‹ˆ๋‹ค.\n"
71
+ "> ์ตœ์‹  ๊ตญ๋‚ด ๋‰ด์Šค ๊ธฐ๋ฐ˜ ์ •๋ณด๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋ฉด ์งˆ๋ฌธ์„ ๋” ๊ตฌ์ฒด์ ์œผ๋กœ ์ž…๋ ฅํ•ด ๋ณด์„ธ์š”.\n\n"
72
+ "---\n\n"
73
+ )
74
+ context = disclaimer + hybrid.answer
75
+ return {**state, "context": context, "mode": "general"}
76
+
77
+ # ๊ทธ๋ž˜ํ”„ ๊ธฐ๋ฐ˜ ๋ชจ๋“œ: ๊ธฐ์กด ์ถœ์ฒ˜ ์ถ”์ถœ + ๋‰ด์Šค ํ”ผ๋“œ ๋กœ์ง
78
+ context = hybrid.answer
79
  sources = []
80
+ seen_urls: set = set()
81
+
82
+ # retriever_result์—์„œ ์ƒ์œ„ 3๊ฐœ ๋‰ด์Šค ์ถœ์ฒ˜ ์ถ”์ถœ
83
+ retriever_result = hybrid.retriever_result
84
+ if retriever_result and hasattr(retriever_result, "items"):
85
+ for item in retriever_result.items:
86
  meta = getattr(item, "metadata", {})
87
  title = meta.get("article_title")
88
  url = meta.get("article_url")
 
161
 
162
  except Exception as e:
163
  context = f"[๊ฒ€์ƒ‰ ์˜ค๋ฅ˜: {e}]"
164
+ return {**state, "context": context, "mode": state.get("mode", "graph")}
165
 
166
 
167
  def generate_node(state: ChatState) -> ChatState:
168
  """Node 2: ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ๋ฅผ ๊ณ ๋ คํ•˜์—ฌ ์ตœ์ข… ๋‹ต๋ณ€ ์ƒ์„ฑ
169
 
170
+ GraphRAG(graph ๋ชจ๋“œ) ๋˜๋Š” ์ผ๋ฐ˜ ์ง€์‹(general ๋ชจ๋“œ) ์‘๋‹ต ๋ชจ๋‘
171
+ retrieve_node์—์„œ context์— ์ตœ์ข… ํ…์ŠคํŠธ๋ฅผ ๋‹ด์•„์ฃผ๋ฏ€๋กœ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
172
  """
 
 
173
  answer = state["context"] if state["context"] else "๊ด€๋ จ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
174
  return {**state, "answer": answer}
175
 
 
214
  "history": history,
215
  "context": "",
216
  "answer": "",
217
+ "mode": "",
218
  }
219
 
220
  yield "๐Ÿ” ์‹ค์‹œ๊ฐ„ ์ง€์‹ ๊ทธ๋ž˜ํ”„์—์„œ ๊ด€๋ จ ๋‰ด์Šค๋ฅผ ๊ฒ€์ƒ‰ํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค..."
 
223
  # LangGraph์˜ stream์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ฐ ๋…ธ๋“œ ์‹คํ–‰ ์‹œ์ ๋งˆ๋‹ค ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›์Œ
224
  for event in chat_graph.stream(state):
225
  if "retrieve" in event:
226
+ retrieved_mode = event["retrieve"].get("mode", "graph")
227
+ if retrieved_mode == "general":
228
+ yield "๐ŸŒ ๊ด€๋ จ ๋‰ด์Šค ์—†์Œ โ€” GPT-4o ์ผ๋ฐ˜ ์ง€์‹์œผ๋กœ ๋‹ต๋ณ€์„ ์ƒ์„ฑํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค..."
229
+ else:
230
+ yield "๐Ÿ’ก ๊ฒ€์ƒ‰ ์™„๋ฃŒ! ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์ตœ์ข… ๋‹ต๋ณ€์„ ์ƒ์„ฑํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค..."
231
  elif "generate" in event:
232
  yield event["generate"]["answer"]
233
  except Exception as e:
 
295
  return stats
296
 
297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
299
  # 5. Gradio UI ๊ตฌ์„ฑ
300
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
311
  secondary_hue="slate",
312
  )
313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
  CHATBOT_DESCRIPTION = """
316
  <div class="prose">
 
353
  if gradio_major < 5:
354
  interface_kwargs["theme"] = theme_obj
355
  blocks_kwargs["theme"] = theme_obj
356
+ blocks_kwargs["css"] = CUSTOM_CSS
357
  elif gradio_major < 6:
358
  launch_kwargs["theme"] = theme_obj
359
  blocks_kwargs["theme"] = theme_obj
360
+ blocks_kwargs["css"] = CUSTOM_CSS
361
  else:
362
  launch_kwargs["theme"] = theme_obj
363
+ launch_kwargs["css"] = CUSTOM_CSS
364
 
365
  # Blocks๋ฅผ ํ™œ์šฉํ•œ 2์ปฌ๋Ÿผ ๋ ˆ์ด์•„์›ƒ ๋Œ€์‹œ๋ณด๋“œ ๊ฐœํŽธ
366
  with gr.Blocks(**blocks_kwargs) as demo:
src/retrieval/finRetrieval.py CHANGED
@@ -12,6 +12,8 @@ app.py์—์„œ importํ•˜์—ฌ Gradio ์ฑ—๋ด‡๊ณผ ์—ฐ๋™ํ•ฉ๋‹ˆ๋‹ค.
12
 
13
  import logging
14
  import os
 
 
15
 
16
  # Neo4j DBMS server warning (Deprecated vector queryNodes ๋“ฑ) ๋กœ๊น… ์ฐจ๋‹จ
17
  logging.getLogger("neo4j").setLevel(logging.ERROR)
@@ -31,6 +33,15 @@ from neo4j_graphrag.retrievers import (
31
  dotenv.load_dotenv()
32
 
33
 
 
 
 
 
 
 
 
 
 
34
  def get_neo4j_driver() -> neo4j.Driver:
35
  uri = os.getenv("NEO4J_URI", "neo4j://localhost:7687")
36
  client_id = os.getenv("NEO4J_CLIENT_ID")
@@ -136,7 +147,6 @@ CYPHER QUERY:
136
  # 3. ToolsRetriever + GraphRAG ์กฐ๋ฆฝ
137
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
138
 
139
- from typing import Any
140
 
141
  from neo4j_graphrag.retrievers.base import Retriever
142
  from neo4j_graphrag.types import RawSearchResult, RetrieverResult
@@ -230,17 +240,20 @@ _prompt_template = CustomRagTemplate(
230
 
231
  class LazyGraphRAG:
232
  """์ž„ํฌํŠธ ์‹œ์ ์— DB ์—ฐ๊ฒฐ์„ ๋ฐฉ์ง€ํ•˜๊ณ  ์‹ค์ œ ํ˜ธ์ถœ๋  ๋•Œ GraphRAG ์ธ์Šคํ„ด์Šค๋ฅผ ์ดˆ๊ธฐํ™”ํ•˜๋Š” ์ง€์—ฐ ํ‰๊ฐ€ ํ”„๋ก์‹œ"""
 
233
  def __init__(self) -> None:
234
  self._graphrag: Any = None
 
 
235
 
236
  def _init_once(self) -> None:
237
  if self._graphrag is not None:
238
  return
239
 
240
  # OpenAI ํด๋ผ์ด์–ธํŠธ ๋ฐ ์ž„๋ฒ ๋” ์ง€์—ฐ ์ดˆ๊ธฐํ™” (CI ํฌ๋ž˜์‹œ ๋ฐฉ์ง€)
241
- rag_llm = OpenAILLM(model_name="gpt-4o", model_params={"temperature": 0})
242
  embedder = OpenAIEmbeddings(model="text-embedding-3-small")
243
-
244
  driver = get_neo4j_driver()
245
 
246
  vector_cypher_retriever = VectorCypherRetriever(
@@ -252,14 +265,14 @@ class LazyGraphRAG:
252
 
253
  text2cypher_retriever = Text2CypherRetriever(
254
  driver=driver,
255
- llm=rag_llm,
256
  neo4j_schema=_get_schema(driver),
257
  examples=_examples,
258
  )
259
 
260
  tools_retriever = ToolsRetriever(
261
  driver=driver,
262
- llm=rag_llm,
263
  tools=[
264
  vector_cypher_retriever.convert_to_tool(
265
  name="vector_retriever",
@@ -271,18 +284,134 @@ class LazyGraphRAG:
271
  ),
272
  ],
273
  )
274
-
275
- hybrid_retriever = HybridFallbackRetriever(
276
  tools_retriever=tools_retriever,
277
  fallback_retriever=vector_cypher_retriever,
278
  )
279
-
280
  self._graphrag = GraphRAG(
281
- llm=rag_llm,
282
- retriever=hybrid_retriever,
283
  prompt_template=_prompt_template,
284
  )
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  def search(self, *args: Any, **kwargs: Any) -> Any:
287
  self._init_once()
288
  assert self._graphrag is not None
 
12
 
13
  import logging
14
  import os
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
 
18
  # Neo4j DBMS server warning (Deprecated vector queryNodes ๋“ฑ) ๋กœ๊น… ์ฐจ๋‹จ
19
  logging.getLogger("neo4j").setLevel(logging.ERROR)
 
33
  dotenv.load_dotenv()
34
 
35
 
36
+ @dataclass
37
+ class HybridResult:
38
+ """GraphRAG ๋˜๋Š” ์ผ๋ฐ˜ ์ง€์‹ ๊ธฐ๋ฐ˜ ํ†ตํ•ฉ ์‘๋‹ต ๊ฒฐ๊ณผ"""
39
+
40
+ answer: str # ์ตœ์ข… ๋‹ต๋ณ€ ๋ฌธ์ž์—ด
41
+ mode: str # "graph": ๊ทธ๋ž˜ํ”„ ๊ฒ€์ƒ‰ ๊ธฐ๋ฐ˜ | "general": GPT-4o ์ผ๋ฐ˜ ์ง€์‹ ๊ธฐ๋ฐ˜
42
+ retriever_result: Any = None # RetrieverResult (mode="graph"์ผ ๋•Œ๋งŒ ์œ ํšจ)
43
+
44
+
45
  def get_neo4j_driver() -> neo4j.Driver:
46
  uri = os.getenv("NEO4J_URI", "neo4j://localhost:7687")
47
  client_id = os.getenv("NEO4J_CLIENT_ID")
 
147
  # 3. ToolsRetriever + GraphRAG ์กฐ๋ฆฝ
148
  # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
149
 
 
150
 
151
  from neo4j_graphrag.retrievers.base import Retriever
152
  from neo4j_graphrag.types import RawSearchResult, RetrieverResult
 
240
 
241
  class LazyGraphRAG:
242
  """์ž„ํฌํŠธ ์‹œ์ ์— DB ์—ฐ๊ฒฐ์„ ๋ฐฉ์ง€ํ•˜๊ณ  ์‹ค์ œ ํ˜ธ์ถœ๋  ๋•Œ GraphRAG ์ธ์Šคํ„ด์Šค๋ฅผ ์ดˆ๊ธฐํ™”ํ•˜๋Š” ์ง€์—ฐ ํ‰๊ฐ€ ํ”„๋ก์‹œ"""
243
+
244
  def __init__(self) -> None:
245
  self._graphrag: Any = None
246
+ self._hybrid_retriever: Any = None # ํ’ˆ์งˆ ํ‰๊ฐ€์šฉ ์ง์ ‘ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๋ฆฌํŠธ๋ฆฌ๋ฒ„
247
+ self._rag_llm: Any = None # ์ผ๋ฐ˜ ์ง€์‹ ๋‹ต๋ณ€ ์ƒ์„ฑ์šฉ LLM
248
 
249
  def _init_once(self) -> None:
250
  if self._graphrag is not None:
251
  return
252
 
253
  # OpenAI ํด๋ผ์ด์–ธํŠธ ๋ฐ ์ž„๋ฒ ๋” ์ง€์—ฐ ์ดˆ๊ธฐํ™” (CI ํฌ๋ž˜์‹œ ๋ฐฉ์ง€)
254
+ self._rag_llm = OpenAILLM(model_name="gpt-4o", model_params={"temperature": 0})
255
  embedder = OpenAIEmbeddings(model="text-embedding-3-small")
256
+
257
  driver = get_neo4j_driver()
258
 
259
  vector_cypher_retriever = VectorCypherRetriever(
 
265
 
266
  text2cypher_retriever = Text2CypherRetriever(
267
  driver=driver,
268
+ llm=self._rag_llm,
269
  neo4j_schema=_get_schema(driver),
270
  examples=_examples,
271
  )
272
 
273
  tools_retriever = ToolsRetriever(
274
  driver=driver,
275
+ llm=self._rag_llm,
276
  tools=[
277
  vector_cypher_retriever.convert_to_tool(
278
  name="vector_retriever",
 
284
  ),
285
  ],
286
  )
287
+
288
+ self._hybrid_retriever = HybridFallbackRetriever(
289
  tools_retriever=tools_retriever,
290
  fallback_retriever=vector_cypher_retriever,
291
  )
292
+
293
  self._graphrag = GraphRAG(
294
+ llm=self._rag_llm,
295
+ retriever=self._hybrid_retriever,
296
  prompt_template=_prompt_template,
297
  )
298
 
299
+ def _is_context_sufficient(self, query_text: str, history: list, retriever_result: Any) -> bool:
300
+ """๊ฒ€์ƒ‰๋œ ์ปจํ…์ŠคํŠธ๊ฐ€ ์งˆ๋ฌธ ๋ฐ ์ด์ „ ๋Œ€ํ™” ํ๋ฆ„์— ์‹ค์งˆ์ ์œผ๋กœ ๋„์›€์ด ๋˜๋Š” ๊ธˆ์œต/๊ธฐ์ˆ  ๋‰ด์Šค ๋ฐ์ดํ„ฐ์ธ์ง€ GPT-4o๋กœ ํŒ๋‹จ"""
301
+ if retriever_result is None:
302
+ return False
303
+ if not hasattr(retriever_result, "items") or not retriever_result.items:
304
+ return False
305
+ total_content = " ".join(
306
+ getattr(item, "content", "") for item in retriever_result.items
307
+ ).strip()
308
+ if len(total_content) < 100:
309
+ return False
310
+
311
+ # GPT-4o ๊ธฐ๋ฐ˜ ์ง€๋Šฅ์  ์ž๊ฐ€ ์ง„๋‹จ (์ด์ „ ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ ๋ฐ ์งˆ๋ฌธ์˜ ๋งฅ๋ฝ ๊ฒฐํ•ฉ ํŒ์ •)
312
+ try:
313
+ assert self._rag_llm is not None
314
+ context_snippet = total_content[:800]
315
+
316
+ # ์ด์ „ ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ์˜ ๋งฅ๋ฝ ์š”์•ฝ ์ถ”์ถœ (์ตœ๊ทผ 3๊ฐœ ๋ฉ”์‹œ์ง€)
317
+ normalized_history = self._normalize_history(history)
318
+ history_summary = "์—†์Œ"
319
+ if normalized_history:
320
+ history_summary = "\n".join(
321
+ f"- {msg['role']}: {msg['content'][:150]}"
322
+ for msg in normalized_history[-3:]
323
+ )
324
+
325
+ routing_prompt = (
326
+ "๋‹น์‹ ์€ ๊ธˆ์œต/๊ธฐ์ˆ  ํŠธ๋ Œ๋“œ RAG ์‹œ์Šคํ…œ์˜ ์ง€๋Šฅํ˜• ๋ผ์šฐํ„ฐ์ž…๋‹ˆ๋‹ค.\n"
327
+ "์‚ฌ์šฉ์ž์˜ [ํ˜„์žฌ ์งˆ๋ฌธ] ๋ฐ [์ตœ๊ทผ ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ]๊ฐ€ ์•„๋ž˜ ์ œ๊ณต๋œ [๊ฒ€์ƒ‰๋œ ๋‰ด์Šค ๋ฐ์ดํ„ฐ]์™€ ์˜๋ฏธ์ ์œผ๋กœ ๋ฐ€์ ‘ํ•˜๊ฒŒ ์—ฐ๊ด€๋˜์–ด ์žˆ๊ณ , "
328
+ "ํ•ด๋‹น ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์งˆ๋ฌธ์— ์‹ค์ œ ๊ตฌ์ฒด์ ์ด๊ณ  ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ๋‹ต๋ณ€์„ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ๋Š”์ง€ ํ‰๊ฐ€ํ•˜์„ธ์š”.\n\n"
329
+ "ํŠนํžˆ, ํ˜„์žฌ ์งˆ๋ฌธ์ด '๊ทธ๊ฑฐ์— ๋Œ€ํ•ด ์ข€ ๋” ์„ค๋ช…ํ•ด์ค˜'๋‚˜ '์ž์†Œ์„œ ํŒ์„ ๋” ๋‹ค๋“ฌ์–ด์ค˜'์™€ ๊ฐ™์€ ํ›„์† ๋Œ€ํ™”ํ˜• ์งˆ๋ฌธ์ผ ๊ฒฝ์šฐ, "
330
+ "[์ตœ๊ทผ ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ]์— ๋ช…์‹œ๋œ ์ฃผ์š” ๊ธˆ์œต/๊ธฐ์ˆ  ํŠธ๋ Œ๋“œ ์ฃผ์ œ(์˜ˆ: ์‚ผ์„ฑ์ „์ž AI, ์นด์นด์˜ค AI ๋“ฑ)๊ฐ€ "
331
+ "์•„๋ž˜ ๋‰ด์Šค ๋ฐ์ดํ„ฐ์˜ ํ•ต์‹ฌ ๋‚ด์šฉ๊ณผ ์ผ์น˜ํ•˜๋Š”์ง€ ์ข…ํ•ฉ์ ์œผ๋กœ ๊ณ ๋ คํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.\n\n"
332
+ "๋งŒ์•ฝ ์งˆ๋ฌธ ๋ฐ ๋Œ€ํ™” ๋งฅ๋ฝ์ด ์•„๋ž˜ ๋‰ด์Šค ๋ฐ์ดํ„ฐ์™€ ์ „ํ˜€ ๋ฌด๊ด€ํ•œ ์ผ๋ฐ˜ ์ƒ์‹, ์ผ์ƒ์ ์ธ ๋Œ€ํ™”, ์ˆ˜ํ•™, ์˜ˆ์ˆ  ๋“ฑ "
333
+ "์ง€์‹ ๊ทธ๋ž˜ํ”„(๋‰ด์Šค ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค)์— ์—†๋Š” ์ฃผ์ œ์˜ ์งˆ๋ฌธ์ด๋ผ๋ฉด ๋ฐ˜๋“œ์‹œ 'NO'๋ผ๊ณ  ๋‹ตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.\n"
334
+ "๋‰ด์Šค ํŒฉํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒฐํ•ฉํ•˜์—ฌ ์˜ฌ๋ฐ”๋ฅธ ๋‹ต๋ณ€์„ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋Š” ๋งฅ๋ฝ์ด๋ผ๋ฉด 'YES', ๊ทธ๋ ‡์ง€ ์•Š๋‹ค๋ฉด 'NO'๋ผ๊ณ ๋งŒ ๋‹ตํ•˜์„ธ์š”.\n\n"
335
+ f"[์ตœ๊ทผ ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ]\n{history_summary}\n\n"
336
+ f"[ํ˜„์žฌ ์งˆ๋ฌธ]\n{query_text}\n\n"
337
+ f"[๊ฒ€์ƒ‰๋œ ๋‰ด์Šค ๋ฐ์ดํ„ฐ]\n{context_snippet}\n\n"
338
+ "ํŒ์ • (YES ๋˜๋Š” NO๋กœ๋งŒ ๋‹ต๋ณ€):"
339
+ )
340
+ # ์•„์ฃผ ๋น ๋ฅด๊ณ  ์ €๋ ดํ•œ ๋‹จ์ผ ํ† ํฐ YES/NO ์‘๋‹ต ์ƒ์„ฑ
341
+ response = self._rag_llm.invoke(
342
+ input=routing_prompt,
343
+ model_params={"temperature": 0, "max_tokens": 5}
344
+ )
345
+ decision = str(response.content).strip().upper()
346
+ return "YES" in decision
347
+ except Exception:
348
+ # ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ ์•ˆ์ „์„ ์œ„ํ•ด ๊ธฐ์กด์˜ ๊ธฐ๋ณธ ๊ธธ์ด ๊ธฐ๋ฐ˜ ํŒ์ •์œผ๋กœ ํด๋ฐฑ
349
+ return len(total_content) >= 100
350
+
351
+ def _normalize_history(self, history: list) -> list:
352
+ """Gradio ํžˆ์Šคํ† ๋ฆฌ(dict ๋˜๋Š” tuple ํ˜•์‹)๋ฅผ LLM message_history ํ˜•์‹์œผ๋กœ ์ •๊ทœํ™”"""
353
+ normalized: list = []
354
+ for msg in history:
355
+ if isinstance(msg, dict) and "role" in msg and "content" in msg:
356
+ normalized.append({"role": msg["role"], "content": str(msg["content"])})
357
+ elif isinstance(msg, (list, tuple)) and len(msg) == 2:
358
+ if msg[0]:
359
+ normalized.append({"role": "user", "content": str(msg[0])})
360
+ if msg[1]:
361
+ normalized.append({"role": "assistant", "content": str(msg[1])})
362
+ return normalized
363
+
364
+ def _generate_general_answer(self, query_text: str, history: list) -> str:
365
+ """๊ทธ๋ž˜ํ”„ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์—†์ด GPT-4o ์ผ๋ฐ˜ ์ง€์‹์œผ๋กœ ๋‹ต๋ณ€ ์ƒ์„ฑ (๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ ๋ฐ˜์˜)"""
366
+ assert self._rag_llm is not None
367
+ system_prompt = (
368
+ "๋‹น์‹ ์€ AI ๋ฐ ํ•€ํ…Œํฌ ๊ธฐ์ˆ  ํŠธ๋ Œ๋“œ ์ „๋ฌธ๊ฐ€์ด์ž, ์ทจ์—… ์ค€๋น„์ƒ์˜ ์—ญ๋Ÿ‰ ๋ถ„์„์„ ๋•๋Š” ์ „๋žต ์ปจ์„คํ„ดํŠธ์ž…๋‹ˆ๋‹ค.\n"
369
+ "ํ˜„์žฌ FinGraph ์ง€์‹ ๊ทธ๋ž˜ํ”„(Neo4j GraphRAG)์—์„œ ๊ด€๋ จ ๋‰ด์Šค ๊ธฐ์‚ฌ๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.\n"
370
+ "์ด์ „ ๋Œ€ํ™” ๋งฅ๋ฝ์„ ์ถฉ๋ถ„ํžˆ ๋ฐ˜์˜ํ•˜๊ณ , GPT-4o์˜ ์ผ๋ฐ˜ ํ•™์Šต ๋ฐ์ดํ„ฐ์— ๊ธฐ๋ฐ˜ํ•˜์—ฌ ์ตœ์„ ์„ ๋‹คํ•ด ์ „๋ฌธ์ ์œผ๋กœ ๋‹ต๋ณ€ํ•ด ์ฃผ์„ธ์š”.\n\n"
371
+ "[์ค‘์š” ์ง€์นจ]\n"
372
+ "- ์‹ค์ œ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋‰ด์Šค ๋งํฌ, ๋‚ ์งœ, ๊ฐ€์งœ URL์„ ์ ˆ๋Œ€ ์ƒ์„ฑํ•˜์ง€ ๋งˆ์„ธ์š”.\n"
373
+ "- ๊ฐ€๋Šฅํ•˜๋‹ค๋ฉด ์ทจ์—… ์ค€๋น„์ƒ์ด ๋ฉด์ ‘/์ž์†Œ์„œ์— ํ™œ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์‹ค์งˆ์ ์ธ ์ธ์‚ฌ์ดํŠธ๋ฅผ ํฌํ•จํ•ด ์ฃผ์„ธ์š”.\n"
374
+ "- ๋‹ต๋ณ€์ด ์ผ๋ฐ˜ AI ํ•™์Šต ๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜์ž„์„ ์ˆจ๊ธฐ์ง€ ๋ง๊ณ  ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์–ธ๊ธ‰ํ•˜๋ฉฐ ์‹œ์ž‘ํ•˜์„ธ์š”."
375
+ )
376
+ normalized_history = self._normalize_history(history)
377
+ response = self._rag_llm.invoke(
378
+ input=query_text,
379
+ message_history=normalized_history,
380
+ system_instruction=system_prompt,
381
+ )
382
+ return str(response.content)
383
+
384
+ def search_with_fallback(self, query_text: str, history: list) -> HybridResult:
385
+ """GraphRAG ๊ฒ€์ƒ‰ -> ์ปจํ…์ŠคํŠธ ํ’ˆ์งˆ ํ‰๊ฐ€ -> ์ผ๋ฐ˜ ์ง€์‹ Fallback ํ†ตํ•ฉ ๋ฉ”์„œ๋“œ.
386
+
387
+ Args:
388
+ query_text: ์‚ฌ์šฉ์ž ์งˆ๋ฌธ ํ…์ŠคํŠธ
389
+ history: ์ด์ „ ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ (Gradio ํ˜•์‹)
390
+
391
+ Returns:
392
+ HybridResult: ๋‹ต๋ณ€, ๋ชจ๋“œ("graph"|"general"), RetrieverResult
393
+ """
394
+ self._init_once()
395
+ assert self._hybrid_retriever is not None
396
+ assert self._graphrag is not None
397
+
398
+ # 1๋‹จ๊ณ„: LLM ํ˜ธ์ถœ ์—†์ด DB ์ฟผ๋ฆฌ๋งŒ์œผ๋กœ ๊ฒ€์ƒ‰ ์‹คํ–‰
399
+ retriever_result = self._hybrid_retriever.search(query_text=query_text)
400
+
401
+ # 2๋‹จ๊ณ„: ์ปจํ…์ŠคํŠธ ํ’ˆ์งˆ ํ‰๊ฐ€ ํ›„ ๋ผ์šฐํŒ…
402
+ if self._is_context_sufficient(query_text, history, retriever_result):
403
+ # 3a. ๊ทธ๋ž˜ํ”„ ๊ธฐ๋ฐ˜ -> GraphRAG ๋ธŒ๋ฆฌํ•‘ ๋‹ต๋ณ€ ์ƒ์„ฑ
404
+ rag_result = self._graphrag.search(query_text=query_text)
405
+ return HybridResult(
406
+ answer=rag_result.answer,
407
+ mode="graph",
408
+ retriever_result=rag_result.retriever_result,
409
+ )
410
+ else:
411
+ # 3b. ์ผ๋ฐ˜ ์ง€์‹ ๊ธฐ๋ฐ˜ -> ํžˆ๏ฟฝ๏ฟฝ๏ฟฝํ† ๋ฆฌ ํฌํ•จ GPT-4o ์ง์ ‘ ํ˜ธ์ถœ
412
+ answer = self._generate_general_answer(query_text, history)
413
+ return HybridResult(answer=answer, mode="general", retriever_result=None)
414
+
415
  def search(self, *args: Any, **kwargs: Any) -> Any:
416
  self._init_once()
417
  assert self._graphrag is not None
src/utils/ui_templates.py CHANGED
@@ -324,6 +324,12 @@ label.svelte-1ipelgc, span.svelte-1ipelgc {
324
  display: none !important;
325
  }
326
 
 
 
 
 
 
 
327
  /* โ”€โ”€ ์‚ฌ์šฉ์ž ๋ฒ„๋ธ” โ”€โ”€ */
328
  .message.user {
329
  background: rgba(30,58,95,0.06) !important;
@@ -349,26 +355,32 @@ label.svelte-1ipelgc, span.svelte-1ipelgc {
349
  background: transparent !important;
350
  }
351
 
352
- /* โ”€โ”€ ๋ฉ”์‹œ์ง€ ๋‚ด๋ถ€ ๋ณด๋” ์™„์ „ ์ œ๊ฑฐ โ”€โ”€ */
353
  .message p, .message li,
354
  [class*="message"] p, [class*="message"] li {
355
- line-height: 1.68 !important;
356
- margin-bottom: 12px !important;
 
 
357
  border: none !important;
358
  border-left: none !important;
359
  box-shadow: none !important;
360
  background: transparent !important;
361
  color: #1e3a5f !important;
362
  }
 
 
 
363
  .message blockquote, [class*="message"] blockquote {
364
  border: none !important;
365
  border-left: none !important;
366
  padding: 0 !important;
 
367
  background: transparent !important;
368
  }
369
  .message h3, [class*="message"] h3 {
370
- margin-top: 18px !important;
371
- margin-bottom: 8px !important;
372
  font-weight: 800 !important;
373
  color: #1e3a5f !important;
374
  }
 
324
  display: none !important;
325
  }
326
 
327
+ /* โ”€โ”€ ๋ฉ”์‹œ์ง€ ๋ฒ„๋ธ” ๊ธฐ๋ณธ ํฌ๊ธฐ ์ถ•์†Œ (์‚ฌ์šฉ์ž/๋ด‡ ๊ณตํ†ต ์„ธ๋กœ ๋†’์ด ์ตœ์ ํ™”) โ”€โ”€ */
328
+ .message {
329
+ padding: 10px 14px !important;
330
+ min-height: auto !important;
331
+ }
332
+
333
  /* โ”€โ”€ ์‚ฌ์šฉ์ž ๋ฒ„๋ธ” โ”€โ”€ */
334
  .message.user {
335
  background: rgba(30,58,95,0.06) !important;
 
355
  background: transparent !important;
356
  }
357
 
358
+ /* โ”€โ”€ ๋ฉ”์‹œ์ง€ ๋‚ด๋ถ€ ์—ฌ๋ฐฑ ์™„์ „ ์ œ์–ด โ”€โ”€ */
359
  .message p, .message li,
360
  [class*="message"] p, [class*="message"] li {
361
+ line-height: 1.55 !important;
362
+ margin: 0 !important;
363
+ margin-bottom: 6px !important;
364
+ padding: 0 !important;
365
  border: none !important;
366
  border-left: none !important;
367
  box-shadow: none !important;
368
  background: transparent !important;
369
  color: #1e3a5f !important;
370
  }
371
+ .message p:last-child, .message li:last-child {
372
+ margin-bottom: 0 !important;
373
+ }
374
  .message blockquote, [class*="message"] blockquote {
375
  border: none !important;
376
  border-left: none !important;
377
  padding: 0 !important;
378
+ margin: 0 !important;
379
  background: transparent !important;
380
  }
381
  .message h3, [class*="message"] h3 {
382
+ margin-top: 14px !important;
383
+ margin-bottom: 6px !important;
384
  font-weight: 800 !important;
385
  color: #1e3a5f !important;
386
  }
tests/test_retrieval.py CHANGED
@@ -41,3 +41,30 @@ def test_portfolio_showcase_aggregation_query():
41
  assert any(indicator in answer for indicator in ["1.", "์ฒซ์งธ", "TOP", "๊ธฐ์‚ฌ", "์ถœ์ฒ˜"])
42
 
43
  print(f"\nโœจ [ํฌํŠธํด๋ฆฌ์˜ค ์‡ผ์ผ€์ด์Šค RAG ๊ฒฐ๊ณผ]\n{answer}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  assert any(indicator in answer for indicator in ["1.", "์ฒซ์งธ", "TOP", "๊ธฐ์‚ฌ", "์ถœ์ฒ˜"])
42
 
43
  print(f"\nโœจ [ํฌํŠธํด๋ฆฌ์˜ค ์‡ผ์ผ€์ด์Šค RAG ๊ฒฐ๊ณผ]\n{answer}")
44
+
45
+
46
+ @pytest.mark.skipif(
47
+ not has_credentials,
48
+ reason="OpenAI API Key ๋˜๋Š” Neo4j ์—ฐ๊ฒฐ ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์—†์œผ๋ฏ€๋กœ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋ฅผ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค."
49
+ )
50
+ def test_hybrid_fallback_general_query():
51
+ """
52
+ [ํ•˜์ด๋ธŒ๋ฆฌ๋“œ RAG Fallback ์‹œ๋‚˜๋ฆฌ์˜ค]
53
+ ์ง€์‹ ๊ทธ๋ž˜ํ”„(๋‰ด์Šค ๋ฐ์ดํ„ฐ)์— ์ „ํ˜€ ์ˆ˜์ง‘๋˜์ง€ ์•Š์€ ์ผ๋ฐ˜ ๊ณผํ•™/์—ญ์‚ฌ ์งˆ๋ฌธ์— ๋Œ€ํ•ด
54
+ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์ž„๊ณ„์น˜ ๋ฏธ๋งŒ์ž„์„ ๊ฐ์ง€ํ•˜๊ณ  ์ž๋™์œผ๋กœ GPT-4o ์ผ๋ฐ˜ ์ง€์‹ ๋ชจ๋“œ(general)๋กœ ๋ผ์šฐํŒ…ํ•˜๋Š”์ง€ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.
55
+ """
56
+ general_query = "ํ”ผํƒ€๊ณ ๋ผ์Šค ์ •๋ฆฌ์™€ ๊ทธ ์‹ค์ƒํ™œ ํ™œ์šฉ ์˜ˆ์‹œ๋ฅผ ๊ฐ„๋‹จํžˆ ์„ค๋ช…ํ•ด์ค˜."
57
+
58
+ # search_with_fallback์„ ํ†ตํ•œ ๋ผ์šฐํŒ… ๊ฒ€์ƒ‰ ์ˆ˜ํ–‰
59
+ result = graphrag.search_with_fallback(query_text=general_query, history=[])
60
+
61
+ # 1. ๋ฐ˜ํ™˜ ํƒ€์ž… ๋ฐ ๋ชจ๋“œ ๊ฒ€์ฆ
62
+ assert result is not None
63
+ assert result.mode == "general"
64
+
65
+ # 2. GPT-4o ์ผ๋ฐ˜ ์ง€์‹ ๋‹ต๋ณ€ ์œ ํšจ์„ฑ ๊ฒ€์ฆ
66
+ assert len(result.answer.strip()) > 0
67
+ assert "ํ”ผํƒ€๊ณ ๋ผ์Šค" in result.answer
68
+
69
+ print(f"\nโœจ [์ผ๋ฐ˜ ์ง€์‹ Fallback ๋ผ์šฐํŒ… ๊ฒฐ๊ณผ]\n{result.answer}")
70
+