umyunsang commited on
Commit
9e65b56
ยท
verified ยท
1 Parent(s): ee2e854

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes. ย  See raw diff
Files changed (50) hide show
  1. src/.DS_Store +0 -0
  2. src/cli/__init__.py +3 -0
  3. src/cli/approval_ui.py +239 -0
  4. src/cli/commands.py +49 -0
  5. src/cli/daemon.py +222 -0
  6. src/cli/http_client.py +237 -0
  7. src/cli/renderer.py +257 -0
  8. src/cli/shell.py +420 -0
  9. src/cli/terminal.py +56 -0
  10. src/data_collection_preprocessing/__init__.py +13 -0
  11. src/data_collection_preprocessing/__main__.py +5 -0
  12. src/data_collection_preprocessing/cli.py +76 -0
  13. src/data_collection_preprocessing/config.py +14 -0
  14. src/data_collection_preprocessing/parsers.py +235 -0
  15. src/data_collection_preprocessing/pipeline.py +177 -0
  16. src/inference/.gitkeep +0 -0
  17. src/inference/__init__.py +0 -0
  18. src/inference/actions/__init__.py +4 -0
  19. src/inference/actions/base.py +226 -0
  20. src/inference/actions/data_go_kr.py +952 -0
  21. src/inference/agent_loop.py +360 -0
  22. src/inference/agent_manager.py +134 -0
  23. src/inference/api_server.py +1899 -0
  24. src/inference/bm25_indexer.py +446 -0
  25. src/inference/db/__init__.py +67 -0
  26. src/inference/db/alembic/env.py +82 -0
  27. src/inference/db/alembic/script.py.mako +25 -0
  28. src/inference/db/alembic/versions/001_create_rag_tables.py +346 -0
  29. src/inference/db/converters.py +162 -0
  30. src/inference/db/crud.py +306 -0
  31. src/inference/db/database.py +77 -0
  32. src/inference/db/models.py +448 -0
  33. src/inference/document_processor.py +545 -0
  34. src/inference/feature_flags.py +58 -0
  35. src/inference/graph/__init__.py +30 -0
  36. src/inference/graph/builder.py +142 -0
  37. src/inference/graph/capabilities/__init__.py +35 -0
  38. src/inference/graph/capabilities/api_lookup.py +268 -0
  39. src/inference/graph/capabilities/append_evidence.py +133 -0
  40. src/inference/graph/capabilities/base.py +129 -0
  41. src/inference/graph/capabilities/defaults.py +94 -0
  42. src/inference/graph/capabilities/demographics_lookup.py +283 -0
  43. src/inference/graph/capabilities/draft_civil_response.py +96 -0
  44. src/inference/graph/capabilities/issue_detector.py +292 -0
  45. src/inference/graph/capabilities/keyword_analyzer.py +261 -0
  46. src/inference/graph/capabilities/rag_search.py +287 -0
  47. src/inference/graph/capabilities/registry.py +125 -0
  48. src/inference/graph/capabilities/stats_lookup.py +287 -0
  49. src/inference/graph/executor_adapter.py +190 -0
  50. src/inference/graph/nodes.py +752 -0
src/.DS_Store ADDED
Binary file (6.15 kB). View file
 
src/cli/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """GovOn CLI package."""
2
+
3
+ __version__ = "1.0.1"
src/cli/approval_ui.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Approval / rejection UI for GovOn CLI.
2
+
3
+ Renders a direction-keyโ€“driven prompt using `prompt_toolkit` when available.
4
+ Falls back to a plain input() prompt if prompt_toolkit is not installed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import unicodedata
10
+
11
+ from src.cli.terminal import (
12
+ get_approval_box_width,
13
+ get_narrow_terminal_warning,
14
+ get_terminal_columns,
15
+ is_layout_supported,
16
+ )
17
+
18
+ _PT_AVAILABLE = False
19
+ try:
20
+ from prompt_toolkit import Application
21
+ from prompt_toolkit.formatted_text import HTML
22
+ from prompt_toolkit.key_binding import KeyBindings
23
+ from prompt_toolkit.layout import Layout
24
+ from prompt_toolkit.layout.containers import HSplit, Window
25
+ from prompt_toolkit.layout.controls import FormattedTextControl
26
+
27
+ _PT_AVAILABLE = True
28
+ except ImportError: # pragma: no cover
29
+ pass
30
+
31
+
32
+ def _display_width(s: str) -> int:
33
+ """Return the display width of *s*, counting wide (CJK) chars as 2."""
34
+ w = 0
35
+ for ch in s:
36
+ eaw = unicodedata.east_asian_width(ch)
37
+ w += 2 if eaw in ("W", "F") else 1
38
+ return w
39
+
40
+
41
+ def _box_line(content: str = "", *, width: int) -> str:
42
+ """Return a single box line padded to *width* display columns."""
43
+ pad = width - _display_width(content)
44
+ inner = content + " " * max(pad, 0)
45
+ return f"โ”‚ {inner} โ”‚"
46
+
47
+
48
+ def _build_box_lines(
49
+ approval_request: dict, selected: int, box_width: int | None = None
50
+ ) -> list[str]:
51
+ """Build the raw text lines of the approval box (no ANSI needed here)."""
52
+ goal: str = approval_request.get("goal", "")
53
+ reason: str = approval_request.get("reason", "")
54
+ tool_summaries: list[str] = approval_request.get("tool_summaries") or []
55
+
56
+ w = get_approval_box_width(get_terminal_columns()) if box_width is None else box_width
57
+ _header = "โ”€ ์ž‘์—… ์Šน์ธ ์š”์ฒญ "
58
+ top = "โ”Œ" + _header + "โ”€" * max(w - _display_width(_header) + 2, 0) + "โ”"
59
+ bot = "โ””" + "โ”€" * (w + 2) + "โ”˜"
60
+
61
+ lines: list[str] = [top, _box_line(width=w)]
62
+
63
+ def _wrap(label: str, value: str) -> None:
64
+ prefix = f" {label}: "
65
+ available = max(w - _display_width(prefix), 1)
66
+ if _display_width(value) <= available:
67
+ lines.append(_box_line(f"{prefix}{value}", width=w))
68
+ else:
69
+ # Truncate value to fit within available display columns
70
+ chunk: list[str] = []
71
+ used = 0
72
+ for ch in value:
73
+ cw = 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
74
+ if used + cw > available:
75
+ break
76
+ chunk.append(ch)
77
+ used += cw
78
+ first = "".join(chunk)
79
+ lines.append(_box_line(f"{prefix}{first}", width=w))
80
+ rest = value[len(first) :]
81
+ while rest:
82
+ row: list[str] = []
83
+ used = 0
84
+ col_limit = w - 4
85
+ for ch in rest:
86
+ cw = 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
87
+ if used + cw > col_limit:
88
+ break
89
+ row.append(ch)
90
+ used += cw
91
+ seg = "".join(row)
92
+ lines.append(_box_line(f" {seg}", width=w))
93
+ rest = rest[len(seg) :]
94
+
95
+ _wrap("๋ชฉํ‘œ", goal)
96
+ _wrap("์ด์œ ", reason)
97
+
98
+ if tool_summaries:
99
+ lines.append(_box_line(width=w))
100
+ lines.append(_box_line(" ์ˆ˜ํ–‰ํ•  ์ž‘์—…:", width=w))
101
+ for idx, summary in enumerate(tool_summaries, 1):
102
+ prefix = f" {idx}. "
103
+ avail = max(w - _display_width(prefix), 1)
104
+ if _display_width(summary) <= avail:
105
+ lines.append(_box_line(f"{prefix}{summary}", width=w))
106
+ else:
107
+ chunk2: list[str] = []
108
+ used2 = 0
109
+ for ch in summary:
110
+ cw = 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
111
+ if used2 + cw > avail:
112
+ break
113
+ chunk2.append(ch)
114
+ used2 += cw
115
+ first2 = "".join(chunk2)
116
+ lines.append(_box_line(f"{prefix}{first2}", width=w))
117
+ rest2 = summary[len(first2) :]
118
+ while rest2:
119
+ row2: list[str] = []
120
+ used2 = 0
121
+ col_limit2 = max(w - 7, 1)
122
+ for ch in rest2:
123
+ cw = 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
124
+ if used2 + cw > col_limit2:
125
+ break
126
+ row2.append(ch)
127
+ used2 += cw
128
+ seg2 = "".join(row2)
129
+ lines.append(_box_line(f" {seg2}", width=w))
130
+ rest2 = rest2[len(seg2) :]
131
+
132
+ lines.append(_box_line(width=w))
133
+ approve_bullet = "โ—" if selected == 0 else "โ—‹"
134
+ reject_bullet = "โ—" if selected == 1 else "โ—‹"
135
+ lines.append(_box_line(f" {approve_bullet} ์Šน์ธ", width=w))
136
+ lines.append(_box_line(f" {reject_bullet} ๊ฑฐ์ ˆ", width=w))
137
+ lines.append(bot)
138
+ return lines
139
+
140
+
141
+ def show_approval_prompt(approval_request: dict) -> bool:
142
+ """Show an interactive approval / rejection prompt.
143
+
144
+ Returns True if approved, False if rejected.
145
+ """
146
+ terminal_columns = get_terminal_columns()
147
+ if not is_layout_supported(terminal_columns):
148
+ print(get_narrow_terminal_warning(terminal_columns))
149
+ return _fallback_prompt(approval_request, columns=terminal_columns)
150
+
151
+ if not _PT_AVAILABLE:
152
+ return _fallback_prompt(approval_request, columns=terminal_columns)
153
+
154
+ return _pt_prompt(approval_request, columns=terminal_columns)
155
+
156
+
157
+ def _pt_prompt(approval_request: dict, *, columns: int) -> bool:
158
+ """prompt_toolkitโ€“based arrow-key selection UI."""
159
+ state = {"selected": 0, "result": None}
160
+ box_width = get_approval_box_width(columns)
161
+
162
+ def get_text():
163
+ # Keep a stable width for a single prompt interaction.
164
+ lines = _build_box_lines(approval_request, state["selected"], box_width=box_width)
165
+ return "\n".join(lines) + "\n\nโ†‘โ†“ ๋ฐฉํ–ฅํ‚ค๋กœ ์„ ํƒ, Enter๋กœ ํ™•์ •"
166
+
167
+ kb = KeyBindings()
168
+
169
+ @kb.add("up")
170
+ @kb.add("k")
171
+ def _up(event):
172
+ state["selected"] = (state["selected"] - 1) % 2
173
+ _refresh_control()
174
+
175
+ @kb.add("down")
176
+ @kb.add("j")
177
+ def _down(event):
178
+ state["selected"] = (state["selected"] + 1) % 2
179
+ _refresh_control()
180
+
181
+ @kb.add("enter")
182
+ def _confirm(event):
183
+ state["result"] = state["selected"] == 0
184
+ event.app.exit()
185
+
186
+ @kb.add("q")
187
+ @kb.add("c-c")
188
+ def _cancel(event):
189
+ state["result"] = False
190
+ event.app.exit()
191
+
192
+ control = FormattedTextControl(text=get_text)
193
+ window = Window(content=control)
194
+ layout = Layout(HSplit([window]))
195
+
196
+ def _refresh_control():
197
+ control.text = get_text # keep as callable
198
+ app.invalidate()
199
+
200
+ app: Application = Application(layout=layout, key_bindings=kb, full_screen=False)
201
+ app.run()
202
+
203
+ return bool(state["result"])
204
+
205
+
206
+ def _fallback_prompt(approval_request: dict, columns: int | None = None) -> bool:
207
+ """Plain input() fallback when prompt_toolkit is unavailable."""
208
+ goal: str = approval_request.get("goal", "")
209
+ reason: str = approval_request.get("reason", "")
210
+ tool_summaries: list[str] = approval_request.get("tool_summaries") or []
211
+ terminal_columns = get_terminal_columns() if columns is None else columns
212
+ separator = "โ”€" * max(terminal_columns - 2, 12)
213
+ title = " ์ž‘์—… ์Šน์ธ ์š”์ฒญ "
214
+ title_width = _display_width(title)
215
+ if terminal_columns > title_width:
216
+ fill_width = terminal_columns - title_width
217
+ left_fill = fill_width // 2
218
+ right_fill = fill_width - left_fill
219
+ title_line = f"{'โ”€' * left_fill}{title}{'โ”€' * right_fill}"
220
+ else:
221
+ title_line = title
222
+
223
+ print(f"\n{title_line}")
224
+ if goal:
225
+ print(f" ๋ชฉํ‘œ: {goal}")
226
+ if reason:
227
+ print(f" ์ด์œ : {reason}")
228
+ if tool_summaries:
229
+ print("\n ์ˆ˜ํ–‰ํ•  ์ž‘์—…:")
230
+ for idx, s in enumerate(tool_summaries, 1):
231
+ print(f" {idx}. {s}")
232
+ print(separator)
233
+
234
+ try:
235
+ answer = input("์Šน์ธํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? (y/n): ").strip().lower()
236
+ except (EOFError, KeyboardInterrupt):
237
+ return False
238
+
239
+ return answer in ("y", "yes", "์˜ˆ", "๋„ค")
src/cli/commands.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Slash command parser and handler for GovOn CLI."""
2
+
3
+ COMMANDS: dict[str, str] = {
4
+ "/help": "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ช…๋ น๊ณผ ๋„์›€๋ง์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.",
5
+ "/clear": "ํ„ฐ๋ฏธ๋„ ํ™”๋ฉด์„ ์ดˆ๊ธฐํ™”ํ•ฉ๋‹ˆ๋‹ค.",
6
+ "/exit": "์…ธ์„ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค.",
7
+ }
8
+
9
+ _HELP_TEXT = """GovOn CLI ์‚ฌ์šฉ๋ฒ•
10
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
11
+ govon ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ REPL ๋ชจ๋“œ
12
+ govon "์งˆ๋ฌธ" ๋‹จ๋ฐœ ์‹คํ–‰ ๋ชจ๋“œ
13
+ govon --session <id> ๊ธฐ์กด ์„ธ์…˜ ์žฌ๊ฐœ
14
+ govon --session <id> "์งˆ๋ฌธ" ๊ธฐ์กด ์„ธ์…˜์—์„œ ๋‹จ๋ฐœ ์‹คํ–‰
15
+ govon --status daemon ์ƒํƒœ ํ™•์ธ
16
+ govon --stop daemon ์ค‘์ง€
17
+
18
+ ์Šฌ๋ž˜์‹œ ๋ช…๋ น
19
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€"""
20
+
21
+ for _cmd, _desc in COMMANDS.items():
22
+ _HELP_TEXT += f"\n {_cmd:<10} {_desc}"
23
+
24
+ _HELP_TEXT += "\nโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n์—…๋ฌด ์š”์ฒญ์€ ์ž์—ฐ์–ด๋กœ ์ง์ ‘ ์ž…๋ ฅํ•˜์„ธ์š”."
25
+
26
+
27
+ def is_command(text: str) -> bool:
28
+ """Return True if text is a slash command."""
29
+ return text.strip().startswith("/")
30
+
31
+
32
+ def handle_command(text: str) -> str | None:
33
+ """Execute a slash command and return a result string, or None.
34
+
35
+ Raises SystemExit for /exit.
36
+ """
37
+ cmd = text.strip().split()[0].lower()
38
+
39
+ if cmd == "/help":
40
+ return _HELP_TEXT
41
+
42
+ if cmd == "/clear":
43
+ print("\033[2J\033[H", end="", flush=True)
44
+ return None
45
+
46
+ if cmd == "/exit":
47
+ raise SystemExit(0)
48
+
49
+ return f"์•Œ ์ˆ˜ ์—†๋Š” ๋ช…๋ น์ž…๋‹ˆ๋‹ค: {cmd}\n/help๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”."
src/cli/daemon.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GovOn daemon lifecycle ๊ด€๋ฆฌ.
2
+
3
+ Issue #144: CLI-daemon/LangGraph runtime ์—ฐ๋™ ๋ฐ session resume.
4
+
5
+ uvicorn์œผ๋กœ ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ GovOn API ์„œ๋ฒ„๋ฅผ ๊ธฐ๋™ํ•˜๊ณ ,
6
+ PID ํŒŒ์ผ๋กœ ํ”„๋กœ์„ธ์Šค ์ƒํƒœ๋ฅผ ์ถ”์ ํ•œ๋‹ค.
7
+
8
+ .. note::
9
+ ์ด ๋ชจ๋“ˆ์€ **๋กœ์ปฌ daemon ์ „์šฉ**์ž…๋‹ˆ๋‹ค.
10
+ ์›๊ฒฉ ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ๋•Œ๋Š” ``GOVON_RUNTIME_URL`` ํ™˜๊ฒฝ๋ณ€์ˆ˜๋ฅผ ์„ค์ •ํ•˜๋ฉด
11
+ ``shell.py``์˜ ``main()``์ด ์ด ๋ชจ๋“ˆ์„ ์™„์ „ํžˆ ๊ฑด๋„ˆ๋›ฐ๊ณ  ์ง€์ •๋œ URL์—
12
+ ์ง์ ‘ ์—ฐ๊ฒฐํ•ฉ๋‹ˆ๋‹ค. Docker, ํด๋ผ์šฐ๋“œ ๋ฐฐํฌ, CI ํ™˜๊ฒฝ์—์„œ๋Š” ํ•ด๋‹น ๋ฐฉ์‹์„
13
+ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import signal
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ import httpx
27
+ from loguru import logger
28
+
29
+
30
+ class DaemonManager:
31
+ """GovOn API ์„œ๋ฒ„ daemon lifecycle ๊ด€๋ฆฌ์ž.
32
+
33
+ PID ํŒŒ์ผ๊ณผ /health ์—”๋“œํฌ์ธํŠธ๋ฅผ ๊ฒฐํ•ฉํ•˜์—ฌ daemon ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜๊ณ ,
34
+ ํ•„์š” ์‹œ uvicorn์œผ๋กœ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๊ธฐ๋™ํ•œ๋‹ค.
35
+
36
+ ํ™˜๊ฒฝ๋ณ€์ˆ˜ ``GOVON_PORT``๋กœ ํฌํŠธ๋ฅผ ์˜ค๋ฒ„๋ผ์ด๋“œํ•  ์ˆ˜ ์žˆ๋‹ค (๊ธฐ๋ณธ: 8000).
37
+ """
38
+
39
+ GOVON_HOME = Path.home() / ".govon"
40
+ _HEALTH_CHECK_TIMEOUT = 120 # ์ตœ๋Œ€ ๋Œ€๊ธฐ ์ดˆ
41
+ _HEALTH_CHECK_INTERVAL = 1 # ์žฌ์‹œ๋„ ๊ฐ„๊ฒฉ (์ดˆ)
42
+
43
+ def __init__(self) -> None:
44
+ self.GOVON_HOME.mkdir(parents=True, exist_ok=True)
45
+ self.port: int = int(os.environ.get("GOVON_PORT", "8000"))
46
+ self.pid_path: Path = self.GOVON_HOME / "daemon.pid"
47
+ self.log_path: Path = self.GOVON_HOME / "daemon.log"
48
+
49
+ def get_base_url(self) -> str:
50
+ """daemon base URL์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค."""
51
+ return f"http://127.0.0.1:{self.port}"
52
+
53
+ def is_running(self) -> bool:
54
+ """daemon์ด ์‹คํ–‰ ์ค‘์ธ์ง€ ํ™•์ธํ•œ๋‹ค.
55
+
56
+ PID ํŒŒ์ผ์ด ์กด์žฌํ•˜๊ณ  ํ•ด๋‹น ํ”„๋กœ์„ธ์Šค๊ฐ€ ์‚ด์•„ ์žˆ์œผ๋ฉฐ,
57
+ /health ์—”๋“œํฌ์ธํŠธ๊ฐ€ ์‘๋‹ตํ•  ๋•Œ True๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
58
+ """
59
+ pid = self._read_pid()
60
+ if pid is None:
61
+ return False
62
+
63
+ # PID ํ”„๋กœ์„ธ์Šค ์ƒ์กด ํ™•์ธ
64
+ if not self._pid_alive(pid):
65
+ logger.debug(f"[daemon] PID {pid} ํ”„๋กœ์„ธ์Šค๊ฐ€ ์—†์Œ. PID ํŒŒ์ผ ์ œ๊ฑฐ.")
66
+ self._remove_pid()
67
+ return False
68
+
69
+ # /health HTTP ํ™•์ธ
70
+ try:
71
+ with httpx.Client(timeout=5.0) as client:
72
+ resp = client.get(f"{self.get_base_url()}/health")
73
+ return resp.status_code == 200
74
+ except (httpx.ConnectError, httpx.TimeoutException, Exception):
75
+ return False
76
+
77
+ def start(self) -> bool:
78
+ """uvicorn์„ ๋ฐฑ๊ทธ๋ผ์šด๋“œ๋กœ ๊ธฐ๋™ํ•˜๊ณ  PID๋ฅผ ๊ธฐ๋กํ•œ๋‹ค.
79
+
80
+ Returns
81
+ -------
82
+ bool
83
+ ๊ธฐ๋™ ์„ฑ๊ณต ์—ฌ๋ถ€ (health check ํ†ต๊ณผ ์‹œ True).
84
+ """
85
+ # ๋ ˆ์ด์Šค ์ปจ๋””์…˜ ๋ฐฉ์ง€: ๊ธฐ๋™ ์ „ ํ•œ ๋ฒˆ ๋” health check
86
+ if self.is_running():
87
+ logger.info("[daemon] ์ด๋ฏธ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค.")
88
+ return True
89
+
90
+ cmd = [
91
+ sys.executable,
92
+ "-m",
93
+ "uvicorn",
94
+ "src.inference.api_server:app",
95
+ "--host",
96
+ "127.0.0.1",
97
+ "--port",
98
+ str(self.port),
99
+ ]
100
+
101
+ logger.info(f"[daemon] ๊ธฐ๋™ ๋ช…๋ น: {' '.join(cmd)}")
102
+
103
+ with open(self.log_path, "a") as log_file:
104
+ proc = subprocess.Popen(
105
+ cmd,
106
+ stdout=log_file,
107
+ stderr=log_file,
108
+ start_new_session=True,
109
+ )
110
+
111
+ self._write_pid(proc.pid)
112
+ logger.info(f"[daemon] ํ”„๋กœ์„ธ์Šค ๊ธฐ๋™ ์™„๋ฃŒ. PID={proc.pid}")
113
+
114
+ # health check ๋Œ€๊ธฐ
115
+ return self._wait_until_healthy()
116
+
117
+ def stop(self) -> None:
118
+ """daemon์„ ์ •์ƒ ์ข…๋ฃŒํ•œ๋‹ค (SIGTERM โ†’ timeout ํ›„ SIGKILL)."""
119
+ pid = self._read_pid()
120
+ if pid is None:
121
+ logger.info("[daemon] PID ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค. ์‹คํ–‰ ์ค‘์ด ์•„๋‹Œ ๊ฒƒ์œผ๋กœ ๊ฐ„์ฃผํ•ฉ๋‹ˆ๋‹ค.")
122
+ return
123
+
124
+ if not self._pid_alive(pid):
125
+ logger.info(f"[daemon] PID {pid} ํ”„๋กœ์„ธ์Šค๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
126
+ self._remove_pid()
127
+ return
128
+
129
+ logger.info(f"[daemon] SIGTERM ์ „์†ก: PID={pid}")
130
+ os.kill(pid, signal.SIGTERM)
131
+
132
+ # ์ตœ๋Œ€ 10์ดˆ ๋Œ€๊ธฐ
133
+ for _ in range(10):
134
+ time.sleep(1)
135
+ if not self._pid_alive(pid):
136
+ logger.info(f"[daemon] PID {pid} ์ •์ƒ ์ข…๋ฃŒ๋จ.")
137
+ self._remove_pid()
138
+ return
139
+
140
+ logger.warning(f"[daemon] SIGKILL ์ „์†ก: PID={pid}")
141
+ try:
142
+ os.kill(pid, signal.SIGKILL)
143
+ except ProcessLookupError:
144
+ pass
145
+ self._remove_pid()
146
+
147
+ def ensure_running(self) -> str:
148
+ """daemon์ด ์‹คํ–‰ ์ค‘์ž„์„ ๋ณด์žฅํ•˜๊ณ  base URL์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
149
+
150
+ ์‹คํ–‰ ์ค‘์ด ์•„๋‹ˆ๋ฉด start()๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค.
151
+
152
+ Returns
153
+ -------
154
+ str
155
+ daemon base URL (์˜ˆ: "http://127.0.0.1:8000").
156
+
157
+ Raises
158
+ ------
159
+ RuntimeError
160
+ daemon ๊ธฐ๋™์— ์‹คํŒจํ•œ ๊ฒฝ์šฐ.
161
+ """
162
+ if not self.is_running():
163
+ success = self.start()
164
+ if not success:
165
+ raise RuntimeError(
166
+ "GovOn daemon ๊ธฐ๋™์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. " f"๋กœ๊ทธ๋ฅผ ํ™•์ธํ•˜์„ธ์š”: {self.log_path}"
167
+ )
168
+ return self.get_base_url()
169
+
170
+ # ------------------------------------------------------------------
171
+ # ๋‚ด๋ถ€ ํ—ฌํผ
172
+ # ------------------------------------------------------------------
173
+
174
+ def _read_pid(self) -> Optional[int]:
175
+ """PID ํŒŒ์ผ์—์„œ PID๋ฅผ ์ฝ๋Š”๋‹ค. ํŒŒ์ผ์ด ์—†์œผ๋ฉด None."""
176
+ if not self.pid_path.exists():
177
+ return None
178
+ try:
179
+ first_line = self.pid_path.read_text().strip().splitlines()[0]
180
+ return int(first_line.split()[0])
181
+ except (ValueError, OSError, IndexError):
182
+ return None
183
+
184
+ def _write_pid(self, pid: int) -> None:
185
+ """PID์™€ ๊ธฐ๋™ ์‹œ๊ฐ(epoch timestamp)์„ ํŒŒ์ผ์— ๊ธฐ๋กํ•œ๋‹ค."""
186
+ self.pid_path.write_text(f"{pid} {int(time.time())}")
187
+
188
+ def _remove_pid(self) -> None:
189
+ """PID ํŒŒ์ผ์„ ์ œ๊ฑฐํ•œ๋‹ค."""
190
+ try:
191
+ self.pid_path.unlink()
192
+ except FileNotFoundError:
193
+ pass
194
+
195
+ @staticmethod
196
+ def _pid_alive(pid: int) -> bool:
197
+ """ํ”„๋กœ์„ธ์Šค๊ฐ€ ์‚ด์•„ ์žˆ๋Š”์ง€ ํ™•์ธํ•œ๋‹ค."""
198
+ try:
199
+ os.kill(pid, 0)
200
+ return True
201
+ except ProcessLookupError:
202
+ return False
203
+ except PermissionError:
204
+ # ํ”„๋กœ์„ธ์Šค๊ฐ€ ์กด์žฌํ•˜์ง€๋งŒ ๊ถŒํ•œ์ด ์—†๋Š” ๊ฒฝ์šฐ โ†’ ์‚ด์•„ ์žˆ์Œ์œผ๋กœ ๊ฐ„์ฃผ
205
+ return True
206
+
207
+ def _wait_until_healthy(self) -> bool:
208
+ """health check๊ฐ€ ํ†ต๊ณผํ•  ๋•Œ๊นŒ์ง€ ์ตœ๋Œ€ 120์ดˆ ๋Œ€๊ธฐํ•œ๋‹ค."""
209
+ deadline = time.monotonic() + self._HEALTH_CHECK_TIMEOUT
210
+ while time.monotonic() < deadline:
211
+ try:
212
+ with httpx.Client(timeout=3.0) as client:
213
+ resp = client.get(f"{self.get_base_url()}/health")
214
+ if resp.status_code == 200:
215
+ logger.info("[daemon] health check ํ†ต๊ณผ.")
216
+ return True
217
+ except (httpx.ConnectError, httpx.TimeoutException, Exception):
218
+ pass
219
+ time.sleep(self._HEALTH_CHECK_INTERVAL)
220
+
221
+ logger.error("[daemon] health check timeout (120์ดˆ).")
222
+ return False
src/cli/http_client.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GovOn ๋กœ์ปฌ daemon API HTTP ํด๋ผ์ด์–ธํŠธ.
2
+
3
+ Issue #144: CLI-daemon/LangGraph runtime ์—ฐ๋™ ๋ฐ session resume.
4
+ Issue #140: CLI ์Šน์ธ UI ๋ฐ ์ตœ์†Œ ๋ช…๋ น ์ฒด๊ณ„ (๋ฐฑ์—”๋“œ ๋ถ€๋ถ„).
5
+
6
+ ๋กœ์ปฌ daemon(uvicorn)์˜ REST API๋ฅผ ๋ž˜ํ•‘ํ•˜๋Š” ํด๋ผ์ด์–ธํŠธ.
7
+ run / approve / cancel ๋“ฑ ํ•ต์‹ฌ ์—”๋“œํฌ์ธํŠธ์— ์ ‘๊ทผํ•œ๋‹ค.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any, Dict, Generator, Iterator, Optional
14
+
15
+ import httpx
16
+ from loguru import logger
17
+
18
+
19
+ class GovOnClient:
20
+ """GovOn ๋กœ์ปฌ daemon HTTP ํด๋ผ์ด์–ธํŠธ.
21
+
22
+ Parameters
23
+ ----------
24
+ base_url : str
25
+ daemon base URL (์˜ˆ: "http://127.0.0.1:8000").
26
+ """
27
+
28
+ _RUN_TIMEOUT = 120.0
29
+ _DEFAULT_TIMEOUT = 30.0
30
+
31
+ def __init__(self, base_url: str) -> None:
32
+ self._base_url = base_url.rstrip("/")
33
+
34
+ # ------------------------------------------------------------------
35
+ # ๊ณต๊ฐœ API
36
+ # ------------------------------------------------------------------
37
+
38
+ def health(self) -> Dict[str, Any]:
39
+ """GET /health โ€” daemon ์ƒํƒœ๋ฅผ ํ™•์ธํ•œ๋‹ค.
40
+
41
+ Returns
42
+ -------
43
+ dict
44
+ ์„œ๋ฒ„๊ฐ€ ๋ฐ˜ํ™˜ํ•˜๋Š” health ์‘๋‹ต.
45
+
46
+ Raises
47
+ ------
48
+ ConnectionError
49
+ daemon์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์„ ๋•Œ.
50
+ """
51
+ return self._get("/health", timeout=self._DEFAULT_TIMEOUT)
52
+
53
+ def run(
54
+ self,
55
+ query: str,
56
+ session_id: Optional[str] = None,
57
+ ) -> Dict[str, Any]:
58
+ """POST /v2/agent/run โ€” ์—์ด์ „ํŠธ ์‹คํ–‰ ์š”์ฒญ.
59
+
60
+ Parameters
61
+ ----------
62
+ query : str
63
+ ์‚ฌ์šฉ์ž ์ž…๋ ฅ ์ฟผ๋ฆฌ.
64
+ session_id : str | None
65
+ ๊ธฐ์กด ์„ธ์…˜์„ ์ด์–ด๋ฐ›์„ ๊ฒฝ์šฐ session ID.
66
+
67
+ Returns
68
+ -------
69
+ dict
70
+ ์„œ๋ฒ„ ์‘๋‹ต (thread_id, status ๋“ฑ ํฌํ•จ).
71
+ """
72
+ body: Dict[str, Any] = {"query": query}
73
+ if session_id is not None:
74
+ body["session_id"] = session_id
75
+
76
+ logger.debug(f"[http_client] run: session_id={session_id} query_len={len(query)}")
77
+ return self._post("/v2/agent/run", body=body, timeout=self._RUN_TIMEOUT)
78
+
79
+ def approve(self, thread_id: str, approved: bool) -> Dict[str, Any]:
80
+ """POST /v2/agent/approve โ€” ์Šน์ธ ๋˜๋Š” ๊ฑฐ์ ˆ.
81
+
82
+ Parameters
83
+ ----------
84
+ thread_id : str
85
+ ์Šน์ธ/๊ฑฐ์ ˆํ•  graph thread ID.
86
+ approved : bool
87
+ True์ด๋ฉด ์Šน์ธ, False์ด๋ฉด ๊ฑฐ์ ˆ.
88
+
89
+ Returns
90
+ -------
91
+ dict
92
+ ์„œ๋ฒ„ ์‘๋‹ต.
93
+ """
94
+ logger.debug(f"[http_client] approve: thread_id={thread_id} approved={approved}")
95
+ return self._post_params(
96
+ "/v2/agent/approve",
97
+ params={"thread_id": thread_id, "approved": str(approved).lower()},
98
+ timeout=self._DEFAULT_TIMEOUT,
99
+ )
100
+
101
+ def stream(
102
+ self,
103
+ query: str,
104
+ session_id: Optional[str] = None,
105
+ ) -> Generator[Dict[str, Any], None, None]:
106
+ """POST /v2/agent/stream โ€” SSE ์ŠคํŠธ๋ฆฌ๋ฐ์œผ๋กœ ๋…ธ๋“œ๋ณ„ ์ด๋ฒคํŠธ๋ฅผ ์ˆ˜์‹ ํ•œ๋‹ค.
107
+
108
+ Parameters
109
+ ----------
110
+ query : str
111
+ ์‚ฌ์šฉ์ž ์ž…๋ ฅ ์ฟผ๋ฆฌ.
112
+ session_id : str | None
113
+ ๊ธฐ์กด ์„ธ์…˜์„ ์ด์–ด๋ฐ›์„ ๊ฒฝ์šฐ session ID.
114
+
115
+ Yields
116
+ ------
117
+ dict
118
+ ํŒŒ์‹ฑ๋œ SSE ์ด๋ฒคํŠธ dict. ์ตœ์†Œ ``node``์™€ ``status`` ํ‚ค๋ฅผ ํฌํ•จํ•œ๋‹ค.
119
+
120
+ Raises
121
+ ------
122
+ ConnectionError
123
+ daemon์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์„ ๋•Œ.
124
+ httpx.HTTPStatusError
125
+ HTTP ์˜ค๋ฅ˜ ์‘๋‹ต ์‹œ.
126
+ """
127
+ body: Dict[str, Any] = {"query": query}
128
+ if session_id is not None:
129
+ body["session_id"] = session_id
130
+
131
+ url = f"{self._base_url}/v2/agent/stream"
132
+ logger.debug(f"[http_client] stream: session_id={session_id} query_len={len(query)}")
133
+
134
+ try:
135
+ timeout = httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=10.0)
136
+ with httpx.Client(timeout=timeout) as client:
137
+ with client.stream("POST", url, json=body) as resp:
138
+ resp.raise_for_status()
139
+ for line in resp.iter_lines():
140
+ line = line.strip()
141
+ if not line:
142
+ continue
143
+ if line.startswith("data:"):
144
+ data_str = line[len("data:") :].strip()
145
+ if not data_str:
146
+ continue
147
+ try:
148
+ event = json.loads(data_str)
149
+ yield event
150
+ except json.JSONDecodeError:
151
+ logger.warning(f"[http_client] SSE JSON ํŒŒ์‹ฑ ์‹คํŒจ: {data_str!r}")
152
+ continue
153
+ except httpx.ConnectError as exc:
154
+ raise ConnectionError(f"daemon์ด ์‹คํ–‰ ์ค‘์ด ์•„๋‹™๋‹ˆ๋‹ค. ({self._base_url})") from exc
155
+ except httpx.HTTPStatusError as exc:
156
+ logger.error(f"[http_client] HTTP {exc.response.status_code}: {url}")
157
+ raise
158
+
159
+ def cancel(self, thread_id: str) -> Dict[str, Any]:
160
+ """POST /v2/agent/cancel โ€” ์‹คํ–‰ ์ค‘์ธ ์„ธ์…˜ ์ทจ์†Œ.
161
+
162
+ Parameters
163
+ ----------
164
+ thread_id : str
165
+ ์ทจ์†Œํ•  graph thread ID.
166
+
167
+ Returns
168
+ -------
169
+ dict
170
+ ์„œ๋ฒ„ ์‘๋‹ต.
171
+ """
172
+ logger.debug(f"[http_client] cancel: thread_id={thread_id}")
173
+ return self._post_params(
174
+ "/v2/agent/cancel",
175
+ params={"thread_id": thread_id},
176
+ timeout=self._DEFAULT_TIMEOUT,
177
+ )
178
+
179
+ # ------------------------------------------------------------------
180
+ # ๋‚ด๋ถ€ ํ—ฌํผ
181
+ # ------------------------------------------------------------------
182
+
183
+ def _get(self, path: str, *, timeout: float) -> Dict[str, Any]:
184
+ url = f"{self._base_url}{path}"
185
+ try:
186
+ with httpx.Client(timeout=timeout) as client:
187
+ resp = client.get(url)
188
+ resp.raise_for_status()
189
+ return resp.json()
190
+ except httpx.ConnectError as exc:
191
+ raise ConnectionError(f"daemon์ด ์‹คํ–‰ ์ค‘์ด ์•„๋‹™๋‹ˆ๋‹ค. ({self._base_url})") from exc
192
+ except httpx.HTTPStatusError as exc:
193
+ logger.error(f"[http_client] HTTP {exc.response.status_code}: {url}")
194
+ raise
195
+
196
+ def _post(
197
+ self,
198
+ path: str,
199
+ *,
200
+ body: Dict[str, Any],
201
+ timeout: float,
202
+ ) -> Dict[str, Any]:
203
+ url = f"{self._base_url}{path}"
204
+ try:
205
+ with httpx.Client(timeout=timeout) as client:
206
+ resp = client.post(url, json=body)
207
+ resp.raise_for_status()
208
+ return resp.json()
209
+ except httpx.ConnectError as exc:
210
+ raise ConnectionError(f"daemon์ด ์‹คํ–‰ ์ค‘์ด ์•„๋‹™๋‹ˆ๋‹ค. ({self._base_url})") from exc
211
+ except httpx.HTTPStatusError as exc:
212
+ logger.error(f"[http_client] HTTP {exc.response.status_code}: {url}")
213
+ raise
214
+
215
+ def _post_params(
216
+ self,
217
+ path: str,
218
+ *,
219
+ params: Dict[str, Any],
220
+ timeout: float,
221
+ ) -> Dict[str, Any]:
222
+ """์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” POST ์š”์ฒญ ํ—ฌํผ.
223
+
224
+ `/v2/agent/approve`, `/v2/agent/cancel` ๋“ฑ FastAPI ์—”๋“œํฌ์ธํŠธ๊ฐ€
225
+ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๊ธฐ๋Œ€ํ•  ๋•Œ ์‚ฌ์šฉํ•œ๋‹ค.
226
+ """
227
+ url = f"{self._base_url}{path}"
228
+ try:
229
+ with httpx.Client(timeout=timeout) as client:
230
+ resp = client.post(url, params=params)
231
+ resp.raise_for_status()
232
+ return resp.json()
233
+ except httpx.ConnectError as exc:
234
+ raise ConnectionError(f"daemon์ด ์‹คํ–‰ ์ค‘์ด ์•„๋‹™๋‹ˆ๋‹ค. ({self._base_url})") from exc
235
+ except httpx.HTTPStatusError as exc:
236
+ logger.error(f"[http_client] HTTP {exc.response.status_code}: {url}")
237
+ raise
src/cli/renderer.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Result rendering for GovOn CLI.
2
+
3
+ Uses `rich` when available; falls back to plain print() otherwise.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from threading import Lock
9
+
10
+ from src.cli.terminal import (
11
+ get_narrow_terminal_warning,
12
+ get_panel_width,
13
+ get_terminal_columns,
14
+ is_layout_supported,
15
+ )
16
+
17
+ try:
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.status import Status
21
+ from rich.text import Text
22
+
23
+ _console = Console()
24
+ _RICH_AVAILABLE = True
25
+ except ImportError: # pragma: no cover
26
+ _console = None # type: ignore[assignment]
27
+ _RICH_AVAILABLE = False
28
+
29
+ _HAS_WARNED_NARROW_TERMINAL = False
30
+ _NARROW_WARNING_LOCK = Lock()
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Node status message mapping
34
+ # ---------------------------------------------------------------------------
35
+
36
+ NODE_STATUS_MESSAGES: dict[str, str] = {
37
+ "session_load": "์„ธ์…˜ ๋กœ๋“œ ์ค‘โ€ฆ",
38
+ "planner": "๊ณ„ํš ์ˆ˜๋ฆฝ ์ค‘โ€ฆ",
39
+ "approval_wait": "์Šน์ธ ๋Œ€๊ธฐ ์ค‘โ€ฆ",
40
+ "tool_execute": "๋„๊ตฌ ์‹คํ–‰ ์ค‘โ€ฆ",
41
+ "synthesis": "๋‹ต๋ณ€ ์ƒ์„ฑ ์ค‘โ€ฆ",
42
+ "persist": "์ €์žฅ ์ค‘โ€ฆ",
43
+ }
44
+
45
+
46
+ def get_node_message(node_name: str) -> str:
47
+ """Return a human-readable status message for a given node name."""
48
+ return NODE_STATUS_MESSAGES.get(node_name, f"{node_name} ์ฒ˜๋ฆฌ ์ค‘โ€ฆ")
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Spinner context manager
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ class StreamingStatusDisplay:
57
+ """Context manager that shows a spinner and updates the message per node.
58
+
59
+ Wraps rich.status.Status when rich is available; falls back to plain print().
60
+ """
61
+
62
+ def __init__(self, initial_message: str = "์ฒ˜๋ฆฌ ์ค‘โ€ฆ") -> None:
63
+ self._initial_message = initial_message
64
+ self._status: Status | None = None # type: ignore[name-defined]
65
+ self._use_rich = False
66
+
67
+ def __enter__(self) -> "StreamingStatusDisplay":
68
+ self._use_rich, _ = _resolve_render_mode()
69
+ if self._use_rich:
70
+ self._status = _console.status(self._initial_message, spinner="dots")
71
+ self._status.__enter__()
72
+ else:
73
+ print(f"โ†’ {self._initial_message}", flush=True)
74
+ return self
75
+
76
+ def update(self, message: str) -> None:
77
+ """Update the displayed status message."""
78
+ if self._use_rich and self._status is not None:
79
+ self._status.update(message)
80
+ else:
81
+ print(f"โ†’ {message}", flush=True)
82
+
83
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
84
+ if self._use_rich and self._status is not None:
85
+ self._status.__exit__(exc_type, exc_val, exc_tb)
86
+ self._status = None
87
+
88
+
89
+ def _warn_narrow_terminal_once(columns: int) -> None:
90
+ """Emit the narrow-terminal fallback warning once per narrow-state entry."""
91
+ global _HAS_WARNED_NARROW_TERMINAL
92
+
93
+ with _NARROW_WARNING_LOCK:
94
+ if _HAS_WARNED_NARROW_TERMINAL:
95
+ return
96
+ _HAS_WARNED_NARROW_TERMINAL = True
97
+
98
+ print(get_narrow_terminal_warning(columns), flush=True)
99
+
100
+
101
+ def _reset_narrow_warning() -> None:
102
+ """Reset narrow-terminal warning state for tests and wide-terminal recovery."""
103
+ global _HAS_WARNED_NARROW_TERMINAL
104
+
105
+ with _NARROW_WARNING_LOCK:
106
+ _HAS_WARNED_NARROW_TERMINAL = False
107
+
108
+
109
+ def _resolve_render_mode() -> tuple[bool, int]:
110
+ """Return (use_rich, terminal_columns) for the current render call."""
111
+ columns = get_terminal_columns()
112
+ if not is_layout_supported(columns):
113
+ _warn_narrow_terminal_once(columns)
114
+ return False, columns
115
+ _reset_narrow_warning()
116
+ return _RICH_AVAILABLE, columns
117
+
118
+
119
+ def _plain_rule(columns: int) -> str:
120
+ """Return a separator that fits within the current terminal."""
121
+ return "โ”€" * max(columns - 2, 12)
122
+
123
+
124
+ def render_evidence_section(evidence_items: list) -> str:
125
+ """EvidenceItem dict ๋ฆฌ์ŠคํŠธ๋ฅผ ์ถœ์ฒ˜ ์„น์…˜ ํ…์ŠคํŠธ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค.
126
+
127
+ source_type๋ณ„๋กœ ๊ทธ๋ฃนํ™”ํ•˜์—ฌ ํ‘œ์‹œํ•œ๋‹ค:
128
+ [๋กœ์ปฌ ๋ฌธ์„œ] โ€” rag ์ถœ์ฒ˜ (file_path, page, score ํฌํ•จ)
129
+ [์™ธ๋ถ€ API] โ€” api ์ถœ์ฒ˜ (URL ํฌํ•จ)
130
+ [LLM ์ƒ์„ฑ] โ€” llm_generated ์ถœ์ฒ˜
131
+
132
+ Parameters
133
+ ----------
134
+ evidence_items : list
135
+ EvidenceItem.to_dict() ํ˜•ํƒœ์˜ dict ๋ฆฌ์ŠคํŠธ.
136
+
137
+ Returns
138
+ -------
139
+ str
140
+ ์ถœ์ฒ˜ ์„น์…˜ ํ…์ŠคํŠธ. items๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ๋ฌธ์ž์—ด.
141
+ """
142
+ if not evidence_items:
143
+ return ""
144
+
145
+ # source_type๋ณ„ ๊ทธ๋ฃนํ™”
146
+ rag_items = [i for i in evidence_items if i.get("source_type") == "rag"]
147
+ api_items = [i for i in evidence_items if i.get("source_type") == "api"]
148
+ llm_items = [i for i in evidence_items if i.get("source_type") == "llm_generated"]
149
+
150
+ lines: list[str] = ["โ”€โ”€ ์ฐธ์กฐ ๊ทผ๊ฑฐ โ”€โ”€"]
151
+ idx = 1
152
+
153
+ if rag_items:
154
+ lines.append("[๋กœ์ปฌ ๋ฌธ์„œ]")
155
+ for item in rag_items:
156
+ title = item.get("title") or item.get("link_or_path", "")
157
+ page = item.get("page")
158
+ score = item.get("score", 0.0)
159
+ page_str = f" (p.{page})" if page is not None else ""
160
+ score_str = f" [{score:.2f}]" if score else ""
161
+ lines.append(f" {idx}. {title}{page_str}{score_str}")
162
+ idx += 1
163
+
164
+ if api_items:
165
+ lines.append("[์™ธ๋ถ€ API]")
166
+ for item in api_items:
167
+ title = item.get("title", "")
168
+ link = item.get("link_or_path", "")
169
+ link_str = f" โ€” {link}" if link else ""
170
+ lines.append(f" {idx}. {title}{link_str}")
171
+ idx += 1
172
+
173
+ if llm_items:
174
+ lines.append("[LLM ์ƒ์„ฑ]")
175
+ for item in llm_items:
176
+ title = item.get("title", "")
177
+ excerpt = item.get("excerpt", "")[:80]
178
+ lines.append(f" {idx}. {title}: {excerpt}" if title else f" {idx}. {excerpt}")
179
+ idx += 1
180
+
181
+ return "\n".join(lines) if len(lines) > 1 else ""
182
+
183
+
184
+ def render_result(result: dict) -> None:
185
+ """Render the final agent response to the terminal.
186
+
187
+ Expected keys (at least one required):
188
+ - result["text"] or result["response"]: main answer text
189
+ - result["evidence_items"]: EvidenceItem dict ๋ฆฌ์ŠคํŠธ (structured, ์šฐ์„ )
190
+ - result["citations"] or result["sources"]: list of source strings (fallback)
191
+ """
192
+ text_body: str = result.get("text") or result.get("response") or ""
193
+ evidence_items: list = result.get("evidence_items") or []
194
+ citations: list = result.get("citations") or result.get("sources") or []
195
+
196
+ use_rich, columns = _resolve_render_mode()
197
+
198
+ if use_rich:
199
+ content = Text(text_body)
200
+ if evidence_items:
201
+ evidence_text = render_evidence_section(evidence_items)
202
+ if evidence_text:
203
+ content.append(f"\n\n{evidence_text}\n", style="dim")
204
+ elif citations:
205
+ content.append("\n\n์ถœ์ฒ˜\n", style="bold")
206
+ for idx, src in enumerate(citations, 1):
207
+ content.append(f" {idx}. {src}\n", style="dim")
208
+ _console.print(
209
+ Panel(
210
+ content,
211
+ title="[bold green]GovOn[/bold green]",
212
+ border_style="green",
213
+ width=get_panel_width(columns),
214
+ )
215
+ )
216
+ else:
217
+ rule = _plain_rule(columns)
218
+ print(f"\n{rule}")
219
+ print("GovOn")
220
+ print(text_body)
221
+ if evidence_items:
222
+ evidence_text = render_evidence_section(evidence_items)
223
+ if evidence_text:
224
+ print(f"\n{evidence_text}")
225
+ elif citations:
226
+ print("\n์ถœ์ฒ˜")
227
+ for idx, src in enumerate(citations, 1):
228
+ print(f" {idx}. {src}")
229
+ print(f"{rule}\n")
230
+
231
+
232
+ def render_status(message: str) -> None:
233
+ """Render a transient status / progress message."""
234
+ use_rich, _ = _resolve_render_mode()
235
+ if use_rich:
236
+ _console.print(f"[dim]โ†’ {message}[/dim]")
237
+ else:
238
+ print(f"โ†’ {message}")
239
+
240
+
241
+ def render_error(message: str) -> None:
242
+ """Render an error message in red."""
243
+ use_rich, _ = _resolve_render_mode()
244
+ if use_rich:
245
+ _console.print(f"[bold red]์˜ค๋ฅ˜:[/bold red] {message}")
246
+ else:
247
+ print(f"์˜ค๋ฅ˜: {message}")
248
+
249
+
250
+ def render_session_info(session_id: str) -> None:
251
+ """Render session resume hint at shell exit."""
252
+ hint = f"[session: {session_id}] govon --session {session_id} ๋กœ ์žฌ๊ฐœ ๊ฐ€๋Šฅ"
253
+ use_rich, _ = _resolve_render_mode()
254
+ if use_rich:
255
+ _console.print(f"[dim]{hint}[/dim]")
256
+ else:
257
+ print(hint)
src/cli/shell.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GovOn CLI โ€” main REPL loop and entry point.
2
+
3
+ Entry point registered in pyproject.toml:
4
+ [project.scripts]
5
+ govon = "src.cli.shell:main"
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+
14
+ import httpx
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Optional dependencies โ€” graceful degradation
18
+ # ---------------------------------------------------------------------------
19
+ _PT_AVAILABLE = False
20
+ try:
21
+ from prompt_toolkit import PromptSession
22
+ from prompt_toolkit.history import InMemoryHistory
23
+
24
+ _PT_AVAILABLE = True
25
+ except ImportError: # pragma: no cover
26
+ pass
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Internal modules
30
+ # ---------------------------------------------------------------------------
31
+ from src.cli.approval_ui import show_approval_prompt
32
+ from src.cli.commands import handle_command, is_command
33
+ from src.cli.renderer import (
34
+ StreamingStatusDisplay,
35
+ get_node_message,
36
+ render_error,
37
+ render_result,
38
+ render_session_info,
39
+ render_status,
40
+ )
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Stub imports for daemon / http_client (other agents implement these).
44
+ # If the real modules exist they are used; otherwise lightweight stubs
45
+ # are defined inline so the shell can be imported and tested standalone.
46
+ # ---------------------------------------------------------------------------
47
+ try:
48
+ from src.cli.daemon import DaemonManager # type: ignore[import]
49
+ except ImportError: # pragma: no cover
50
+
51
+ class DaemonManager: # type: ignore[no-redef]
52
+ """Stub: real implementation provided by daemon.py agent."""
53
+
54
+ def ensure_running(self) -> str:
55
+ raise RuntimeError("DaemonManager not available. Install the full GovOn package.")
56
+
57
+ def is_running(self) -> bool:
58
+ return False
59
+
60
+ def stop(self) -> None:
61
+ pass
62
+
63
+
64
+ try:
65
+ from src.cli.http_client import GovOnClient # type: ignore[import]
66
+ except ImportError: # pragma: no cover
67
+
68
+ class GovOnClient: # type: ignore[no-redef]
69
+ """Stub: real implementation provided by http_client.py agent."""
70
+
71
+ def __init__(self, base_url: str) -> None:
72
+ self._base_url = base_url
73
+
74
+ def run(self, query: str, session_id: str | None = None) -> dict:
75
+ raise RuntimeError("GovOnClient not available. Install the full GovOn package.")
76
+
77
+ def stream(self, query: str, session_id: str | None = None):
78
+ raise RuntimeError("GovOnClient not available. Install the full GovOn package.")
79
+ yield # make it a generator
80
+
81
+ def approve(self, thread_id: str, approved: bool) -> dict:
82
+ raise RuntimeError("GovOnClient not available. Install the full GovOn package.")
83
+
84
+ def cancel(self, thread_id: str) -> dict:
85
+ raise RuntimeError("GovOnClient not available. Install the full GovOn package.")
86
+
87
+ def health(self) -> dict:
88
+ raise RuntimeError("GovOnClient not available. Install the full GovOn package.")
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Core helpers
93
+ # ---------------------------------------------------------------------------
94
+
95
+ _PROMPT_TEXT = "govon> "
96
+
97
+
98
+ def _get_input(session: "PromptSession | None") -> str: # type: ignore[name-defined]
99
+ """Read one line of user input (prompt_toolkit or plain input())."""
100
+ if _PT_AVAILABLE and session is not None:
101
+ return session.prompt(_PROMPT_TEXT)
102
+ return input(_PROMPT_TEXT)
103
+
104
+
105
+ def _process_query(
106
+ client: "GovOnClient",
107
+ query: str,
108
+ session_id: str | None,
109
+ ) -> tuple[str | None, bool]:
110
+ """Send *query* to the backend and handle approval flow.
111
+
112
+ Attempts to use the streaming endpoint (/v2/agent/stream) for per-node
113
+ progress display. Falls back to the blocking run() call when the streaming
114
+ endpoint is unavailable.
115
+
116
+ Returns (new_session_id, should_continue).
117
+ `should_continue` is False only when an unrecoverable error is returned
118
+ that suggests the daemon is down.
119
+ """
120
+ # --- Try streaming path first ---
121
+ try:
122
+ return _process_query_streaming(client, query, session_id)
123
+ except (AttributeError, NotImplementedError):
124
+ # client.stream() is not available (stub or older server)
125
+ pass
126
+ except (ConnectionError, httpx.HTTPStatusError, httpx.StreamError, OSError):
127
+ # Streaming endpoint unavailable โ€” fall back silently
128
+ pass
129
+
130
+ # --- Fallback: blocking run() with simple spinner ---
131
+ return _process_query_blocking(client, query, session_id)
132
+
133
+
134
+ def _process_query_streaming(
135
+ client: "GovOnClient",
136
+ query: str,
137
+ session_id: str | None,
138
+ ) -> tuple[str | None, bool]:
139
+ """Streaming path: calls client.stream() and shows per-node progress."""
140
+ final_response: dict = {}
141
+ approval_event: dict | None = None
142
+ new_session_id: str | None = None
143
+
144
+ with StreamingStatusDisplay("์ฒ˜๋ฆฌ ์ค‘โ€ฆ") as status_display:
145
+ for event in client.stream(query, session_id):
146
+ node: str = event.get("node", "")
147
+ event_status: str = event.get("status", "")
148
+
149
+ if node == "error" or event_status == "error":
150
+ render_error(event.get("error", "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."))
151
+ return session_id, True
152
+
153
+ if event_status == "awaiting_approval":
154
+ approval_event = event
155
+ break
156
+
157
+ # Update spinner with node-specific message
158
+ if node:
159
+ msg = get_node_message(node)
160
+ status_display.update(msg)
161
+
162
+ # Collect session/thread id from any event
163
+ if not new_session_id:
164
+ new_session_id = event.get("session_id") or event.get("thread_id")
165
+
166
+ # Collect final result if present
167
+ if event_status == "completed" or event.get("final_text") or event.get("text"):
168
+ final_response = event
169
+
170
+ # Handle approval
171
+ if approval_event is not None:
172
+ if not new_session_id:
173
+ new_session_id = approval_event.get("session_id") or approval_event.get("thread_id")
174
+ approval_request: dict = approval_event.get("approval_request") or {}
175
+ approved = show_approval_prompt(approval_request)
176
+ thread_id: str = approval_event.get("thread_id") or ""
177
+
178
+ if not approved:
179
+ try:
180
+ client.approve(thread_id, approved=False)
181
+ except Exception: # pragma: no cover
182
+ pass
183
+ return new_session_id or session_id, True
184
+
185
+ render_status("์Šน์ธ๋จ โ€” ๊ณ„์† ์ง„ํ–‰ ์ค‘โ€ฆ")
186
+ try:
187
+ approved_response = client.approve(thread_id, approved=True)
188
+ except Exception as exc: # pragma: no cover
189
+ render_error(f"์Šน์ธ ์š”์ฒญ ์‹คํŒจ: {exc}")
190
+ return new_session_id or session_id, True
191
+
192
+ render_result(approved_response)
193
+ return (
194
+ approved_response.get("session_id")
195
+ or approved_response.get("thread_id")
196
+ or new_session_id
197
+ or session_id,
198
+ True,
199
+ )
200
+
201
+ # Handle completed result from streaming events
202
+ if final_response:
203
+ _sid = final_response.get("session_id") or final_response.get("thread_id") or new_session_id
204
+ render_result(final_response)
205
+ return _sid or session_id, True
206
+
207
+ # No useful response received
208
+ render_result({"text": ""})
209
+ return new_session_id or session_id, True
210
+
211
+
212
+ def _process_query_blocking(
213
+ client: "GovOnClient",
214
+ query: str,
215
+ session_id: str | None,
216
+ ) -> tuple[str | None, bool]:
217
+ """Blocking fallback path: calls client.run() with a simple spinner."""
218
+ render_status("์ฒ˜๋ฆฌ ์ค‘โ€ฆ")
219
+
220
+ try:
221
+ response = client.run(query, session_id)
222
+ except Exception as exc: # pragma: no cover
223
+ render_error(f"์š”์ฒญ ์‹คํŒจ: {exc}")
224
+ return session_id, True
225
+
226
+ new_session_id: str | None = response.get("session_id") or response.get("thread_id")
227
+ status: str = response.get("status", "")
228
+
229
+ if status == "awaiting_approval":
230
+ approval_request: dict = response.get("approval_request") or {}
231
+ approved = show_approval_prompt(approval_request)
232
+
233
+ if not approved:
234
+ # ๊ฑฐ์ ˆ: ์„œ๋ฒ„์— ํ†ต๋ณด ํ›„ ํ”„๋กฌํ”„ํŠธ ๋ณต๊ท€
235
+ _thread_id: str = response.get("thread_id") or ""
236
+ try:
237
+ client.approve(_thread_id, approved=False)
238
+ except Exception: # pragma: no cover
239
+ pass
240
+ return new_session_id or session_id, True
241
+
242
+ thread_id: str = response.get("thread_id") or ""
243
+ render_status("์Šน์ธ๋จ โ€” ๊ณ„์† ์ง„ํ–‰ ์ค‘โ€ฆ")
244
+ try:
245
+ approved_response = client.approve(thread_id, approved=True)
246
+ except Exception as exc: # pragma: no cover
247
+ render_error(f"์Šน์ธ ์š”์ฒญ ์‹คํŒจ: {exc}")
248
+ return new_session_id or session_id, True
249
+
250
+ render_result(approved_response)
251
+ return (
252
+ approved_response.get("session_id")
253
+ or approved_response.get("thread_id")
254
+ or new_session_id
255
+ or session_id,
256
+ True,
257
+ )
258
+
259
+ if status in ("completed", "done", "success") or "text" in response or "response" in response:
260
+ render_result(response)
261
+ return new_session_id or session_id, True
262
+
263
+ # Unknown status โ€” render raw
264
+ render_result({"text": str(response)})
265
+ return new_session_id or session_id, True
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # REPL loop
270
+ # ---------------------------------------------------------------------------
271
+
272
+
273
+ def _run_repl(client: "GovOnClient", initial_session_id: str | None = None) -> None:
274
+ """Run the interactive REPL until EOF or /exit."""
275
+ session_id: str | None = initial_session_id
276
+ pt_session = PromptSession(history=InMemoryHistory()) if _PT_AVAILABLE else None
277
+
278
+ while True:
279
+ try:
280
+ text = _get_input(pt_session).strip()
281
+ except EOFError:
282
+ # Ctrl+D
283
+ break
284
+ except KeyboardInterrupt:
285
+ # Ctrl+C while idle โ†’ exit
286
+ print()
287
+ break
288
+
289
+ if not text:
290
+ continue
291
+
292
+ if is_command(text):
293
+ try:
294
+ result = handle_command(text)
295
+ except SystemExit:
296
+ break
297
+ if result is not None:
298
+ print(result)
299
+ continue
300
+
301
+ # Normal query
302
+ try:
303
+ session_id, should_continue = _process_query(client, text, session_id)
304
+ except KeyboardInterrupt:
305
+ # Ctrl+C while processing โ†’ cancel and return to prompt
306
+ print("\n์š”์ฒญ์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")
307
+ continue
308
+
309
+ if not should_continue: # pragma: no cover
310
+ break
311
+
312
+ if session_id:
313
+ render_session_info(session_id)
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # Single-shot mode
318
+ # ---------------------------------------------------------------------------
319
+
320
+
321
+ def _run_once(client: "GovOnClient", query: str, session_id: str | None) -> None:
322
+ """Run a single query and exit."""
323
+ new_session_id, _ = _process_query(client, query, session_id)
324
+ if new_session_id:
325
+ render_session_info(new_session_id)
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Entry point
330
+ # ---------------------------------------------------------------------------
331
+
332
+
333
+ def main() -> None:
334
+ """CLI entry point for the `govon` command."""
335
+ parser = argparse.ArgumentParser(
336
+ prog="govon",
337
+ description="GovOn โ€” shell-first local agentic runtime",
338
+ formatter_class=argparse.RawTextHelpFormatter,
339
+ )
340
+ parser.add_argument(
341
+ "query",
342
+ nargs="?",
343
+ default=None,
344
+ help="๋‹จ๋ฐœ ์‹คํ–‰ํ•  ์งˆ๋ฌธ (์ƒ๋žต ์‹œ ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ REPL ๋ชจ๋“œ)",
345
+ )
346
+ parser.add_argument(
347
+ "--session",
348
+ metavar="SESSION_ID",
349
+ default=None,
350
+ help="์žฌ๊ฐœํ•  ๊ธฐ์กด ์„ธ์…˜ ID",
351
+ )
352
+ parser.add_argument(
353
+ "--status",
354
+ action="store_true",
355
+ help="daemon ์ƒํƒœ ํ™•์ธ ํ›„ ์ข…๋ฃŒ",
356
+ )
357
+ parser.add_argument(
358
+ "--stop",
359
+ action="store_true",
360
+ help="daemon ์ค‘์ง€ ํ›„ ์ข…๋ฃŒ",
361
+ )
362
+
363
+ args = parser.parse_args()
364
+
365
+ # GOVON_RUNTIME_URL์ด ์„ค์ •๋œ ๊ฒฝ์šฐ ์›๊ฒฉ ์„œ๋ฒ„์— ์ง์ ‘ ์—ฐ๊ฒฐํ•˜๊ณ  daemon์„ ๊ด€๋ฆฌํ•˜์ง€ ์•Š๋Š”๋‹ค.
366
+ runtime_url = os.environ.get("GOVON_RUNTIME_URL")
367
+
368
+ if runtime_url:
369
+ if not runtime_url.startswith(("http://", "https://")):
370
+ print(
371
+ f"์˜ค๋ฅ˜: GOVON_RUNTIME_URL์€ http:// ๋˜๋Š” https://๋กœ ์‹œ์ž‘ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค: {runtime_url}",
372
+ file=sys.stderr,
373
+ )
374
+ sys.exit(1)
375
+ # ์›๊ฒฉ ๋Ÿฐํƒ€์ž„ ๋ชจ๋“œ: daemon ๊ด€๋ฆฌ ์—†์ด ์ง€์ •๋œ URL์— ์ง์ ‘ ์—ฐ๊ฒฐ
376
+ if args.status:
377
+ print(f"GovOn daemon: ์›๊ฒฉ ๋ชจ๋“œ (GOVON_RUNTIME_URL={runtime_url})")
378
+ sys.exit(0)
379
+ if args.stop:
380
+ print("์˜ค๋ฅ˜: ์›๊ฒฉ ๋Ÿฐํƒ€์ž„ ๋ชจ๋“œ์—์„œ๋Š” --stop์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", file=sys.stderr)
381
+ sys.exit(1)
382
+ base_url = runtime_url.rstrip("/")
383
+ else:
384
+ # ๋กœ์ปฌ daemon ๋ชจ๋“œ
385
+ daemon = DaemonManager()
386
+
387
+ # --status
388
+ if args.status:
389
+ if daemon.is_running():
390
+ print("GovOn daemon: ์‹คํ–‰ ์ค‘")
391
+ else:
392
+ print("GovOn daemon: ์ค‘์ง€๋จ")
393
+ sys.exit(0)
394
+
395
+ # --stop
396
+ if args.stop:
397
+ daemon.stop()
398
+ print("GovOn daemon์ด ์ค‘์ง€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")
399
+ sys.exit(0)
400
+
401
+ # Ensure daemon is up and get base URL
402
+ try:
403
+ base_url = daemon.ensure_running()
404
+ except Exception as exc:
405
+ print(f"์˜ค๋ฅ˜: daemon์„ ์‹œ์ž‘ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค โ€” {exc}", file=sys.stderr)
406
+ sys.exit(1)
407
+
408
+ client = GovOnClient(base_url)
409
+
410
+ if args.query:
411
+ # Single-shot mode
412
+ _run_once(client, args.query, args.session)
413
+ else:
414
+ # Interactive REPL mode
415
+ print("GovOn CLI (์ข…๋ฃŒ: Ctrl+D ๋˜๋Š” /exit)")
416
+ _run_repl(client, initial_session_id=args.session)
417
+
418
+
419
+ if __name__ == "__main__":
420
+ main()
src/cli/terminal.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Terminal layout helpers for the GovOn CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+
7
+ DEFAULT_TERMINAL_COLUMNS = 80
8
+ MIN_TERMINAL_COLUMNS = 40
9
+ MIN_CONTENT_WIDTH = 20
10
+ APPROVAL_BOX_MAX_WIDTH = 55
11
+ APPROVAL_BOX_MARGIN = 4
12
+ PANEL_MARGIN = 2
13
+
14
+ assert MIN_CONTENT_WIDTH < MIN_TERMINAL_COLUMNS
15
+
16
+
17
+ def get_terminal_columns(default: int = DEFAULT_TERMINAL_COLUMNS) -> int:
18
+ """Return the current terminal width in columns."""
19
+ return max(shutil.get_terminal_size(fallback=(default, 24)).columns, 1)
20
+
21
+
22
+ def _cols(columns: int | None) -> int:
23
+ """Resolve an explicit column override or read the current terminal width."""
24
+ return get_terminal_columns() if columns is None else columns
25
+
26
+
27
+ def is_layout_supported(columns: int | None = None) -> bool:
28
+ """Return True when the terminal is wide enough for full rich layouts."""
29
+ return _cols(columns) >= MIN_TERMINAL_COLUMNS
30
+
31
+
32
+ def get_approval_box_width(columns: int | None = None) -> int:
33
+ """Return the inner width for the approval box.
34
+
35
+ Callers should gate rich box rendering with `is_layout_supported()` first.
36
+ For very narrow terminals, plain fallback is the supported rendering path.
37
+ """
38
+ current_columns = _cols(columns)
39
+ return max(
40
+ MIN_CONTENT_WIDTH,
41
+ min(APPROVAL_BOX_MAX_WIDTH, current_columns - APPROVAL_BOX_MARGIN),
42
+ )
43
+
44
+
45
+ def get_panel_width(columns: int | None = None) -> int:
46
+ """Return the rich panel width for result rendering."""
47
+ return max(MIN_CONTENT_WIDTH, _cols(columns) - PANEL_MARGIN)
48
+
49
+
50
+ def get_narrow_terminal_warning(columns: int | None = None) -> str:
51
+ """Return the warning shown when the terminal is too narrow."""
52
+ current_columns = _cols(columns)
53
+ return (
54
+ f"ํ„ฐ๋ฏธ๋„ ๋„ˆ๋น„๊ฐ€ {current_columns}์—ด๋กœ ์ข์•„ plain mode๋กœ ์ „ํ™˜ํ•ฉ๋‹ˆ๋‹ค. "
55
+ f"์ตœ์†Œ {MIN_TERMINAL_COLUMNS}์—ด ์ด์ƒ์—์„œ ์ „์ฒด ๋ ˆ์ด์•„์›ƒ์ด ๋ณด์žฅ๋ฉ๋‹ˆ๋‹ค."
56
+ )
src/data_collection_preprocessing/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """๋ฏผ์›๋‹ต๋ณ€ ์–ด๋Œ‘ํ„ฐ์šฉ ํ•™์Šต ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ๋ฐ ์ „์ฒ˜๋ฆฌ ํŒจํ‚ค์ง€."""
2
+
3
+ from .config import DataConfig
4
+ from .parsers import AdminLawParser, GovQAParser, GukripParser
5
+ from .pipeline import CivilResponseDataPipeline
6
+
7
+ __all__ = [
8
+ "DataConfig",
9
+ "GukripParser",
10
+ "GovQAParser",
11
+ "AdminLawParser",
12
+ "CivilResponseDataPipeline",
13
+ ]
src/data_collection_preprocessing/__main__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """python -m src.data_collection_preprocessing ์ง„์ž…์ ."""
2
+
3
+ from .cli import main
4
+
5
+ main()
src/data_collection_preprocessing/cli.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI entry point: python -m src.data_collection_preprocessing"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from .config import DataConfig
11
+ from .pipeline import CivilResponseDataPipeline
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser(description="๋ฏผ์›๋‹ต๋ณ€ ์–ด๋Œ‘ํ„ฐ ํ•™์Šต ๋ฐ์ดํ„ฐ ํŒŒ์ดํ”„๋ผ์ธ")
16
+ parser.add_argument(
17
+ "--raw-dir",
18
+ default="data/raw/aihub",
19
+ help="AI Hub ์›์‹œ ๋ฐ์ดํ„ฐ ๋ฃจํŠธ ๋””๋ ‰ํ„ฐ๋ฆฌ (๊ธฐ๋ณธ: data/raw/aihub)",
20
+ )
21
+ parser.add_argument(
22
+ "--output-dir",
23
+ default="data/processed",
24
+ help="์ถœ๋ ฅ ๋””๋ ‰ํ„ฐ๋ฆฌ (๊ธฐ๋ณธ: data/processed)",
25
+ )
26
+ parser.add_argument(
27
+ "--min-answer-length",
28
+ type=int,
29
+ default=30,
30
+ help="์ตœ์†Œ ๋‹ต๋ณ€ ๊ธธ์ด (๊ธฐ๋ณธ: 30์ž)",
31
+ )
32
+ parser.add_argument(
33
+ "--max-answer-length",
34
+ type=int,
35
+ default=4096,
36
+ help="์ตœ๋Œ€ ๋‹ต๋ณ€ ๊ธธ์ด (๊ธฐ๋ณธ: 4096์ž)",
37
+ )
38
+ parser.add_argument(
39
+ "--train-ratio",
40
+ type=float,
41
+ default=0.9,
42
+ help="train ๋น„์œจ (๊ธฐ๋ณธ: 0.9)",
43
+ )
44
+ parser.add_argument(
45
+ "--log-level",
46
+ default="INFO",
47
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
48
+ )
49
+ args = parser.parse_args()
50
+
51
+ logging.basicConfig(
52
+ level=getattr(logging, args.log_level),
53
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
54
+ stream=sys.stdout,
55
+ )
56
+
57
+ config = DataConfig(
58
+ raw_dir=Path(args.raw_dir),
59
+ output_dir=Path(args.output_dir),
60
+ min_answer_length=args.min_answer_length,
61
+ max_answer_length=args.max_answer_length,
62
+ train_ratio=args.train_ratio,
63
+ )
64
+
65
+ pipeline = CivilResponseDataPipeline(config)
66
+ stats = pipeline.run()
67
+
68
+ print("\nํŒŒ์ดํ”„๋ผ์ธ ์™„๋ฃŒ")
69
+ print(f" ์ด ๋ ˆ์ฝ”๋“œ: {stats['total']:,}")
70
+ print(f" train: {stats['train']:,}")
71
+ print(f" val: {stats['val']:,}")
72
+ print(f" ์ถœ๋ ฅ ๊ฒฝ๋กœ: {args.output_dir}/")
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
src/data_collection_preprocessing/config.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """๋ฐ์ดํ„ฐ ํŒŒ์ดํ”„๋ผ์ธ ์„ค์ •."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass
8
+ class DataConfig:
9
+ raw_dir: Path = Path("data/raw/aihub")
10
+ output_dir: Path = Path("data/processed")
11
+ min_answer_length: int = 30
12
+ max_answer_length: int = 4096
13
+ min_question_length: int = 5
14
+ train_ratio: float = 0.9
src/data_collection_preprocessing/parsers.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AI Hub ๋ฐ์ดํ„ฐ์…‹ ํŒŒ์„œ ๋ชจ๋“ˆ.
2
+
3
+ ๊ฐ ํŒŒ์„œ๋Š” ๋‹จ์ผ JSON ํŒŒ์ผ์„ ํŒŒ์‹ฑํ•˜์—ฌ ํ•™์Šต ๋ ˆ์ฝ”๋“œ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
4
+ ๋ฐ˜ํ™˜ ํ˜•์‹:
5
+ {
6
+ "question": str,
7
+ "answer": str,
8
+ "source": str,
9
+ "category": str,
10
+ "metadata": dict,
11
+ }
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+
22
+ def _load_json(filepath: Path) -> Any:
23
+ with open(filepath, encoding="utf-8") as f:
24
+ return json.load(f)
25
+
26
+
27
+ class GukripParser:
28
+ """71852 ๊ตญ๋ฆฝ์•„์‹œ์•„๋ฌธํ™”์ „๋‹น ํŒŒ์„œ.
29
+
30
+ consulting_content์˜ '์ƒ๋‹ด์›:' ๋ฐœํ™”๋ฅผ ์ถ”์ถœํ•˜์—ฌ ๋‹ต๋ณ€์œผ๋กœ ์‚ฌ์šฉํ•˜๊ณ ,
31
+ instructions[0].data[0].instruction์„ ์งˆ๋ฌธ์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค.
32
+ """
33
+
34
+ def parse(self, filepath: Path) -> list[dict]:
35
+ data = _load_json(filepath)
36
+ if isinstance(data, list):
37
+ records = []
38
+ for item in data:
39
+ records.extend(self._parse_item(item))
40
+ return records
41
+ return self._parse_item(data)
42
+
43
+ def _parse_item(self, item: dict) -> list[dict]:
44
+ content: str = item.get("consulting_content", "")
45
+ source_id: str = item.get("source_id", "")
46
+ consulting_date: str = item.get("consulting_date", "")
47
+ category: str = item.get("consulting_category", "")
48
+
49
+ # ์ƒ๋‹ด์› ๋ฐœํ™” ์ถ”์ถœ
50
+ agent_turns = []
51
+ for line in content.split("\n"):
52
+ line = line.strip()
53
+ if line.startswith("์ƒ๋‹ด์›:"):
54
+ turn_text = line[len("์ƒ๋‹ด์›:") :].strip()
55
+ if turn_text:
56
+ agent_turns.append(turn_text)
57
+
58
+ if not agent_turns:
59
+ return []
60
+
61
+ answer = " ".join(agent_turns)
62
+
63
+ # instruction์—์„œ ์งˆ๋ฌธ ์ถ”์ถœ
64
+ instructions = item.get("instructions", [])
65
+ if not instructions:
66
+ return []
67
+
68
+ data_list = instructions[0].get("data", [])
69
+ if not data_list:
70
+ return []
71
+
72
+ question = data_list[0].get("instruction", "").strip()
73
+ if not question:
74
+ return []
75
+
76
+ return [
77
+ {
78
+ "question": question,
79
+ "answer": answer,
80
+ "source": "71852_๊ตญ๋ฆฝ์•„์‹œ์•„๋ฌธํ™”์ „๋‹น",
81
+ "category": category,
82
+ "metadata": {
83
+ "source_id": source_id,
84
+ "consulting_date": consulting_date,
85
+ },
86
+ }
87
+ ]
88
+
89
+
90
+ class GovQAParser:
91
+ """71852 ์ค‘์•™/์ง€๋ฐฉํ–‰์ •๊ธฐ๊ด€ ํŒŒ์„œ.
92
+
93
+ consulting_content์—์„œ Q/A ํ˜•์‹์„ ํŒŒ์‹ฑํ•˜์—ฌ ๊ณต์‹ ์ •๋ถ€ ๋‹ต๋ณ€์„ ์ถ”์ถœํ•œ๋‹ค.
94
+ ๋ณด์กฐ ์งˆ๋ฌธ(instructions.data[*].instruction)์€ ๋ณ„๋„ ๋ ˆ์ฝ”๋“œ๋กœ ์ƒ์„ฑํ•œ๋‹ค.
95
+ """
96
+
97
+ # A ๊ตฌ๋ถ„์ž ํŒจํ„ด: "\nA :" ๋˜๋Š” "\nA:"
98
+ _A_SEP = re.compile(r"\nA\s*:")
99
+
100
+ def parse(self, filepath: Path) -> list[dict]:
101
+ data = _load_json(filepath)
102
+ if isinstance(data, list):
103
+ records = []
104
+ for item in data:
105
+ records.extend(self._parse_item(item))
106
+ return records
107
+ return self._parse_item(data)
108
+
109
+ def _parse_item(self, item: dict) -> list[dict]:
110
+ content: str = item.get("consulting_content", "")
111
+ source_str: str = item.get("source", "")
112
+ source_id: str = item.get("source_id", "")
113
+ consulting_date: str = item.get("consulting_date", "")
114
+ category: str = item.get("consulting_category", "")
115
+
116
+ # A ๋ถ€๋ถ„ ๋ถ„๋ฆฌ
117
+ parts = self._A_SEP.split(content, maxsplit=1)
118
+ if len(parts) < 2:
119
+ return []
120
+
121
+ q_part, a_part = parts[0], parts[1].strip()
122
+ if not a_part:
123
+ return []
124
+
125
+ # Q ๋ถ€๋ถ„์—์„œ ์งˆ๋ฌธ ์ถ”์ถœ
126
+ question = self._extract_question(q_part)
127
+ if not question:
128
+ return []
129
+
130
+ records = [
131
+ {
132
+ "question": question,
133
+ "answer": a_part,
134
+ "source": "71852_์ค‘์•™ํ–‰์ •๊ธฐ๊ด€",
135
+ "category": category,
136
+ "metadata": {
137
+ "source_id": source_id,
138
+ "consulting_date": consulting_date,
139
+ "org": source_str,
140
+ },
141
+ }
142
+ ]
143
+
144
+ # ๋ณด์กฐ ์งˆ๋ฌธ(instructions.data[*].instruction)์œผ๋กœ ์ถ”๊ฐ€ ๋ ˆ์ฝ”๋“œ ์ƒ์„ฑ
145
+ instructions = item.get("instructions", [])
146
+ if instructions:
147
+ for instr_item in instructions[0].get("data", []):
148
+ sub_q = instr_item.get("instruction", "").strip()
149
+ if sub_q and sub_q != question:
150
+ records.append(
151
+ {
152
+ "question": sub_q,
153
+ "answer": a_part,
154
+ "source": "71852_์ค‘์•™ํ–‰์ •๊ธฐ๊ด€",
155
+ "category": category,
156
+ "metadata": {
157
+ "source_id": source_id,
158
+ "consulting_date": consulting_date,
159
+ "org": source_str,
160
+ "question_type": "auxiliary",
161
+ },
162
+ }
163
+ )
164
+
165
+ return records
166
+
167
+ @staticmethod
168
+ def _extract_question(q_part: str) -> str:
169
+ """Q ๋ธ”๋ก์—์„œ ์งˆ๋ฌธ ํ…์ŠคํŠธ๋ฅผ ์ถ”์ถœํ•œ๋‹ค."""
170
+ # "Q :" ๋˜๋Š” "Q:" ์ดํ›„ ํ…์ŠคํŠธ ์ถ”์ถœ
171
+ q_match = re.search(r"\nQ\s*:(.*?)(?=\n\n|\Z)", q_part, re.DOTALL)
172
+ if q_match:
173
+ return q_match.group(1).strip()
174
+
175
+ # fallback: "์ œ๋ชฉ :" ์ดํ›„ ํ…์ŠคํŠธ
176
+ title_match = re.search(r"์ œ๋ชฉ\s*:\s*(.+)", q_part)
177
+ if title_match:
178
+ return title_match.group(1).strip()
179
+
180
+ return q_part.strip()
181
+
182
+
183
+ class GovQALocalParser(GovQAParser):
184
+ """71852 ์ง€๋ฐฉํ–‰์ •๊ธฐ๊ด€ ํŒŒ์„œ โ€” GovQAParser์™€ ๋™์ผํ•œ ๋กœ์ง, source ๋ ˆ์ด๋ธ”๋งŒ ๋‹ค๋ฆ„."""
185
+
186
+ def _parse_item(self, item: dict) -> list[dict]:
187
+ records = super()._parse_item(item)
188
+ for r in records:
189
+ r["source"] = "71852_์ง€๋ฐฉํ–‰์ •๊ธฐ๊ด€"
190
+ return records
191
+
192
+
193
+ class AdminLawParser:
194
+ """71847 ํ–‰์ •๋ฒ• ํŒŒ์„œ.
195
+
196
+ label.input์„ ์งˆ๋ฌธ, label.output์„ ๋‹ต๋ณ€์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค.
197
+ ๊ฒฐ์ •๋ก€(TL_๊ฒฐ์ •๋ก€_QA)์™€ ๋ฒ•๋ น(TL_๋ฒ•๋ น_QA) ๋ชจ๋‘ ๋™์ผ ๊ตฌ์กฐ.
198
+ """
199
+
200
+ def __init__(self, source_label: str = "71847_๊ฒฐ์ •๋ก€"):
201
+ self.source_label = source_label
202
+
203
+ def parse(self, filepath: Path) -> list[dict]:
204
+ data = _load_json(filepath)
205
+ if isinstance(data, list):
206
+ records = []
207
+ for item in data:
208
+ records.extend(self._parse_item(item))
209
+ return records
210
+ return self._parse_item(data)
211
+
212
+ def _parse_item(self, item: dict) -> list[dict]:
213
+ label = item.get("label", {})
214
+ question = label.get("input", "").strip()
215
+ answer = label.get("output", "").strip()
216
+
217
+ if not question or not answer:
218
+ return []
219
+
220
+ info = item.get("info", {})
221
+ case_name = info.get("caseName", info.get("title", ""))
222
+ category = info.get("ministry", info.get("caseCode", ""))
223
+
224
+ return [
225
+ {
226
+ "question": question,
227
+ "answer": answer,
228
+ "source": self.source_label,
229
+ "category": category,
230
+ "metadata": {
231
+ "case_name": case_name,
232
+ "law_class": info.get("lawClass", ""),
233
+ },
234
+ }
235
+ ]
src/data_collection_preprocessing/pipeline.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """๋ฏผ์›๋‹ต๋ณ€ ํ•™์Šต ๋ฐ์ดํ„ฐ ํŒŒ์ดํ”„๋ผ์ธ."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import random
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .config import DataConfig
13
+ from .parsers import AdminLawParser, GovQALocalParser, GovQAParser, GukripParser
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class CivilResponseDataPipeline:
19
+ """AI Hub ์›์‹œ ๋ฐ์ดํ„ฐ๋ฅผ instruction-tuning JSONL๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ํŒŒ์ดํ”„๋ผ์ธ."""
20
+
21
+ INSTRUCTION_TEXT = "๋‹ค์Œ ๋ฏผ์›์— ๋Œ€ํ•œ ๋‹ต๋ณ€์„ ์ž‘์„ฑํ•ด ์ฃผ์„ธ์š”."
22
+
23
+ def __init__(self, config: DataConfig | None = None):
24
+ self.config = config or DataConfig()
25
+
26
+ def run(self) -> dict[str, int]:
27
+ """์ „์ฒด ํŒŒ์ดํ”„๋ผ์ธ ์‹คํ–‰. ๊ฒฐ๊ณผ ํ†ต๊ณ„ ๋ฐ˜ํ™˜."""
28
+ records: list[dict] = []
29
+
30
+ logger.info("71852 ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ์‹œ์ž‘")
31
+ records_71852 = self._process_71852()
32
+ logger.info("71852 ๋ฐ์ดํ„ฐ %d๊ฐœ ์ˆ˜์ง‘", len(records_71852))
33
+ records.extend(records_71852)
34
+
35
+ logger.info("71847 ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ์‹œ์ž‘")
36
+ records_71847 = self._process_71847()
37
+ logger.info("71847 ๋ฐ์ดํ„ฐ %d๊ฐœ ์ˆ˜์ง‘", len(records_71847))
38
+ records.extend(records_71847)
39
+
40
+ logger.info("์ค‘๋ณต ์ œ๊ฑฐ ์ „ ์ด %d๊ฐœ", len(records))
41
+ records = self._deduplicate(records)
42
+ logger.info("์ค‘๋ณต ์ œ๊ฑฐ ํ›„ %d๊ฐœ", len(records))
43
+
44
+ records = self._filter(records)
45
+ logger.info("ํ•„ํ„ฐ๋ง ํ›„ %d๊ฐœ", len(records))
46
+
47
+ train, val = self._split(records)
48
+ logger.info("train=%d, val=%d", len(train), len(val))
49
+
50
+ output_dir = Path(self.config.output_dir)
51
+ output_dir.mkdir(parents=True, exist_ok=True)
52
+
53
+ self._save_jsonl(train, output_dir / "train.jsonl")
54
+ self._save_jsonl(val, output_dir / "val.jsonl")
55
+
56
+ return {
57
+ "total": len(records),
58
+ "train": len(train),
59
+ "val": len(val),
60
+ }
61
+
62
+ # ------------------------------------------------------------------
63
+ # ๋ฐ์ดํ„ฐ์…‹๋ณ„ ์ฒ˜๋ฆฌ
64
+ # ------------------------------------------------------------------
65
+
66
+ def _process_71852(self) -> list[dict]:
67
+ base = Path(self.config.raw_dir) / "71852"
68
+ records: list[dict] = []
69
+
70
+ # ๊ตญ๋ฆฝ์•„์‹œ์•„๋ฌธํ™”์ „๋‹น
71
+ gukrp = GukripParser()
72
+ for split in ("train", "val"):
73
+ dir_path = base / split / "๊ตญ๋ฆฝ"
74
+ if dir_path.exists():
75
+ records.extend(self._parse_dir(gukrp, dir_path))
76
+
77
+ # ์ค‘์•™ํ–‰์ •๊ธฐ๊ด€
78
+ gov_central = GovQAParser()
79
+ for split in ("train", "val"):
80
+ dir_path = base / split / "์ค‘์•™"
81
+ if dir_path.exists():
82
+ records.extend(self._parse_dir(gov_central, dir_path))
83
+
84
+ # ์ง€๋ฐฉํ–‰์ •๊ธฐ๊ด€
85
+ gov_local = GovQALocalParser()
86
+ for split in ("train", "val"):
87
+ dir_path = base / split / "์ง€๋ฐฉ"
88
+ if dir_path.exists():
89
+ records.extend(self._parse_dir(gov_local, dir_path))
90
+
91
+ return records
92
+
93
+ def _process_71847(self) -> list[dict]:
94
+ base = Path(self.config.raw_dir) / "71847"
95
+ records: list[dict] = []
96
+
97
+ # ๊ฒฐ์ •๋ก€ QA
98
+ decision_parser = AdminLawParser(source_label="71847_๊ฒฐ์ •๋ก€")
99
+ dir_path = base / "TL_๊ฒฐ์ •๋ก€_QA"
100
+ if dir_path.exists():
101
+ records.extend(self._parse_dir(decision_parser, dir_path))
102
+
103
+ # ๋ฒ•๋ น QA
104
+ law_parser = AdminLawParser(source_label="71847_๋ฒ•๋ น")
105
+ dir_path = base / "TL_๋ฒ•๋ น_QA"
106
+ if dir_path.exists():
107
+ records.extend(self._parse_dir(law_parser, dir_path))
108
+
109
+ return records
110
+
111
+ # ------------------------------------------------------------------
112
+ # ์œ ํ‹ธ๋ฆฌํ‹ฐ
113
+ # ------------------------------------------------------------------
114
+
115
+ @staticmethod
116
+ def _parse_dir(parser: Any, dir_path: Path) -> list[dict]:
117
+ records: list[dict] = []
118
+ json_files = list(dir_path.glob("*.json"))
119
+ logger.debug(" %s: %d ํŒŒ์ผ", dir_path, len(json_files))
120
+ for filepath in json_files:
121
+ try:
122
+ records.extend(parser.parse(filepath))
123
+ except Exception as exc: # noqa: BLE001
124
+ logger.warning("ํŒŒ์‹ฑ ์‹คํŒจ %s: %s", filepath, exc)
125
+ return records
126
+
127
+ def _deduplicate(self, records: list[dict]) -> list[dict]:
128
+ """์งˆ๋ฌธ+๋‹ต๋ณ€ ํ•ด์‹œ ๊ธฐ๋ฐ˜ ์ค‘๋ณต ์ œ๊ฑฐ."""
129
+ seen: set[str] = set()
130
+ unique: list[dict] = []
131
+ for rec in records:
132
+ key = hashlib.md5( # nosec B324
133
+ (rec["question"] + rec["answer"]).encode("utf-8"),
134
+ usedforsecurity=False,
135
+ ).hexdigest()
136
+ if key not in seen:
137
+ seen.add(key)
138
+ unique.append(rec)
139
+ return unique
140
+
141
+ def _filter(self, records: list[dict]) -> list[dict]:
142
+ """๊ธธ์ด ํ•„ํ„ฐ๋ง."""
143
+ filtered: list[dict] = []
144
+ for rec in records:
145
+ answer_len = len(rec["answer"])
146
+ question_len = len(rec["question"])
147
+ if answer_len < self.config.min_answer_length:
148
+ continue
149
+ if answer_len > self.config.max_answer_length:
150
+ continue
151
+ if question_len < self.config.min_question_length:
152
+ continue
153
+ filtered.append(rec)
154
+ return filtered
155
+
156
+ def _split(self, records: list[dict]) -> tuple[list[dict], list[dict]]:
157
+ """train/val ๋ถ„๋ฆฌ (์…”ํ”Œ ํ›„ ๋น„์œจ ๋ถ„ํ• )."""
158
+ shuffled = list(records)
159
+ random.seed(42)
160
+ random.shuffle(shuffled)
161
+ split_idx = int(len(shuffled) * self.config.train_ratio)
162
+ return shuffled[:split_idx], shuffled[split_idx:]
163
+
164
+ def _save_jsonl(self, records: list[dict], filepath: Path) -> None:
165
+ """Instruction-tuning ํ‘œ์ค€ JSONL ํ˜•์‹์œผ๋กœ ์ €์žฅ."""
166
+ filepath = Path(filepath)
167
+ with open(filepath, "w", encoding="utf-8") as f:
168
+ for rec in records:
169
+ line = {
170
+ "instruction": self.INSTRUCTION_TEXT,
171
+ "input": rec["question"],
172
+ "output": rec["answer"],
173
+ "source": rec["source"],
174
+ "category": rec.get("category", ""),
175
+ }
176
+ f.write(json.dumps(line, ensure_ascii=False) + "\n")
177
+ logger.info("์ €์žฅ ์™„๋ฃŒ: %s (%d ๋ ˆ์ฝ”๋“œ)", filepath, len(records))
src/inference/.gitkeep ADDED
File without changes
src/inference/__init__.py ADDED
File without changes
src/inference/actions/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .base import ActionResult, BaseAction, Citation
2
+ from .data_go_kr import MinwonAnalysisAction
3
+
4
+ __all__ = ["ActionResult", "BaseAction", "Citation", "MinwonAnalysisAction"]
src/inference/actions/base.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Action ์ถ”์ƒ ๋ฒ ์ด์Šค ํด๋ž˜์Šค ๋ชจ๋“ˆ.
2
+
3
+ AgentLoop์—์„œ ์™ธ๋ถ€ API๋‚˜ ์„œ๋น„์Šค๋ฅผ ํ˜ธ์ถœํ•˜๋Š” Action์˜
4
+ ๊ณตํ†ต ์ธํ„ฐํŽ˜์ด์Šค์™€ ๊ฒฐ๊ณผ ํƒ€์ž…์„ ์ •์˜ํ•œ๋‹ค.
5
+
6
+ Issue: #394
7
+ """
8
+
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from loguru import logger
14
+
15
+ from ..session_context import SessionContext
16
+
17
+
18
+ @dataclass
19
+ class Citation:
20
+ """์ถœ์ฒ˜ ์ •๋ณด.
21
+
22
+ API ์‘๋‹ต์ด๋‚˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์˜ ์ถœ์ฒ˜๋ฅผ ํ‘œํ˜„ํ•œ๋‹ค.
23
+
24
+ Parameters
25
+ ----------
26
+ title : str
27
+ ์ถœ์ฒ˜ ์ œ๋ชฉ.
28
+ url : str
29
+ ์ถœ์ฒ˜ URL. ์—†์œผ๋ฉด ๋นˆ ๋ฌธ์ž์—ด.
30
+ date : str
31
+ ์ž‘์„ฑ/๋“ฑ๋ก ๋‚ ์งœ. ์—†์œผ๋ฉด ๋นˆ ๋ฌธ์ž์—ด.
32
+ snippet : str
33
+ ๋ณธ๋ฌธ ์š”์•ฝ(๋ฐœ์ทŒ). ์—†์œผ๋ฉด ๋นˆ ๋ฌธ์ž์—ด.
34
+ metadata : Dict[str, Any]
35
+ ์ถ”๊ฐ€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ.
36
+ """
37
+
38
+ title: str
39
+ url: str = ""
40
+ date: str = ""
41
+ snippet: str = ""
42
+ metadata: Dict[str, Any] = field(default_factory=dict)
43
+
44
+ def to_dict(self) -> Dict[str, Any]:
45
+ return {
46
+ "title": self.title,
47
+ "url": self.url,
48
+ "date": self.date,
49
+ "snippet": self.snippet,
50
+ "metadata": self.metadata,
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class ActionResult:
56
+ """Action ์‹คํ–‰ ๊ฒฐ๊ณผ.
57
+
58
+ BaseAction.execute()์˜ ๋ฐ˜ํ™˜๊ฐ’์œผ๋กœ,
59
+ AgentLoop์˜ ToolFunction ๋ฐ˜ํ™˜ ํ˜•์‹(dict)๊ณผ ํ˜ธํ™˜๋œ๋‹ค.
60
+
61
+ Parameters
62
+ ----------
63
+ success : bool
64
+ ์‹คํ–‰ ์„ฑ๊ณต ์—ฌ๋ถ€.
65
+ data : Dict[str, Any]
66
+ ์„ฑ๊ณต ์‹œ ํŽ˜์ด๋กœ๋“œ.
67
+ error : Optional[str]
68
+ ์‹คํŒจ ์‹œ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€.
69
+ source : str
70
+ ๊ฒฐ๊ณผ ์ถœ์ฒ˜ ์‹๋ณ„์ž (์˜ˆ: "data.go.kr").
71
+ citations : List[Citation]
72
+ ์ฐธ์กฐ๋œ ์ถœ์ฒ˜ ๋ชฉ๋ก.
73
+ context_text : str
74
+ LLM ํ”„๋กฌํ”„ํŠธ์— ์‚ฝ์ž…ํ•  ์ปจํ…์ŠคํŠธ ํ…์ŠคํŠธ.
75
+ """
76
+
77
+ success: bool
78
+ data: Dict[str, Any] = field(default_factory=dict)
79
+ error: Optional[str] = None
80
+ source: str = ""
81
+ citations: List[Citation] = field(default_factory=list)
82
+ context_text: str = ""
83
+
84
+ def to_dict(self) -> Dict[str, Any]:
85
+ """AgentLoop ToolFunction ๋ฐ˜ํ™˜ ํ˜•์‹(dict)์œผ๋กœ ๋ณ€ํ™˜."""
86
+ return {
87
+ "success": self.success,
88
+ "data": self.data,
89
+ "error": self.error,
90
+ "source": self.source,
91
+ "citations": [c.to_dict() for c in self.citations],
92
+ "context_text": self.context_text,
93
+ }
94
+
95
+
96
+ class BaseAction(ABC):
97
+ """Action ์ถ”์ƒ ๋ฒ ์ด์Šค ํด๋ž˜์Šค.
98
+
99
+ AgentLoop์˜ ToolFunction ์‹œ๊ทธ๋‹ˆ์ฒ˜(query, context, session) -> dict์™€
100
+ ํ˜ธํ™˜๋˜๋Š” ๋น„๋™๊ธฐ callable์„ ์ œ๊ณตํ•œ๋‹ค.
101
+
102
+ Parameters
103
+ ----------
104
+ action_name : str
105
+ Action ์‹๋ณ„์ž. ๋กœ๊น…์— ์‚ฌ์šฉ๋œ๋‹ค.
106
+ """
107
+
108
+ def __init__(self, action_name: str) -> None:
109
+ self._action_name = action_name
110
+
111
+ async def __call__(
112
+ self,
113
+ query: str,
114
+ context: Dict[str, Any],
115
+ session: SessionContext,
116
+ ) -> Dict[str, Any]:
117
+ """AgentLoop ToolFunction ์‹œ๊ทธ๋‹ˆ์ฒ˜ ํ˜ธํ™˜ ์ง„์ž…์ .
118
+
119
+ 1. validate()๋กœ ์‚ฌ์ „ ๊ฒ€์ฆ.
120
+ 2. ๊ฒ€์ฆ ํ†ต๊ณผ ์‹œ execute() ์‹คํ–‰.
121
+ 3. ActionResult๋ฅผ dict๋กœ ๋ณ€ํ™˜ํ•ด ๋ฐ˜ํ™˜.
122
+ 4. ์„ฑ๊ณต/์‹คํŒจ๋ฅผ ๋กœ๊น…ํ•œ๋‹ค.
123
+
124
+ Parameters
125
+ ----------
126
+ query : str
127
+ ์‚ฌ์šฉ์ž ์š”์ฒญ ํ…์ŠคํŠธ.
128
+ context : Dict[str, Any]
129
+ AgentLoop ๋ˆ„์  ์ปจํ…์ŠคํŠธ(์ด์ „ tool ๊ฒฐ๊ณผ ํฌํ•จ).
130
+ session : SessionContext
131
+ ํ˜„์žฌ ์„ธ์…˜ ์ปจํ…์ŠคํŠธ.
132
+
133
+ Returns
134
+ -------
135
+ Dict[str, Any]
136
+ ActionResult.to_dict() ๊ฒฐ๊ณผ.
137
+ """
138
+ # 1. ์‚ฌ์ „ ๊ฒ€์ฆ
139
+ validation_error = self.validate(query, context, session)
140
+ if validation_error:
141
+ logger.warning(f"[{self._action_name}] ๊ฒ€์ฆ ์‹คํŒจ: {validation_error}")
142
+ result = ActionResult(
143
+ success=False,
144
+ error=validation_error,
145
+ source=self._action_name,
146
+ )
147
+ return result.to_dict()
148
+
149
+ # 2. ์‹คํ–‰
150
+ try:
151
+ result = await self.execute(query, context, session)
152
+ except Exception as exc:
153
+ logger.error(
154
+ f"[{self._action_name}] execute() ์˜ˆ์™ธ ๋ฐœ์ƒ: {exc}",
155
+ exc_info=True,
156
+ )
157
+ result = ActionResult(
158
+ success=False,
159
+ error=f"Action ์‹คํ–‰ ์ค‘ ์˜ค๋ฅ˜: {exc}",
160
+ source=self._action_name,
161
+ )
162
+
163
+ # 3. ๋กœ๊น…
164
+ if result.success:
165
+ logger.info(
166
+ f"[{self._action_name}] ์„ฑ๊ณต "
167
+ f"citations={len(result.citations)} "
168
+ f"context_text_len={len(result.context_text)}"
169
+ )
170
+ else:
171
+ logger.warning(f"[{self._action_name}] ์‹คํŒจ: {result.error}")
172
+
173
+ return result.to_dict()
174
+
175
+ def validate(
176
+ self,
177
+ query: str,
178
+ context: Dict[str, Any],
179
+ session: SessionContext,
180
+ ) -> Optional[str]:
181
+ """์‹คํ–‰ ์ „ ์‚ฌ์ „ ๊ฒ€์ฆ. ์˜ค๋ฅ˜๊ฐ€ ์žˆ์œผ๋ฉด ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
182
+
183
+ ๊ธฐ๋ณธ ๊ตฌํ˜„: ๋นˆ ์ฟผ๋ฆฌ ๊ฒ€์‚ฌ. ์„œ๋ธŒํด๋ž˜์Šค์—์„œ super() ํ˜ธ์ถœ ํ›„ ์ถ”๊ฐ€ ๊ฒ€์ฆ ๊ฐ€๋Šฅ.
184
+
185
+ Parameters
186
+ ----------
187
+ query : str
188
+ ์‚ฌ์šฉ์ž ์š”์ฒญ ํ…์ŠคํŠธ.
189
+ context : Dict[str, Any]
190
+ AgentLoop ๋ˆ„์  ์ปจํ…์ŠคํŠธ.
191
+ session : SessionContext
192
+ ํ˜„์žฌ ์„ธ์…˜ ์ปจํ…์ŠคํŠธ.
193
+
194
+ Returns
195
+ -------
196
+ Optional[str]
197
+ ๊ฒ€์ฆ ์‹คํŒจ ๋ฉ”์‹œ์ง€. None์ด๋ฉด ๊ฒ€์ฆ ํ†ต๊ณผ.
198
+ """
199
+ if not query or not query.strip():
200
+ return "์ฟผ๋ฆฌ๊ฐ€ ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค."
201
+ return None
202
+
203
+ @abstractmethod
204
+ async def execute(
205
+ self,
206
+ query: str,
207
+ context: Dict[str, Any],
208
+ session: SessionContext,
209
+ ) -> ActionResult:
210
+ """Action ์‹ค์ œ ์‹คํ–‰ ๋กœ์ง. ์„œ๋ธŒํด๋ž˜์Šค์—์„œ ๊ตฌํ˜„ํ•œ๋‹ค.
211
+
212
+ Parameters
213
+ ----------
214
+ query : str
215
+ ์‚ฌ์šฉ์ž ์š”์ฒญ ํ…์ŠคํŠธ.
216
+ context : Dict[str, Any]
217
+ AgentLoop ๋ˆ„์  ์ปจํ…์ŠคํŠธ.
218
+ session : SessionContext
219
+ ํ˜„์žฌ ์„ธ์…˜ ์ปจํ…์ŠคํŠธ.
220
+
221
+ Returns
222
+ -------
223
+ ActionResult
224
+ ์‹คํ–‰ ๊ฒฐ๊ณผ.
225
+ """
226
+ ...
src/inference/actions/data_go_kr.py ADDED
@@ -0,0 +1,952 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """data.go.kr ๋ฏผ์› ๋ถ„์„ API Action ๋ชจ๋“ˆ.
2
+
3
+ ๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ(data.go.kr)์˜ ๋ฏผ์›๋ถ„์„์ •๋ณด์กฐํšŒ API๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ
4
+ ์œ ์‚ฌ ๋ฏผ์› ์‚ฌ๋ก€๋ฅผ ๊ฒ€์ƒ‰ํ•˜๊ณ  LLM ์ปจํ…์ŠคํŠธ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค.
5
+
6
+ API ๋ฌธ์„œ: https://www.data.go.kr/data/15025759/openapi.do
7
+ Issue: #394
8
+ """
9
+
10
+ import os
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from loguru import logger
14
+
15
+ from ..session_context import SessionContext
16
+ from .base import ActionResult, BaseAction, Citation
17
+
18
+ try:
19
+ import httpx
20
+
21
+ _HTTPX_AVAILABLE = True
22
+ _HttpxTimeoutError = httpx.TimeoutException
23
+ _HttpxStatusError = httpx.HTTPStatusError
24
+ except ImportError:
25
+ httpx = None # type: ignore
26
+ _HTTPX_AVAILABLE = False
27
+ _HttpxTimeoutError = type(None) # ์ ˆ๋Œ€ ๋งค์น˜๋˜์ง€ ์•Š๋Š” ํƒ€์ž…
28
+ _HttpxStatusError = type(None)
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # ์ƒ์ˆ˜
33
+ # ---------------------------------------------------------------------------
34
+
35
+ _BASE_URL = "http://apis.data.go.kr/1140100/minAnalsInfoView5"
36
+ _ENDPOINT_SIMILAR = "/minSimilarInfo5"
37
+
38
+
39
+ class MinwonAnalysisAction(BaseAction):
40
+ """๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ ๋ฏผ์›๋ถ„์„์ •๋ณด์กฐํšŒ API Action.
41
+
42
+ data.go.kr์˜ ๋ฏผ์›๋ถ„์„์ •๋ณด์กฐํšŒ API๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ
43
+ ์œ ์‚ฌ ๋ฏผ์› ์‚ฌ๋ก€๋ฅผ ๊ฐ€์ ธ์˜ค๊ณ  AgentLoop ์ปจํ…์ŠคํŠธ์— ์ œ๊ณตํ•œ๋‹ค.
44
+
45
+ Parameters
46
+ ----------
47
+ api_key : Optional[str]
48
+ ๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ API ์ธ์ฆํ‚ค. None์ด๋ฉด DATA_GO_KR_API_KEY ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ๋กœ๋“œ.
49
+ ret_count : int
50
+ ๋ฐ˜ํ™˜ํ•  ์œ ์‚ฌ ์‚ฌ๋ก€ ์ˆ˜. ๊ธฐ๋ณธ๊ฐ’ 5.
51
+ min_score : int
52
+ ์ตœ์†Œ ์œ ์‚ฌ๋„ ์ ์ˆ˜. ๊ธฐ๋ณธ๊ฐ’ 2.
53
+ timeout : float
54
+ HTTP ์š”์ฒญ ํƒ€์ž„์•„์›ƒ(์ดˆ). ๊ธฐ๋ณธ๊ฐ’ 10.0.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ api_key: Optional[str] = None,
60
+ ret_count: int = 5,
61
+ min_score: int = 2,
62
+ timeout: float = 10.0,
63
+ ) -> None:
64
+ super().__init__(action_name="minwon_analysis")
65
+ self._api_key = api_key or os.getenv("DATA_GO_KR_API_KEY", "")
66
+ self._ret_count = ret_count
67
+ self._min_score = min_score
68
+ self._timeout = timeout
69
+
70
+ def validate(
71
+ self,
72
+ query: str,
73
+ context: Dict[str, Any],
74
+ session: SessionContext,
75
+ ) -> Optional[str]:
76
+ """API ํ‚ค์™€ ์ฟผ๋ฆฌ ๊ธธ์ด๋ฅผ ๊ฒ€์ฆํ•œ๋‹ค.
77
+
78
+ Parameters
79
+ ----------
80
+ query : str
81
+ ์‚ฌ์šฉ์ž ์š”์ฒญ ํ…์ŠคํŠธ.
82
+ context : Dict[str, Any]
83
+ AgentLoop ๋ˆ„์  ์ปจํ…์ŠคํŠธ.
84
+ session : SessionContext
85
+ ํ˜„์žฌ ์„ธ์…˜ ์ปจํ…์ŠคํŠธ.
86
+
87
+ Returns
88
+ -------
89
+ Optional[str]
90
+ ๊ฒ€์ฆ ์‹คํŒจ ๋ฉ”์‹œ์ง€. None์ด๋ฉด ํ†ต๊ณผ.
91
+ """
92
+ base_error = super().validate(query, context, session)
93
+ if base_error:
94
+ return base_error
95
+
96
+ if not self._api_key:
97
+ return "DATA_GO_KR_API_KEY ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."
98
+
99
+ if len(query.strip()) < 2:
100
+ return "์ฟผ๋ฆฌ๊ฐ€ ๋„ˆ๋ฌด ์งง์Šต๋‹ˆ๋‹ค (์ตœ์†Œ 2์ž ์ด์ƒ)."
101
+
102
+ if not _HTTPX_AVAILABLE:
103
+ return "httpx ํŒจํ‚ค์ง€๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. pip install httpx>=0.27.0"
104
+
105
+ return None
106
+
107
+ async def execute(
108
+ self,
109
+ query: str,
110
+ context: Dict[str, Any],
111
+ session: SessionContext,
112
+ ) -> ActionResult:
113
+ """์œ ์‚ฌ ๋ฏผ์› ์‚ฌ๋ก€๋ฅผ ์กฐํšŒํ•˜๊ณ  ActionResult๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
114
+
115
+ 1. _enrich_query๋กœ ๋ถ„๋ฅ˜ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ๋ฐ˜์˜ํ•œ ๊ฒ€์ƒ‰์–ด ์ƒ์„ฑ.
116
+ 2. _call_similar_api๋กœ API ํ˜ธ์ถœ.
117
+ 3. ๊ฒฐ๊ณผ๋ฅผ ํŒŒ์‹ฑํ•˜์—ฌ ActionResult ์ƒ์„ฑ.
118
+
119
+ Parameters
120
+ ----------
121
+ query : str
122
+ ์‚ฌ์šฉ์ž ์š”์ฒญ ํ…์ŠคํŠธ.
123
+ context : Dict[str, Any]
124
+ AgentLoop ๋ˆ„์  ์ปจํ…์ŠคํŠธ.
125
+ session : SessionContext
126
+ ํ˜„์žฌ ์„ธ์…˜ ์ปจํ…์ŠคํŠธ.
127
+
128
+ Returns
129
+ -------
130
+ ActionResult
131
+ ์œ ์‚ฌ ๋ฏผ์› ์‚ฌ๋ก€์™€ LLM ์ปจํ…์ŠคํŠธ๊ฐ€ ํฌํ•จ๋œ ๊ฒฐ๊ณผ.
132
+ """
133
+ payload = await self.fetch_similar_cases(query, context)
134
+ items = payload["results"]
135
+
136
+ if items is None:
137
+ return ActionResult(
138
+ success=False,
139
+ error="๋ฏผ์› ๋ถ„์„ API ํ˜ธ์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.",
140
+ source="data.go.kr",
141
+ )
142
+
143
+ if not items:
144
+ return ActionResult(
145
+ success=True,
146
+ data={"results": [], "query": payload["query"], "count": 0},
147
+ source="data.go.kr",
148
+ context_text="",
149
+ )
150
+
151
+ return ActionResult(
152
+ success=True,
153
+ data={
154
+ "results": items,
155
+ "query": payload["query"],
156
+ "count": len(items),
157
+ },
158
+ source="data.go.kr",
159
+ citations=payload["citations"],
160
+ context_text=payload["context_text"],
161
+ )
162
+
163
+ async def fetch_similar_cases(
164
+ self,
165
+ query: str,
166
+ context: Dict[str, Any],
167
+ ret_count: Optional[int] = None,
168
+ min_score: Optional[int] = None,
169
+ ) -> Dict[str, Any]:
170
+ """์œ ์‚ฌ ๋ฏผ์› ์‚ฌ๋ก€ ๊ฒ€์ƒ‰์— ํ•„์š”ํ•œ payload๋ฅผ ๊ตฌ์„ฑํ•œ๋‹ค.
171
+
172
+ api_lookup capability ๋‚ด๋ถ€์—์„œ minSimilarInfo5 ํ˜ธ์ถœ ๊ฒฝ๋กœ๋ฅผ
173
+ ๊ณต์šฉ์œผ๋กœ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ณต๊ฐœ helper๋กœ ์ œ๊ณตํ•œ๋‹ค.
174
+
175
+ Parameters
176
+ ----------
177
+ ret_count : Optional[int]
178
+ ๋ฐ˜ํ™˜ ๊ฑด์ˆ˜ ์˜ค๋ฒ„๋ผ์ด๋“œ.
179
+ min_score : Optional[int]
180
+ ์ตœ์†Œ ์œ ์‚ฌ๋„ ์˜ค๋ฒ„๋ผ์ด๋“œ.
181
+ """
182
+ search_query = self._enrich_query(query, context)
183
+ logger.debug(f"[minwon_analysis] ๋ณด๊ฐ•๋œ ๊ฒ€์ƒ‰์–ด: {search_query!r}")
184
+ items = await self._call_similar_api(search_query, ret_count=ret_count, min_score=min_score)
185
+
186
+ return {
187
+ "query": search_query,
188
+ "results": items,
189
+ "count": len(items or []),
190
+ "context_text": self._build_context_text(items or [], query) if items else "",
191
+ "citations": self._build_citations(items or []),
192
+ }
193
+
194
+ async def _call_similar_api(
195
+ self,
196
+ search_query: str,
197
+ ret_count: Optional[int] = None,
198
+ min_score: Optional[int] = None,
199
+ ) -> Optional[List[Dict[str, Any]]]:
200
+ """๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ ์œ ์‚ฌ๋ฏผ์›์ •๋ณด API๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค.
201
+
202
+ Parameters
203
+ ----------
204
+ search_query : str
205
+ API์— ์ „๋‹ฌํ•  ๊ฒ€์ƒ‰์–ด.
206
+ ret_count : Optional[int]
207
+ ๋ฐ˜ํ™˜ ๊ฑด์ˆ˜ ์˜ค๋ฒ„๋ผ์ด๋“œ. None์ด๋ฉด ์ธ์Šคํ„ด์Šค ๊ธฐ๋ณธ๊ฐ’.
208
+ min_score : Optional[int]
209
+ ์ตœ์†Œ ์œ ์‚ฌ๋„ ์˜ค๋ฒ„๋ผ์ด๋“œ. None์ด๋ฉด ์ธ์Šคํ„ด์Šค ๊ธฐ๋ณธ๊ฐ’.
210
+
211
+ Returns
212
+ -------
213
+ Optional[List[Dict[str, Any]]]
214
+ ์„ฑ๊ณต ์‹œ ์•„์ดํ…œ ๋ชฉ๋ก, ์‹คํŒจ ์‹œ None.
215
+ """
216
+ url = _BASE_URL + _ENDPOINT_SIMILAR
217
+ params = {
218
+ "serviceKey": self._api_key,
219
+ "startPos": 1,
220
+ "retCount": ret_count if ret_count is not None else self._ret_count,
221
+ "target": "qna,qna_origin",
222
+ "minScore": min_score if min_score is not None else self._min_score,
223
+ "dataType": "json",
224
+ "searchword": search_query,
225
+ }
226
+
227
+ try:
228
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
229
+ response = await client.get(url, params=params)
230
+ response.raise_for_status()
231
+ body = response.json()
232
+ except _HttpxTimeoutError as exc:
233
+ logger.warning(f"[minwon_analysis] API ํƒ€์ž„์•„์›ƒ: {exc}")
234
+ return None
235
+ except _HttpxStatusError as exc:
236
+ logger.warning(f"[minwon_analysis] HTTP ์˜ค๋ฅ˜ {exc.response.status_code}: {exc}")
237
+ return None
238
+ except Exception as exc:
239
+ logger.error(f"[minwon_analysis] API ํ˜ธ์ถœ ์˜ค๋ฅ˜: {exc}", exc_info=True)
240
+ return None
241
+
242
+ # ์‹ค์ œ API๋Š” ์ตœ์ƒ์œ„ ๋ฐฐ์—ด([]) ๋˜๋Š” returnObject ๋ž˜ํ•‘์œผ๋กœ ์‘๋‹ต
243
+ if isinstance(body, list):
244
+ return body
245
+
246
+ if not isinstance(body, dict):
247
+ logger.warning(f"[minwon_analysis] ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์‘๋‹ต ํƒ€์ž…: {type(body)}")
248
+ return None
249
+
250
+ # returnObject ๋ž˜ํ•‘
251
+ if "returnObject" in body:
252
+ obj = body["returnObject"]
253
+ return obj if isinstance(obj, list) else []
254
+
255
+ # ์—๋Ÿฌ ์‘๋‹ต ๊ฒ€์‚ฌ โ€” ์„ฑ๊ณต ์ฝ”๋“œ๋งŒ ํ†ต๊ณผ
256
+ _SUCCESS_CODES = {"00", "0", "200", ""}
257
+ code = str(body.get("code", body.get("resultCode", "00")))
258
+ if code not in _SUCCESS_CODES:
259
+ logger.warning(
260
+ f"[minwon_analysis] API ์—๋Ÿฌ (code={code}): {body.get('msg', body.get('resultMsg', ''))}"
261
+ )
262
+ return None
263
+
264
+ return self._parse_similar_items(body)
265
+
266
+ def _parse_similar_items(self, raw_body: Dict[str, Any]) -> List[Dict[str, Any]]:
267
+ """API ์‘๋‹ต์—์„œ ์•„์ดํ…œ ๋ชฉ๋ก์„ ์ถ”์ถœํ•œ๋‹ค.
268
+
269
+ ๋ฐฐ์—ด ํ˜•์‹๊ณผ ๋‹จ์ผ dict ๋ž˜ํ•‘ ํ˜•์‹์„ ๋ชจ๋‘ ์ฒ˜๋ฆฌํ•œ๋‹ค.
270
+
271
+ Parameters
272
+ ----------
273
+ raw_body : Dict[str, Any]
274
+ API ์ „์ฒด ์‘๋‹ต JSON.
275
+
276
+ Returns
277
+ -------
278
+ List[Dict[str, Any]]
279
+ ํŒŒ์‹ฑ๋œ ์•„์ดํ…œ ๋ชฉ๋ก.
280
+ """
281
+ # ์ตœ์ƒ์œ„ ํ‚ค ํƒ์ƒ‰: body โ†’ items โ†’ item ๋˜๋Š” ์ง์ ‘ items
282
+ body = raw_body.get("body") or raw_body.get("response", {}).get("body") or raw_body
283
+ items_raw = body.get("items") if isinstance(body, dict) else None
284
+
285
+ if items_raw is None:
286
+ logger.debug("[minwon_analysis] ์‘๋‹ต์— 'items' ํ‚ค ์—†์Œ โ€” ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜")
287
+ return []
288
+
289
+ # ๋ฐฐ์—ด vs dict ๋ž˜ํ•‘ ์ฒ˜๋ฆฌ
290
+ if isinstance(items_raw, list):
291
+ return items_raw
292
+ if isinstance(items_raw, dict):
293
+ item = items_raw.get("item")
294
+ if item is None:
295
+ return []
296
+ if isinstance(item, list):
297
+ return item
298
+ if isinstance(item, dict):
299
+ return [item]
300
+
301
+ logger.warning(f"[minwon_analysis] ์˜ˆ์ƒ์น˜ ๋ชปํ•œ items ํ˜•์‹: {type(items_raw)}")
302
+ return []
303
+
304
+ def _build_context_text(self, items: List[Dict[str, Any]], query: str) -> str:
305
+ """์•„์ดํ…œ ๋ชฉ๋ก์„ LLM ํ”„๋กฌํ”„ํŠธ์šฉ ์ปจํ…์ŠคํŠธ ํ…์ŠคํŠธ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค.
306
+
307
+ Parameters
308
+ ----------
309
+ items : List[Dict[str, Any]]
310
+ API์—์„œ ๋ฐ˜ํ™˜๋œ ์•„์ดํ…œ ๋ชฉ๋ก.
311
+ query : str
312
+ ์›๋ณธ ์‚ฌ์šฉ์ž ์ฟผ๋ฆฌ.
313
+
314
+ Returns
315
+ -------
316
+ str
317
+ LLM ํ”„๋กฌํ”„ํŠธ์— ์‚ฝ์ž…ํ•  ํ…์ŠคํŠธ.
318
+ """
319
+ if not items:
320
+ return ""
321
+
322
+ lines = [f"### ๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ ์œ ์‚ฌ ๋ฏผ์› ์‚ฌ๋ก€ (๊ฒ€์ƒ‰์–ด: {query})\n"]
323
+ for i, item in enumerate(items[:5], 1):
324
+ title = item.get("title") or item.get("qnaTitle") or ""
325
+ content = item.get("content") or item.get("qnaContent") or item.get("question") or ""
326
+ answer = item.get("answer") or item.get("qnaAnswer") or ""
327
+ category = (
328
+ item.get("category") or item.get("minCategory") or item.get("main_sub_name") or ""
329
+ )
330
+ date = item.get("regDate") or item.get("date") or item.get("create_date") or ""
331
+
332
+ lines.append(f"{i}. [{category}] {title}")
333
+ if date:
334
+ lines.append(f" (๋“ฑ๋ก์ผ: {date})")
335
+ if content:
336
+ preview = content[:200] + "..." if len(content) > 200 else content
337
+ lines.append(f" ๋ฏผ์›: {preview}")
338
+ if answer:
339
+ ans_preview = answer[:200] + "..." if len(answer) > 200 else answer
340
+ lines.append(f" ๋‹ต๋ณ€: {ans_preview}")
341
+ lines.append("")
342
+
343
+ return "\n".join(lines)
344
+
345
+ def _build_citations(self, items: List[Dict[str, Any]]) -> List[Citation]:
346
+ """์•„์ดํ…œ ๋ชฉ๋ก์—์„œ Citation ๊ฐ์ฒด ๋ชฉ๋ก์„ ์ƒ์„ฑํ•œ๋‹ค.
347
+
348
+ Parameters
349
+ ----------
350
+ items : List[Dict[str, Any]]
351
+ API์—์„œ ๋ฐ˜ํ™˜๋œ ์•„์ดํ…œ ๋ชฉ๋ก.
352
+
353
+ Returns
354
+ -------
355
+ List[Citation]
356
+ Citation ๊ฐ์ฒด ๋ชฉ๋ก.
357
+ """
358
+ citations = []
359
+ for item in items:
360
+ title = item.get("title") or item.get("qnaTitle") or ""
361
+ url = item.get("url") or item.get("detailUrl") or ""
362
+ date = item.get("regDate") or item.get("date") or item.get("create_date") or ""
363
+ content = item.get("content") or item.get("qnaContent") or item.get("question") or ""
364
+ snippet = content[:150] + "..." if len(content) > 150 else content
365
+
366
+ # ์ œ๋ชฉ ์—†๋Š” ํ•ญ๋ชฉ์€ ์Šคํ‚ต
367
+ if not title:
368
+ continue
369
+
370
+ citations.append(
371
+ Citation(
372
+ title=title,
373
+ url=url,
374
+ date=date,
375
+ snippet=snippet,
376
+ metadata={k: v for k, v in item.items() if k not in ("content", "answer")},
377
+ )
378
+ )
379
+ return citations
380
+
381
+ def _enrich_query(self, query: str, context: Dict[str, Any]) -> str:
382
+ """์„ธ์…˜ ์š”์•ฝ์ด๋‚˜ ์ตœ๊ทผ assistant ์‘๋‹ต์„ ๋ฐ˜์˜ํ•ด ๊ฒ€์ƒ‰์–ด๋ฅผ ๋ณด๊ฐ•ํ•œ๋‹ค.
383
+
384
+ Parameters
385
+ ----------
386
+ query : str
387
+ ์›๋ณธ ์‚ฌ์šฉ์ž ์ฟผ๋ฆฌ.
388
+ context : Dict[str, Any]
389
+ AgentLoop ๋ˆ„์  ์ปจํ…์ŠคํŠธ.
390
+
391
+ Returns
392
+ -------
393
+ str
394
+ ๋ณด๊ฐ•๋œ ๊ฒ€์ƒ‰์–ด.
395
+ """
396
+ query_variants = context.get("query_variants", {})
397
+ if isinstance(query_variants, dict):
398
+ prepared_query = str(query_variants.get("api_lookup", "")).strip()
399
+ if prepared_query:
400
+ return prepared_query
401
+
402
+ session_context = str(context.get("session_context", "")).strip()
403
+ if session_context:
404
+ recent_summary = " ".join(session_context.splitlines()[-2:]).strip()
405
+ if recent_summary and recent_summary not in query:
406
+ return f"{query} {recent_summary[:120]}".strip()
407
+ return query
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # ๊ณตํ†ต API ํ˜ธ์ถœ ํ—ฌํผ
411
+ # ---------------------------------------------------------------------------
412
+
413
+ async def _call_api(
414
+ self, endpoint: str, params: Dict[str, Any]
415
+ ) -> Optional[List[Dict[str, Any]]]:
416
+ """๊ณตํ†ต API ํ˜ธ์ถœ + ์‘๋‹ต ํŒŒ์‹ฑ.
417
+
418
+ Parameters
419
+ ----------
420
+ endpoint : str
421
+ _BASE_URL ๋’ค์— ๋ถ™๋Š” ์—”๋“œํฌ์ธํŠธ ๊ฒฝ๋กœ.
422
+ params : Dict[str, Any]
423
+ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ (serviceKey, dataType ์ž๋™ ์ถ”๊ฐ€).
424
+
425
+ Returns
426
+ -------
427
+ Optional[List[Dict[str, Any]]]
428
+ ์„ฑ๊ณต ์‹œ ์•„์ดํ…œ ๋ชฉ๋ก, ์‹คํŒจ ์‹œ None.
429
+ """
430
+ if not _HTTPX_AVAILABLE:
431
+ logger.warning("[minwon_analysis] httpx ๋ฏธ์„ค์น˜")
432
+ return None
433
+
434
+ url = _BASE_URL + endpoint
435
+ params["serviceKey"] = self._api_key
436
+ params["dataType"] = "json"
437
+
438
+ try:
439
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
440
+ response = await client.get(url, params=params)
441
+ response.raise_for_status()
442
+ body = response.json()
443
+ except httpx.TimeoutException as exc:
444
+ logger.warning(f"[minwon_analysis] API ํƒ€์ž„์•„์›ƒ ({endpoint}): {exc}")
445
+ return None
446
+ except httpx.HTTPStatusError as exc:
447
+ logger.warning(
448
+ f"[minwon_analysis] HTTP ์˜ค๋ฅ˜ ({endpoint}) " f"{exc.response.status_code}: {exc}"
449
+ )
450
+ return None
451
+ except Exception as exc:
452
+ logger.error(
453
+ f"[minwon_analysis] API ํ˜ธ์ถœ ์˜ค๋ฅ˜ ({endpoint}): {exc}",
454
+ exc_info=True,
455
+ )
456
+ return None
457
+
458
+ # ์ตœ์ƒ์œ„ ๋ฐฐ์—ด
459
+ if isinstance(body, list):
460
+ return body
461
+
462
+ # dict ๋ž˜ํ•‘
463
+ if isinstance(body, dict):
464
+ if "returnObject" in body:
465
+ obj = body["returnObject"]
466
+ return obj if isinstance(obj, list) else []
467
+ # ์—๋Ÿฌ ์ฝ”๋“œ ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ (๊ธฐ์กด _call_similar_api์™€ ๋™์ผ)
468
+ code = str(body.get("code", body.get("resultCode", "00")))
469
+ if code not in ("00", "0", "200", ""):
470
+ logger.warning(
471
+ f"[minwon_analysis] API ์—๋Ÿฌ ({endpoint}): code={code}, "
472
+ f"msg={body.get('msg', body.get('resultMsg', ''))}"
473
+ )
474
+ return None
475
+ # body > items ๊ฒฝ๋กœ ํŒŒ์‹ฑ ์‹œ๋„
476
+ return self._parse_similar_items(body)
477
+
478
+ return None
479
+
480
+ # ---------------------------------------------------------------------------
481
+ # ์ด์Šˆ ํƒ์ง€ API (issue_detector)
482
+ # ---------------------------------------------------------------------------
483
+
484
+ async def get_rising_keywords(
485
+ self,
486
+ analysis_time: str,
487
+ max_result: int = 10,
488
+ target: str = "pttn,dfpt,saeol",
489
+ main_sub_code: str = "1140100",
490
+ ) -> Optional[List[Dict[str, Any]]]:
491
+ """๊ธ‰์ฆํ‚ค์›Œ๋“œ๋ฅผ ์กฐํšŒํ•œ๋‹ค.
492
+
493
+ Parameters
494
+ ----------
495
+ analysis_time : str
496
+ ๋ถ„์„ ์‹œ์  (์˜ˆ: "2021050614").
497
+ max_result : int
498
+ ์ตœ๋Œ€ ๊ฒฐ๊ณผ ์ˆ˜.
499
+ target : str
500
+ ๋Œ€์ƒ ์ฑ„๋„.
501
+ main_sub_code : str
502
+ ๊ธฐ๊ด€ ์ฝ”๋“œ.
503
+ """
504
+ return await self._call_api(
505
+ "/minRisingKeyword5",
506
+ {
507
+ "analysisTime": analysis_time,
508
+ "maxResult": max_result,
509
+ "target": target,
510
+ "mainSubCode": main_sub_code,
511
+ },
512
+ )
513
+
514
+ async def get_today_topics(
515
+ self,
516
+ search_date: str,
517
+ top_n: int = 5,
518
+ target: str = "pttn,dfpt,saeol",
519
+ ) -> Optional[List[Dict[str, Any]]]:
520
+ """์˜ค๋Š˜ ์ด์Šˆ ํ† ํ”ฝ์„ ์กฐํšŒํ•œ๋‹ค.
521
+
522
+ Parameters
523
+ ----------
524
+ search_date : str
525
+ ๊ฒ€์ƒ‰ ๋‚ ์งœ (์˜ˆ: "20210506").
526
+ top_n : int
527
+ ์ƒ์œ„ N๊ฐœ.
528
+ target : str
529
+ ๋Œ€์ƒ ์ฑ„๋„.
530
+ """
531
+ return await self._call_api(
532
+ "/minTodayTopicInfo5",
533
+ {
534
+ "searchDate": search_date,
535
+ "todayTopicTopN": top_n,
536
+ "target": target,
537
+ },
538
+ )
539
+
540
+ async def get_top_keywords_by_period(
541
+ self,
542
+ analysis_time: str,
543
+ period: str = "MONTHLY",
544
+ range_count: int = 1,
545
+ max_result: int = 5,
546
+ target: str = "pttn,dfpt,saeol",
547
+ main_sub_code: str = "1140100",
548
+ ) -> Optional[List[Dict[str, Any]]]:
549
+ """๊ธฐ๊ฐ„๋ณ„ ์ตœ๋‹ค ํ‚ค์›Œ๋“œ๋ฅผ ์กฐํšŒํ•œ๋‹ค.
550
+
551
+ Parameters
552
+ ----------
553
+ analysis_time : str
554
+ ๋ถ„์„ ์‹œ์ž‘ ์‹œ์  (์˜ˆ: "20210301").
555
+ period : str
556
+ ๊ธฐ๊ฐ„ ๋‹จ์œ„ ("DAILY" | "WEEKLY" | "MONTHLY").
557
+ range_count : int
558
+ ๊ธฐ๊ฐ„ ๋ฒ”์œ„ ์ˆ˜.
559
+ max_result : int
560
+ ์ตœ๋Œ€ ๊ฒฐ๊ณผ ์ˆ˜.
561
+ target : str
562
+ ๋Œ€์ƒ ์ฑ„๋„.
563
+ main_sub_code : str
564
+ ๊ธฐ๊ด€ ์ฝ”๋“œ.
565
+ """
566
+ return await self._call_api(
567
+ "/minDFTopNKeyword5",
568
+ {
569
+ "target": target,
570
+ "period": period,
571
+ "analysisTime": analysis_time,
572
+ "rangeCount": range_count,
573
+ "maxResult": max_result,
574
+ "mainSubCode": main_sub_code,
575
+ },
576
+ )
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # ํ†ต๊ณ„ API (stats_lookup)
580
+ # ---------------------------------------------------------------------------
581
+
582
+ async def get_statistics(
583
+ self,
584
+ date_from: str,
585
+ date_to: str,
586
+ period: str = "DAILY",
587
+ target: str = "pttn,dfpt,saeol",
588
+ sort_by: str = "NAME",
589
+ sort_order: str = "false",
590
+ ) -> Optional[List[Dict[str, Any]]]:
591
+ """๋งž์ถคํ˜• ํ†ต๊ณ„๋ฅผ ์กฐํšŒํ•œ๋‹ค.
592
+
593
+ Parameters
594
+ ----------
595
+ date_from : str
596
+ ์‹œ์ž‘ ๋‚ ์งœ (YYYYMMDD).
597
+ date_to : str
598
+ ์ข…๋ฃŒ ๋‚ ์งœ (YYYYMMDD).
599
+ period : str
600
+ ๊ธฐ๊ฐ„ ๋‹จ์œ„.
601
+ target : str
602
+ ๋Œ€์ƒ ์ฑ„๋„.
603
+ sort_by : str
604
+ ์ •๋ ฌ ๊ธฐ์ค€.
605
+ sort_order : str
606
+ ์ •๋ ฌ ์ˆœ์„œ ("true" ์˜ค๋ฆ„์ฐจ์ˆœ, "false" ๋‚ด๋ฆผ์ฐจ์ˆœ).
607
+ """
608
+ return await self._call_api(
609
+ "/minStaticsInfo5",
610
+ {
611
+ "target": target,
612
+ "dateFrom": date_from,
613
+ "dateTo": date_to,
614
+ "period": period,
615
+ "sortBy": sort_by,
616
+ "sortOrder": sort_order,
617
+ },
618
+ )
619
+
620
+ async def get_trend(
621
+ self,
622
+ date_from: str,
623
+ date_to: str,
624
+ period: str = "DAILY",
625
+ target: str = "pttn,dfpt,saeol",
626
+ sort_by: str = "NAME",
627
+ sort_order: str = "false",
628
+ ) -> Optional[List[Dict[str, Any]]]:
629
+ """๋ฏผ์› ํŠธ๋ Œ๋“œ(์‹œ๊ณ„์—ด)๋ฅผ ์กฐํšŒํ•œ๋‹ค.
630
+
631
+ Parameters
632
+ ----------
633
+ date_from : str
634
+ ์‹œ์ž‘ ๋‚ ์งœ์‹œ๊ฐ„ (YYYYMMDDHH).
635
+ date_to : str
636
+ ์ข…๋ฃŒ ๋‚ ์งœ์‹œ๊ฐ„ (YYYYMMDDHH).
637
+ period : str
638
+ ๊ธฐ๊ฐ„ ๋‹จ์œ„.
639
+ target : str
640
+ ๋Œ€์ƒ ์ฑ„๋„.
641
+ sort_by : str
642
+ ์ •๋ ฌ ๊ธฐ์ค€.
643
+ sort_order : str
644
+ ์ •๋ ฌ ์ˆœ์„œ.
645
+ """
646
+ return await self._call_api(
647
+ "/minTimeSeriseView5",
648
+ {
649
+ "target": target,
650
+ "dateFrom": date_from,
651
+ "dateTo": date_to,
652
+ "period": period,
653
+ "sortBy": sort_by,
654
+ "sortOrder": sort_order,
655
+ },
656
+ )
657
+
658
+ async def get_doc_count(
659
+ self,
660
+ date_from: str,
661
+ date_to: str,
662
+ searchword: str,
663
+ target: str = "pttn,dfpt,saeol",
664
+ min_score: int = 70,
665
+ omit_duplicate: bool = False,
666
+ ) -> Optional[List[Dict[str, Any]]]:
667
+ """๋ฏผ์› ๊ฑด์ˆ˜๋ฅผ ์กฐํšŒํ•œ๋‹ค.
668
+
669
+ Parameters
670
+ ----------
671
+ date_from : str
672
+ ์‹œ์ž‘ ๋‚ ์งœ (YYYYMMDD).
673
+ date_to : str
674
+ ์ข…๋ฃŒ ๋‚ ์งœ (YYYYMMDD).
675
+ searchword : str
676
+ ๊ฒ€์ƒ‰์–ด.
677
+ target : str
678
+ ๋Œ€์ƒ ์ฑ„๋„.
679
+ min_score : int
680
+ ์ตœ์†Œ ์œ ์‚ฌ๋„ ์ ์ˆ˜.
681
+ omit_duplicate : bool
682
+ ์ค‘๋ณต ์ œ๊ฑฐ ์—ฌ๋ถ€.
683
+ """
684
+ return await self._call_api(
685
+ "/minSearchDocCnt5",
686
+ {
687
+ "dateFrom": date_from,
688
+ "dateTo": date_to,
689
+ "target": target,
690
+ "minScore": min_score,
691
+ "searchword": searchword,
692
+ "omitDuplicate": str(omit_duplicate).lower(),
693
+ },
694
+ )
695
+
696
+ async def get_org_ranking(
697
+ self,
698
+ date_from: str,
699
+ date_to: str,
700
+ top_n: int = 5,
701
+ target: str = "pttn,dfpt,saeol",
702
+ sort_by: str = "VALUE",
703
+ sort_order: str = "false",
704
+ ) -> Optional[List[Dict[str, Any]]]:
705
+ """๊ธฐ๊ด€๋ณ„ ๋ฏผ์› ์ˆœ์œ„๋ฅผ ์กฐํšŒํ•œ๋‹ค.
706
+
707
+ Parameters
708
+ ----------
709
+ date_from : str
710
+ ์‹œ์ž‘ ๋‚ ์งœ (YYYYMMDD).
711
+ date_to : str
712
+ ์ข…๋ฃŒ ๋‚ ์งœ (YYYYMMDD).
713
+ top_n : int
714
+ ์ƒ์œ„ N๊ฐœ.
715
+ target : str
716
+ ๋Œ€์ƒ ์ฑ„๋„.
717
+ sort_by : str
718
+ ์ •๋ ฌ ๊ธฐ์ค€.
719
+ sort_order : str
720
+ ์ •๋ ฌ ์ˆœ์„œ.
721
+ """
722
+ return await self._call_api(
723
+ "/minMofacetInfo5",
724
+ {
725
+ "topN": top_n,
726
+ "sortBy": sort_by,
727
+ "sortOrder": sort_order,
728
+ "target": target,
729
+ "dateFrom": date_from,
730
+ "dateTo": date_to,
731
+ },
732
+ )
733
+
734
+ async def get_region_ranking(
735
+ self,
736
+ date_from: str,
737
+ date_to: str,
738
+ top_n: int = 5,
739
+ target: str = "pttn,dfpt,saeol",
740
+ sort_by: str = "VALUE",
741
+ sort_order: str = "false",
742
+ ) -> Optional[List[Dict[str, Any]]]:
743
+ """์ง€์—ญ๋ณ„ ๋ฏผ์› ์ˆœ์œ„๋ฅผ ์กฐํšŒํ•œ๋‹ค.
744
+
745
+ Parameters
746
+ ----------
747
+ date_from : str
748
+ ์‹œ์ž‘ ๋‚ ์งœ (YYYYMMDD).
749
+ date_to : str
750
+ ์ข…๋ฃŒ ๋‚ ์งœ (YYYYMMDD).
751
+ top_n : int
752
+ ์ƒ์œ„ N๊ฐœ.
753
+ target : str
754
+ ๋Œ€์ƒ ์ฑ„๋„.
755
+ sort_by : str
756
+ ์ •๋ ฌ ๊ธฐ์ค€.
757
+ sort_order : str
758
+ ์ •๋ ฌ ์ˆœ์„œ.
759
+ """
760
+ return await self._call_api(
761
+ "/minMrfacetInfo5",
762
+ {
763
+ "topN": top_n,
764
+ "sortBy": sort_by,
765
+ "sortOrder": sort_order,
766
+ "dateFrom": date_from,
767
+ "dateTo": date_to,
768
+ "target": target,
769
+ },
770
+ )
771
+
772
+ # ---------------------------------------------------------------------------
773
+ # ํ‚ค์›Œ๋“œ ๋ถ„์„ API (keyword_analyzer)
774
+ # ---------------------------------------------------------------------------
775
+
776
+ async def get_core_keywords(
777
+ self,
778
+ date_from: str,
779
+ date_to: str,
780
+ result_count: int = 5,
781
+ target: str = "pttn,dfpt,saeol",
782
+ ) -> Optional[List[Dict[str, Any]]]:
783
+ """ํ•ต์‹ฌ ํ‚ค์›Œ๋“œ๋ฅผ ์กฐํšŒํ•œ๋‹ค.
784
+
785
+ Parameters
786
+ ----------
787
+ date_from : str
788
+ ์‹œ์ž‘ ๋‚ ์งœ (YYYYMMDD).
789
+ date_to : str
790
+ ์ข…๋ฃŒ ๋‚ ์งœ (YYYYMMDD).
791
+ result_count : int
792
+ ๊ฒฐ๊ณผ ์ˆ˜.
793
+ target : str
794
+ ๋Œ€์ƒ ์ฑ„๋„.
795
+ """
796
+ return await self._call_api(
797
+ "/minTopNKeyword5",
798
+ {
799
+ "target": target,
800
+ "dateFrom": date_from,
801
+ "dateTo": date_to,
802
+ "resultCount": result_count,
803
+ },
804
+ )
805
+
806
+ async def get_related_words(
807
+ self,
808
+ date_from: str,
809
+ date_to: str,
810
+ searchword: str,
811
+ result_count: int = 5,
812
+ target: str = "pttn,dfpt,saeol",
813
+ ) -> Optional[List[Dict[str, Any]]]:
814
+ """์—ฐ๊ด€์–ด๋ฅผ ์กฐํšŒํ•œ๋‹ค.
815
+
816
+ Parameters
817
+ ----------
818
+ date_from : str
819
+ ์‹œ์ž‘ ๋‚ ์งœ (YYYYMMDD).
820
+ date_to : str
821
+ ์ข…๋ฃŒ ๋‚ ์งœ (YYYYMMDD).
822
+ searchword : str
823
+ ๊ฒ€์ƒ‰์–ด.
824
+ result_count : int
825
+ ๊ฒฐ๊ณผ ์ˆ˜.
826
+ target : str
827
+ ๋Œ€์ƒ ์ฑ„๋„.
828
+ """
829
+ return await self._call_api(
830
+ "/minWdcloudInfo5",
831
+ {
832
+ "target": target,
833
+ "searchword": searchword,
834
+ "dateFrom": date_from,
835
+ "dateTo": date_to,
836
+ "resultCount": result_count,
837
+ },
838
+ )
839
+
840
+ # ---------------------------------------------------------------------------
841
+ # ์ธ๊ตฌํ†ต๊ณ„ API (demographics_lookup)
842
+ # ---------------------------------------------------------------------------
843
+
844
+ async def get_gender_stats(
845
+ self,
846
+ date_from: str,
847
+ date_to: str,
848
+ searchword: str,
849
+ target: str = "pttn",
850
+ ) -> Optional[List[Dict[str, Any]]]:
851
+ """์„ฑ๋ณ„ ํ†ต๊ณ„๋ฅผ ์กฐํšŒํ•œ๋‹ค.
852
+
853
+ Parameters
854
+ ----------
855
+ date_from : str
856
+ ์‹œ์ž‘ ๋‚ ์งœ (YYYYMMDD).
857
+ date_to : str
858
+ ์ข…๋ฃŒ ๋‚ ์งœ (YYYYMMDD).
859
+ searchword : str
860
+ ๊ฒ€์ƒ‰์–ด.
861
+ target : str
862
+ ๋Œ€์ƒ ์ฑ„๋„.
863
+ """
864
+ return await self._call_api(
865
+ "/minPttnStstGndrInfo5",
866
+ {
867
+ "dateFrom": date_from,
868
+ "dateTo": date_to,
869
+ "target": target,
870
+ "searchword": searchword,
871
+ },
872
+ )
873
+
874
+ async def get_age_stats(
875
+ self,
876
+ date_from: str,
877
+ date_to: str,
878
+ searchword: str,
879
+ target: str = "pttn",
880
+ ) -> Optional[List[Dict[str, Any]]]:
881
+ """์—ฐ๋ น๋ณ„ ํ†ต๊ณ„๋ฅผ ์กฐํšŒํ•œ๋‹ค.
882
+
883
+ Parameters
884
+ ----------
885
+ date_from : str
886
+ ์‹œ์ž‘ ๋‚ ์งœ (YYYYMMDD).
887
+ date_to : str
888
+ ์ข…๋ฃŒ ๋‚ ์งœ (YYYYMMDD).
889
+ searchword : str
890
+ ๊ฒ€์ƒ‰์–ด.
891
+ target : str
892
+ ๋Œ€์ƒ ์ฑ„๋„.
893
+ """
894
+ return await self._call_api(
895
+ "/minPttnStstAgeInfo5",
896
+ {
897
+ "dateFrom": date_from,
898
+ "dateTo": date_to,
899
+ "target": target,
900
+ "searchword": searchword,
901
+ },
902
+ )
903
+
904
+ async def get_population_ratio(
905
+ self,
906
+ date_from: str,
907
+ date_to: str,
908
+ top_n: int = 5,
909
+ target: str = "pttn,saeol,dfpt",
910
+ period: str = "DAILY",
911
+ sort_by: str = "VALUE",
912
+ sort_order: str = "false",
913
+ date_type: str = "C",
914
+ search_type: str = "REGION",
915
+ ) -> Optional[List[Dict[str, Any]]]:
916
+ """์ธ๊ตฌ๋Œ€๋น„ ๋ฏผ์› ๋น„์œจ์„ ์กฐํšŒํ•œ๋‹ค.
917
+
918
+ Parameters
919
+ ----------
920
+ date_from : str
921
+ ์‹œ์ž‘ ๋‚ ์งœ (YYYYMMDD).
922
+ date_to : str
923
+ ์ข…๋ฃŒ ๋‚ ์งœ (YYYYMMDD).
924
+ top_n : int
925
+ ์ƒ์œ„ N๊ฐœ.
926
+ target : str
927
+ ๋Œ€์ƒ ์ฑ„๋„.
928
+ period : str
929
+ ๊ธฐ๊ฐ„ ๋‹จ์œ„.
930
+ sort_by : str
931
+ ์ •๋ ฌ ๊ธฐ์ค€.
932
+ sort_order : str
933
+ ์ •๋ ฌ ์ˆœ์„œ.
934
+ date_type : str
935
+ ๋‚ ์งœ ์œ ํ˜• ("C" ์ ‘์ˆ˜์ผ, "R" ๋“ฑ๋ก์ผ).
936
+ search_type : str
937
+ ๊ฒ€์ƒ‰ ์œ ํ˜• ("REGION" ์ง€์—ญ๋ณ„).
938
+ """
939
+ return await self._call_api(
940
+ "/minMrPopltnRtInfo5",
941
+ {
942
+ "target": target,
943
+ "dateFrom": date_from,
944
+ "dateTo": date_to,
945
+ "dateType": date_type,
946
+ "topN": top_n,
947
+ "period": period,
948
+ "sortBy": sort_by,
949
+ "sortOrder": sort_order,
950
+ "searchType": search_type,
951
+ },
952
+ )
src/inference/agent_loop.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """์„ธ์…˜ ๊ธฐ๋ฐ˜ task loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, AsyncGenerator, Callable, Dict, List, Optional
10
+
11
+ from loguru import logger
12
+
13
+ from .query_builder import build_query_variants, build_runtime_query_context, resolve_tool_query
14
+ from .session_context import SessionContext
15
+ from .tool_router import ExecutionPlan, ToolName, ToolRouter, ToolStep, ToolType, tool_name
16
+
17
+
18
+ @dataclass
19
+ class ToolResult:
20
+ tool: ToolName
21
+ success: bool
22
+ data: Dict[str, Any] = field(default_factory=dict)
23
+ error: Optional[str] = None
24
+ latency_ms: float = 0.0
25
+
26
+ def to_dict(self) -> Dict[str, Any]:
27
+ return {
28
+ "tool": tool_name(self.tool),
29
+ "success": self.success,
30
+ "data": self.data,
31
+ "error": self.error,
32
+ "latency_ms": round(self.latency_ms, 2),
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class AgentTrace:
38
+ request_id: str
39
+ session_id: str
40
+ plan: Optional[ExecutionPlan] = None
41
+ tool_results: List[ToolResult] = field(default_factory=list)
42
+ total_latency_ms: float = 0.0
43
+ final_text: str = ""
44
+ error: Optional[str] = None
45
+
46
+ def to_dict(self) -> Dict[str, Any]:
47
+ return {
48
+ "request_id": self.request_id,
49
+ "session_id": self.session_id,
50
+ "plan": self.plan.tool_names if self.plan else [],
51
+ "plan_reason": self.plan.reason if self.plan else "",
52
+ "tool_results": [result.to_dict() for result in self.tool_results],
53
+ "total_latency_ms": round(self.total_latency_ms, 2),
54
+ "error": self.error,
55
+ }
56
+
57
+
58
+ ToolFunction = Callable[..., Any]
59
+ DEFAULT_TOOL_TIMEOUT = 30.0
60
+
61
+
62
+ class AgentLoop:
63
+ """GovOn MVP capability loop."""
64
+
65
+ def __init__(
66
+ self,
67
+ tool_registry: Dict[ToolName, ToolFunction],
68
+ router: Optional[ToolRouter] = None,
69
+ tool_timeout: float = DEFAULT_TOOL_TIMEOUT,
70
+ ) -> None:
71
+ self._tools = {tool_name(name): runner for name, runner in tool_registry.items()}
72
+ self._router = router or ToolRouter()
73
+ self._tool_timeout = tool_timeout
74
+
75
+ async def run(
76
+ self,
77
+ query: str,
78
+ session: SessionContext,
79
+ request_id: Optional[str] = None,
80
+ force_tools: Optional[List[ToolName]] = None,
81
+ ) -> AgentTrace:
82
+ rid = request_id or str(uuid.uuid4())
83
+ trace = AgentTrace(request_id=rid, session_id=session.session_id)
84
+ loop_start = time.monotonic()
85
+ started_at = time.time()
86
+
87
+ try:
88
+ session.add_turn("user", query)
89
+
90
+ has_context = bool(session.tool_runs or session.conversations)
91
+ plan = self._router.plan(query, has_context=has_context, force_tools=force_tools)
92
+ trace.plan = plan
93
+
94
+ accumulated: Dict[str, Any] = build_runtime_query_context(session, query)
95
+ accumulated["conversation"] = [
96
+ {"role": turn.role, "content": turn.content} for turn in session.recent_history[-5:]
97
+ ]
98
+ accumulated["query_variants"] = build_query_variants(
99
+ query,
100
+ tool_names=plan.tool_names,
101
+ context=accumulated,
102
+ )
103
+
104
+ for step in plan.steps:
105
+ result = await self._execute_tool(step, accumulated, session)
106
+ trace.tool_results.append(result)
107
+ accumulated[step.step_id] = result.data if result.success else {}
108
+ session.add_tool_run(
109
+ tool=step.step_id,
110
+ graph_run_request_id=rid,
111
+ success=result.success,
112
+ latency_ms=result.latency_ms,
113
+ error=result.error,
114
+ metadata=self._build_tool_log_metadata(result.data),
115
+ )
116
+
117
+ trace.final_text = self._extract_final_text(accumulated, plan)
118
+ session.add_turn("assistant", trace.final_text)
119
+
120
+ except Exception as exc:
121
+ trace.error = str(exc)
122
+ logger.error(f"[AgentLoop] request_id={rid} ์˜ค๋ฅ˜: {exc}", exc_info=True)
123
+ finally:
124
+ trace.total_latency_ms = (time.monotonic() - loop_start) * 1000
125
+ self._record_graph_run(
126
+ session=session,
127
+ trace=trace,
128
+ started_at=started_at,
129
+ completed_at=time.time(),
130
+ )
131
+
132
+ return trace
133
+
134
+ async def run_stream(
135
+ self,
136
+ query: str,
137
+ session: SessionContext,
138
+ request_id: Optional[str] = None,
139
+ force_tools: Optional[List[ToolName]] = None,
140
+ ) -> AsyncGenerator[Dict[str, Any], None]:
141
+ rid = request_id or str(uuid.uuid4())
142
+ loop_start = time.monotonic()
143
+ started_at = time.time()
144
+ trace = AgentTrace(request_id=rid, session_id=session.session_id)
145
+
146
+ try:
147
+ session.add_turn("user", query)
148
+ has_context = bool(session.tool_runs or session.conversations)
149
+ plan = self._router.plan(query, has_context=has_context, force_tools=force_tools)
150
+ trace.plan = plan
151
+
152
+ yield {
153
+ "type": "plan",
154
+ "request_id": rid,
155
+ "plan": plan.tool_names,
156
+ "reason": plan.reason,
157
+ }
158
+
159
+ accumulated: Dict[str, Any] = build_runtime_query_context(session, query)
160
+ accumulated["query_variants"] = build_query_variants(
161
+ query,
162
+ tool_names=plan.tool_names,
163
+ context=accumulated,
164
+ )
165
+
166
+ for step in plan.steps:
167
+ yield {"type": "tool_start", "request_id": rid, "tool": step.step_id}
168
+ result = await self._execute_tool(step, accumulated, session)
169
+ trace.tool_results.append(result)
170
+ accumulated[step.step_id] = result.data if result.success else {}
171
+ session.add_tool_run(
172
+ tool=step.step_id,
173
+ graph_run_request_id=rid,
174
+ success=result.success,
175
+ latency_ms=result.latency_ms,
176
+ error=result.error,
177
+ metadata=self._build_tool_log_metadata(result.data),
178
+ )
179
+ yield {
180
+ "type": "tool_result",
181
+ "request_id": rid,
182
+ "tool": step.step_id,
183
+ "success": result.success,
184
+ "latency_ms": round(result.latency_ms, 2),
185
+ "error": result.error,
186
+ }
187
+
188
+ trace.final_text = self._extract_final_text(accumulated, plan)
189
+ session.add_turn("assistant", trace.final_text)
190
+ trace.total_latency_ms = (time.monotonic() - loop_start) * 1000
191
+ yield {
192
+ "type": "final",
193
+ "request_id": rid,
194
+ "text": trace.final_text,
195
+ "trace": trace.to_dict(),
196
+ "finished": True,
197
+ }
198
+
199
+ except Exception as exc:
200
+ trace.error = str(exc)
201
+ trace.total_latency_ms = (time.monotonic() - loop_start) * 1000
202
+ logger.error(f"[AgentLoop] stream request_id={rid} ์˜ค๋ฅ˜: {exc}", exc_info=True)
203
+ yield {
204
+ "type": "error",
205
+ "request_id": rid,
206
+ "error": "์—์ด์ „ํŠธ ์ฒ˜๋ฆฌ ์ค‘ ๋‚ด๋ถ€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.",
207
+ "finished": True,
208
+ }
209
+ finally:
210
+ if trace.total_latency_ms == 0.0:
211
+ trace.total_latency_ms = (time.monotonic() - loop_start) * 1000
212
+ self._record_graph_run(
213
+ session=session,
214
+ trace=trace,
215
+ started_at=started_at,
216
+ completed_at=time.time(),
217
+ )
218
+
219
+ async def _execute_tool(
220
+ self,
221
+ step: ToolStep,
222
+ accumulated: Dict[str, Any],
223
+ session: SessionContext,
224
+ ) -> ToolResult:
225
+ step_name = step.step_id
226
+ tool_fn = self._tools.get(step_name)
227
+ if tool_fn is None:
228
+ return ToolResult(
229
+ tool=step.tool, success=False, error=f"๋“ฑ๋ก๋˜์ง€ ์•Š์€ tool: {step_name}"
230
+ )
231
+
232
+ start = time.monotonic()
233
+ try:
234
+ execution_query = resolve_tool_query(step_name, accumulated)
235
+ result_data = await asyncio.wait_for(
236
+ tool_fn(
237
+ query=execution_query,
238
+ context=accumulated,
239
+ session=session,
240
+ ),
241
+ timeout=self._tool_timeout,
242
+ )
243
+ return ToolResult(
244
+ tool=step.tool,
245
+ success=True,
246
+ data=result_data if isinstance(result_data, dict) else {"result": result_data},
247
+ latency_ms=(time.monotonic() - start) * 1000,
248
+ )
249
+ except asyncio.TimeoutError:
250
+ return ToolResult(
251
+ tool=step.tool,
252
+ success=False,
253
+ error=f"tool {step_name} ํƒ€์ž„์•„์›ƒ ({self._tool_timeout}์ดˆ)",
254
+ latency_ms=(time.monotonic() - start) * 1000,
255
+ )
256
+ except Exception as exc:
257
+ logger.error(f"[AgentLoop] tool {step_name} ์‹คํ–‰ ์˜ค๋ฅ˜: {exc}", exc_info=True)
258
+ return ToolResult(
259
+ tool=step.tool,
260
+ success=False,
261
+ error=str(exc),
262
+ latency_ms=(time.monotonic() - start) * 1000,
263
+ )
264
+
265
+ @staticmethod
266
+ def _build_tool_log_metadata(data: Dict[str, Any]) -> Dict[str, Any]:
267
+ """tool log์— ๋‚จ๊ธธ ์ž‘์€ preview๋งŒ ๋ณด๊ด€ํ•œ๋‹ค."""
268
+ metadata: Dict[str, Any] = {}
269
+ if "count" in data:
270
+ metadata["count"] = data["count"]
271
+ if "query" in data:
272
+ metadata["query"] = data["query"]
273
+ if "results" in data and isinstance(data["results"], list):
274
+ metadata["result_count"] = len(data["results"])
275
+ if "text" in data:
276
+ metadata["text_preview"] = str(data["text"])[:200]
277
+ return metadata
278
+
279
+ @staticmethod
280
+ def _build_plan_summary(plan: Optional[ExecutionPlan]) -> str:
281
+ if not plan:
282
+ return ""
283
+
284
+ tools = " -> ".join(step.step_id for step in plan.steps)
285
+ if plan.reason:
286
+ return f"{plan.reason} | tools: {tools}"
287
+ return tools
288
+
289
+ @staticmethod
290
+ def _graph_run_status(trace: AgentTrace) -> str:
291
+ if trace.error:
292
+ return "failed"
293
+ if any(not result.success for result in trace.tool_results):
294
+ return "completed_with_errors"
295
+ return "completed"
296
+
297
+ @classmethod
298
+ def _record_graph_run(
299
+ cls,
300
+ session: SessionContext,
301
+ trace: AgentTrace,
302
+ started_at: float,
303
+ completed_at: float,
304
+ ) -> None:
305
+ success_count = sum(1 for result in trace.tool_results if result.success)
306
+ failure_count = len(trace.tool_results) - success_count
307
+ session.add_graph_run(
308
+ request_id=trace.request_id,
309
+ plan_summary=cls._build_plan_summary(trace.plan),
310
+ approval_status="not_requested",
311
+ executed_capabilities=[tool_name(result.tool) for result in trace.tool_results],
312
+ status=cls._graph_run_status(trace),
313
+ error=trace.error,
314
+ total_latency_ms=trace.total_latency_ms,
315
+ metadata={
316
+ "plan_reason": trace.plan.reason if trace.plan else "",
317
+ "tool_result_count": len(trace.tool_results),
318
+ "success_count": success_count,
319
+ "failure_count": failure_count,
320
+ "final_text_preview": trace.final_text[:200],
321
+ },
322
+ started_at=started_at,
323
+ completed_at=completed_at,
324
+ )
325
+
326
+ @staticmethod
327
+ def _extract_final_text(accumulated: Dict[str, Any], plan: ExecutionPlan) -> str:
328
+ for tool_type in (ToolType.APPEND_EVIDENCE, ToolType.DRAFT_CIVIL_RESPONSE):
329
+ payload = accumulated.get(tool_type.value, {})
330
+ if isinstance(payload, dict) and payload.get("text"):
331
+ return str(payload["text"])
332
+
333
+ for step in plan.steps:
334
+ payload = accumulated.get(step.step_id, {})
335
+ if isinstance(payload, dict) and payload.get("text"):
336
+ return str(payload["text"])
337
+
338
+ parts: List[str] = []
339
+
340
+ rag_data = accumulated.get(ToolType.RAG_SEARCH.value, {})
341
+ if rag_data.get("results"):
342
+ lines = ["[๋กœ์ปฌ ๋ฌธ์„œ ๊ทผ๊ฑฐ]"]
343
+ for item in rag_data["results"][:3]:
344
+ title = item.get("title", "")
345
+ content = item.get("content", "")[:120]
346
+ lines.append(f"- {title}: {content}")
347
+ parts.append("\n".join(lines))
348
+
349
+ api_data = accumulated.get(ToolType.API_LOOKUP.value, {})
350
+ if api_data.get("context_text"):
351
+ parts.append(api_data["context_text"])
352
+ elif api_data.get("results"):
353
+ lines = ["[์™ธ๋ถ€ ์กฐํšŒ ๊ฒฐ๊ณผ]"]
354
+ for item in api_data["results"][:3]:
355
+ title = item.get("title", item.get("qnaTitle", ""))
356
+ content = item.get("content", item.get("qnaContent", ""))[:120]
357
+ lines.append(f"- {title}: {content}")
358
+ parts.append("\n".join(lines))
359
+
360
+ return "\n\n".join(parts) if parts else "์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
src/inference/agent_manager.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent Manager for multi-persona prompt construction.
3
+
4
+ Loads agent persona definitions from Markdown files with YAML frontmatter,
5
+ and builds EXAONE chat template prompts with the appropriate system message.
6
+
7
+ Issue: #56
8
+ """
9
+
10
+ import os
11
+ import re
12
+ from typing import Dict, List, Optional
13
+
14
+ import yaml
15
+ from loguru import logger
16
+
17
+
18
+ class AgentPersona:
19
+ """Parsed agent persona from a Markdown file."""
20
+
21
+ def __init__(
22
+ self,
23
+ name: str,
24
+ role: str,
25
+ description: str,
26
+ system_prompt: str,
27
+ temperature: float = 0.7,
28
+ max_tokens: int = 512,
29
+ ):
30
+ self.name = name
31
+ self.role = role
32
+ self.description = description
33
+ self.system_prompt = system_prompt
34
+ self.temperature = temperature
35
+ self.max_tokens = max_tokens
36
+
37
+ def __repr__(self) -> str:
38
+ return f"AgentPersona(name={self.name!r}, role={self.role!r})"
39
+
40
+
41
+ class AgentManager:
42
+ """
43
+ Loads and manages agent personas from Markdown files.
44
+
45
+ Each agent file uses YAML frontmatter for configuration and Markdown body
46
+ for the system prompt content.
47
+
48
+ Usage:
49
+ manager = AgentManager("agents/")
50
+ persona = manager.get_agent("generator_civil_response")
51
+ prompt = manager.build_prompt("generator_civil_response", "๋ฏผ์› ๋‹ต๋ณ€ ์ดˆ์•ˆ์„ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”.")
52
+ """
53
+
54
+ _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)
55
+
56
+ def __init__(self, agents_dir: str = "agents"):
57
+ self.agents_dir = agents_dir
58
+ self._agents: Dict[str, AgentPersona] = {}
59
+ self._load_agents()
60
+
61
+ _DANGEROUS_TOKENS = ["[|user|]", "[|assistant|]", "[|system|]", "[|endofturn|]"]
62
+
63
+ def _load_agents(self) -> None:
64
+ base_dir = os.path.realpath(self.agents_dir)
65
+ if not os.path.isdir(base_dir):
66
+ logger.warning(f"Agents directory not found: {base_dir}")
67
+ return
68
+
69
+ for filename in os.listdir(base_dir):
70
+ if not filename.endswith(".md"):
71
+ continue
72
+ filepath = os.path.realpath(os.path.join(base_dir, filename))
73
+ if not filepath.startswith(base_dir + os.sep):
74
+ logger.warning(f"๊ฒฝ๋กœ ์ˆœํšŒ ์‹œ๋„ ๊ฐ์ง€, ๋ฌด์‹œ๋จ: {filename}")
75
+ continue
76
+ try:
77
+ agent = self._parse_agent_file(filepath)
78
+ self._agents[agent.name] = agent
79
+ logger.info(f"Loaded agent: {agent.name} ({agent.role})")
80
+ except Exception as e:
81
+ logger.error(f"Failed to load agent from {filename}: {e}")
82
+
83
+ def _parse_agent_file(self, filepath: str) -> AgentPersona:
84
+ with open(filepath, "r", encoding="utf-8") as f:
85
+ content = f.read()
86
+
87
+ match = self._FRONTMATTER_RE.match(content)
88
+ if not match:
89
+ raise ValueError(f"Invalid agent file format (missing YAML frontmatter): {filepath}")
90
+
91
+ frontmatter = yaml.safe_load(match.group(1))
92
+ body = match.group(2).strip()
93
+
94
+ name = (frontmatter.get("name") or "").strip()
95
+ if not name or not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
96
+ raise ValueError(f"์œ ํšจํ•˜์ง€ ์•Š์€ ์—์ด์ „ํŠธ ์ด๋ฆ„: {name!r} in {filepath}")
97
+
98
+ return AgentPersona(
99
+ name=name,
100
+ role=frontmatter.get("role", ""),
101
+ description=frontmatter.get("description", ""),
102
+ system_prompt=body,
103
+ temperature=float(frontmatter.get("temperature", 0.7)),
104
+ max_tokens=int(frontmatter.get("max_tokens", 512)),
105
+ )
106
+
107
+ def get_agent(self, name: str) -> Optional[AgentPersona]:
108
+ return self._agents.get(name)
109
+
110
+ def list_agents(self) -> List[str]:
111
+ return list(self._agents.keys())
112
+
113
+ def build_prompt(self, agent_name: str, user_message: str) -> str:
114
+ """
115
+ Build an EXAONE chat template prompt with the agent's system message.
116
+
117
+ Format:
118
+ [|system|]{system_prompt}[|endofturn|]
119
+ [|user|]{user_message}[|endofturn|]
120
+ [|assistant|]
121
+ """
122
+ agent = self._agents.get(agent_name)
123
+ if agent is None:
124
+ raise ValueError(f"Unknown agent: {agent_name}")
125
+
126
+ for token in self._DANGEROUS_TOKENS:
127
+ if token in user_message:
128
+ raise ValueError(f"์ด์Šค์ผ€์ดํ”„๋˜์ง€ ์•Š์€ ํŠน์ˆ˜ ํ† ํฐ ๊ฐ์ง€: {token}")
129
+
130
+ return (
131
+ f"[|system|]{agent.system_prompt}[|endofturn|]"
132
+ f"\n[|user|]{user_message}[|endofturn|]"
133
+ f"\n[|assistant|]"
134
+ )
src/inference/api_server.py ADDED
@@ -0,0 +1,1899 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import re
5
+ import time
6
+ import uuid
7
+ from contextlib import asynccontextmanager
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Any, AsyncGenerator, Dict, List, Optional
11
+
12
+ from fastapi import Depends, FastAPI, HTTPException, Request, Security
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import StreamingResponse
15
+ from fastapi.security import APIKeyHeader
16
+ from loguru import logger
17
+
18
+ try:
19
+ from vllm import AsyncLLM, SamplingParams
20
+ except ImportError:
21
+ try:
22
+ from vllm.engine.async_llm_engine import AsyncLLMEngine as AsyncLLM
23
+ from vllm.sampling_params import SamplingParams
24
+ except ImportError:
25
+ AsyncLLM = None
26
+ SamplingParams = None
27
+
28
+ try:
29
+ from vllm.lora.request import LoRARequest
30
+ except ImportError:
31
+ LoRARequest = None
32
+
33
+ # Multi-LoRA adapter name โ†’ numeric ID ๋งคํ•‘ (vLLM LoRARequest์— ์ „๋‹ฌ)
34
+ _LORA_ID_MAP: Dict[str, int] = {"civil": 1, "legal": 2}
35
+
36
+ from .agent_loop import AgentLoop, AgentTrace
37
+ from .agent_manager import AgentManager
38
+ from .bm25_indexer import BM25Indexer
39
+ from .feature_flags import FeatureFlags
40
+ from .hybrid_search import HybridSearchEngine, SearchMode
41
+ from .index_manager import IndexType, MultiIndexManager
42
+ from .retriever import CivilComplaintRetriever
43
+ from .runtime_config import RuntimeConfig
44
+ from .schemas import (
45
+ AgentRunRequest,
46
+ AgentRunResponse,
47
+ AgentTraceSchema,
48
+ GenerateCivilResponseRequest,
49
+ GenerateCivilResponseResponse,
50
+ GenerateRequest,
51
+ GenerateResponse,
52
+ RetrievedCase,
53
+ SearchRequest,
54
+ SearchResponse,
55
+ SearchResult,
56
+ ToolResultSchema,
57
+ )
58
+ from .session_context import SessionContext, SessionStore
59
+ from .tool_router import ToolType, tool_name
60
+
61
+ SessionLocal = None
62
+ LocalDocumentIndexer = None
63
+ SKIP_MODEL_LOAD = os.getenv("SKIP_MODEL_LOAD", "false").lower() in ("true", "1", "yes")
64
+
65
+
66
+ async def _noop_tool(query: str, context: dict, session: Any) -> dict:
67
+ """build_mvp_registry fallback์šฉ no-op tool."""
68
+ return {"success": False, "error": "tool์ด ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค"}
69
+
70
+
71
+ if not SKIP_MODEL_LOAD:
72
+ try:
73
+ from vllm.engine.arg_utils import AsyncEngineArgs
74
+
75
+ from .vllm_stabilizer import apply_transformers_patch
76
+ except ImportError:
77
+ logger.warning("vllm modules not found. Model loading will fail if attempted.")
78
+ AsyncEngineArgs = object
79
+ apply_transformers_patch = lambda: None
80
+
81
+ try:
82
+ from slowapi import Limiter
83
+ from slowapi.middleware import SlowAPIMiddleware
84
+ from slowapi.util import get_remote_address
85
+
86
+ limiter = Limiter(key_func=get_remote_address)
87
+ _RATE_LIMIT_AVAILABLE = True
88
+ except ImportError:
89
+ limiter = None
90
+ _RATE_LIMIT_AVAILABLE = False
91
+
92
+ _API_KEY = os.getenv("API_KEY")
93
+ _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
94
+
95
+
96
+ async def verify_api_key(api_key: str = Security(_api_key_header)):
97
+ if _API_KEY is None:
98
+ return
99
+ if api_key != _API_KEY:
100
+ raise HTTPException(status_code=401, detail="์œ ํšจํ•˜์ง€ ์•Š์€ API ํ‚ค์ž…๋‹ˆ๋‹ค.")
101
+
102
+
103
+ runtime_config = RuntimeConfig.from_env()
104
+ runtime_config.log_summary()
105
+
106
+ MODEL_PATH = runtime_config.model.model_path
107
+ DATA_PATH = runtime_config.paths.data_path
108
+ INDEX_PATH = runtime_config.paths.index_path
109
+ GPU_UTILIZATION = runtime_config.gpu_utilization
110
+ MAX_MODEL_LEN = runtime_config.max_model_len
111
+ TRUST_REMOTE_CODE = runtime_config.model.trust_remote_code
112
+ _PROJECT_ROOT = str(Path(__file__).resolve().parent.parent.parent)
113
+ AGENTS_DIR = runtime_config.paths.agents_dir
114
+
115
+
116
+ @dataclass
117
+ class PreparedGeneration:
118
+ prompt: str
119
+ sampling_params: SamplingParams
120
+ retrieved_cases: List[dict]
121
+ search_results: List[SearchResult]
122
+
123
+
124
+ if not SKIP_MODEL_LOAD:
125
+ apply_transformers_patch()
126
+
127
+
128
+ def _extract_content_by_type(result: dict, index_type: IndexType) -> str:
129
+ extras = result.get("extras", {})
130
+ if index_type == IndexType.CASE:
131
+ case_text = "\n".join(
132
+ part
133
+ for part in (extras.get("complaint_text", ""), extras.get("answer_text", ""))
134
+ if part
135
+ ).strip()
136
+ text = case_text or extras.get("content", "") or extras.get("chunk_text", "")
137
+ elif index_type == IndexType.LAW:
138
+ text = (
139
+ extras.get("law_text", "") or extras.get("content", "") or extras.get("chunk_text", "")
140
+ )
141
+ elif index_type == IndexType.MANUAL:
142
+ text = (
143
+ extras.get("manual_text", "")
144
+ or extras.get("content", "")
145
+ or extras.get("chunk_text", "")
146
+ )
147
+ elif index_type == IndexType.NOTICE:
148
+ text = (
149
+ extras.get("notice_text", "")
150
+ or extras.get("content", "")
151
+ or extras.get("chunk_text", "")
152
+ )
153
+ else:
154
+ text = ""
155
+ return text or result.get("title", "")
156
+
157
+
158
+ def _extract_approval_request(graph_state: Any) -> Any:
159
+ """LangGraph interrupt state์—์„œ approval payload๋ฅผ ๏ฟฝ๏ฟฝ๏ฟฝ์ถœํ•œ๋‹ค."""
160
+ if not graph_state or not getattr(graph_state, "tasks", None):
161
+ return None
162
+ task = graph_state.tasks[0]
163
+ if not getattr(task, "interrupts", None):
164
+ return None
165
+ return task.interrupts[0].value
166
+
167
+
168
+ class vLLMEngineManager:
169
+ """GovOn Shell MVP์šฉ ๋กœ์ปฌ ๋Ÿฐํƒ€์ž„ ๋งค๋‹ˆ์ €."""
170
+
171
+ def __init__(self):
172
+ self.engine: AsyncLLM = None
173
+ self.retriever: CivilComplaintRetriever = None
174
+ self.index_manager: Optional[MultiIndexManager] = None
175
+ self.hybrid_engine: Optional[HybridSearchEngine] = None
176
+ self.bm25_indexers: dict[IndexType, BM25Indexer] = {}
177
+ self.embed_model = None
178
+ self.feature_flags = FeatureFlags.from_env()
179
+ self.session_store = SessionStore()
180
+ self.agent_manager = AgentManager(AGENTS_DIR)
181
+ self.agent_loop: Optional[AgentLoop] = None
182
+ self.graph = None # LangGraph CompiledGraph (v2 ์—”๋“œํฌ์ธํŠธ์šฉ)
183
+ self.local_document_indexer: Optional[Any] = None
184
+ self.local_document_sync_status: Optional[Dict[str, Any]] = None
185
+ self._local_document_sync_task: Optional[asyncio.Task] = None
186
+ self._checkpointer_ctx = None # AsyncSqliteSaver ์ปจํ…์ŠคํŠธ ๋งค๋‹ˆ์ € (lifespan์—์„œ ๊ด€๋ฆฌ)
187
+ self._sync_checkpointer_conn = None # SqliteSaver์šฉ sqlite3 connection (leak ๋ฐฉ์ง€)
188
+ self._init_agent_loop()
189
+ self._init_graph()
190
+
191
+ async def initialize(self):
192
+ if SKIP_MODEL_LOAD:
193
+ logger.info("SKIP_MODEL_LOAD=true: ๋ชจ๋ธ ๋ฐ ์ธ๋ฑ์Šค ๋กœ๋”ฉ์„ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.")
194
+ return
195
+
196
+ logger.info(f"Initializing vLLM runtime with model: {MODEL_PATH}")
197
+ # EXAONE 4.0-32B-AWQ ๋„ค์ดํ‹ฐ๋ธŒ tool calling ํ™œ์„ฑํ™”:
198
+ # --enable-auto-tool-choice --tool-call-parser hermes
199
+ # Multi-LoRA ์„œ๋น™ ์‹œ --enable-lora --lora-modules ์˜ต์…˜ ์ถ”๊ฐ€
200
+ # HuggingFace Spaces L4 (24GB VRAM) ๊ธฐ์ค€ ~20GB ์ ์œ 
201
+ try:
202
+ engine_kwargs = dict(
203
+ model=MODEL_PATH,
204
+ trust_remote_code=TRUST_REMOTE_CODE,
205
+ gpu_memory_utilization=GPU_UTILIZATION,
206
+ max_model_len=MAX_MODEL_LEN,
207
+ dtype=runtime_config.model.dtype,
208
+ enforce_eager=runtime_config.model.enforce_eager,
209
+ )
210
+ # Multi-LoRA ์„œ๋น™: adapter_paths๊ฐ€ ์„ค์ •๋˜์–ด ์žˆ์œผ๋ฉด ํ™œ์„ฑํ™”
211
+ lora_enabled = bool(runtime_config.model.adapter_paths)
212
+ if lora_enabled:
213
+ engine_kwargs.update(
214
+ enable_lora=True,
215
+ max_loras=4,
216
+ max_lora_rank=64,
217
+ )
218
+ logger.info(
219
+ f"Multi-LoRA ํ™œ์„ฑํ™”: adapters={list(runtime_config.model.adapter_paths.keys())}"
220
+ )
221
+ engine_args = AsyncEngineArgs(**engine_kwargs)
222
+ if hasattr(AsyncLLM, "from_engine_args"):
223
+ self.engine = AsyncLLM.from_engine_args(engine_args)
224
+ else:
225
+ self.engine = AsyncLLM(engine_args)
226
+ except Exception as exc:
227
+ logger.error(f"vLLM ์—”์ง„ ์ดˆ๊ธฐํ™” ์‹คํŒจ: {exc}")
228
+ raise
229
+
230
+ logger.info(f"Initializing retriever with index: {INDEX_PATH}")
231
+ self.retriever = CivilComplaintRetriever(
232
+ index_path=INDEX_PATH if os.path.exists(INDEX_PATH) else None,
233
+ data_path=DATA_PATH if not os.path.exists(INDEX_PATH) else None,
234
+ )
235
+ if self.retriever.index is not None and not os.path.exists(INDEX_PATH):
236
+ self.retriever.save_index(INDEX_PATH)
237
+
238
+ faiss_index_dir = runtime_config.paths.faiss_index_dir
239
+ local_docs_root = runtime_config.paths.local_docs_root
240
+ if os.path.isdir(faiss_index_dir) or local_docs_root:
241
+ self.index_manager = MultiIndexManager(base_dir=faiss_index_dir)
242
+ logger.info(f"MultiIndexManager ์ดˆ๊ธฐํ™” ์™„๋ฃŒ: {faiss_index_dir}")
243
+ else:
244
+ logger.warning(f"FAISS ์ธ๋ฑ์Šค ๋””๋ ‰ํ„ฐ๋ฆฌ ๋ฏธ์กด์žฌ: {faiss_index_dir}")
245
+
246
+ bm25_index_dir = os.getenv("BM25_INDEX_DIR", "models/bm25_index")
247
+ if os.path.isdir(bm25_index_dir):
248
+ for idx_type in IndexType:
249
+ bm25_path = os.path.join(bm25_index_dir, f"{idx_type.value}.pkl")
250
+ if not os.path.exists(bm25_path):
251
+ continue
252
+ try:
253
+ indexer = BM25Indexer()
254
+ indexer.load(bm25_path)
255
+ self.bm25_indexers[idx_type] = indexer
256
+ logger.info(f"BM25 ์ธ๋ฑ์Šค ๋กœ๋“œ ์™„๋ฃŒ: {idx_type.value} ({indexer.doc_count}๊ฑด)")
257
+ except Exception as exc:
258
+ logger.warning(f"BM25 ์ธ๋ฑ์Šค ๋กœ๋“œ ์‹คํŒจ ({idx_type.value}): {exc}")
259
+
260
+ if self.retriever and hasattr(self.retriever, "model"):
261
+ self.embed_model = self.retriever.model
262
+
263
+ if self.index_manager and self.embed_model:
264
+ self.hybrid_engine = HybridSearchEngine(
265
+ index_manager=self.index_manager,
266
+ bm25_indexers=self.bm25_indexers,
267
+ embed_model=self.embed_model,
268
+ )
269
+ logger.info("HybridSearchEngine ์ดˆ๊ธฐํ™” ์™„๋ฃŒ")
270
+ self._schedule_local_document_sync()
271
+ else:
272
+ logger.warning("HybridSearchEngine ๋ฏธ์ดˆ๊ธฐํ™”: index_manager ๋˜๋Š” embed_model ์—†์Œ")
273
+
274
+ def _schedule_local_document_sync(self) -> None:
275
+ indexer = self._build_local_document_indexer()
276
+ if indexer is None:
277
+ return
278
+ if self._local_document_sync_task and not self._local_document_sync_task.done():
279
+ return
280
+
281
+ self.local_document_sync_status = {
282
+ "status": "syncing",
283
+ "root_dir": str(indexer.root_dir),
284
+ "source_name": indexer.source_name,
285
+ }
286
+ self._local_document_sync_task = asyncio.create_task(self._sync_local_documents_async())
287
+
288
+ async def _sync_local_documents_async(self) -> Optional[Dict[str, Any]]:
289
+ try:
290
+ return await asyncio.to_thread(self.sync_local_documents)
291
+ except Exception as exc:
292
+ logger.error(f"๋ฐฑ๊ทธ๋ผ์šด๋“œ ๋กœ์ปฌ ๋ฌธ์„œ ์ธ๋ฑ์‹ฑ ์‹คํŒจ: {exc}", exc_info=True)
293
+ if self.local_document_indexer is None:
294
+ return None
295
+ self.local_document_sync_status = {
296
+ "status": "error",
297
+ "root_dir": str(self.local_document_indexer.root_dir),
298
+ "source_name": self.local_document_indexer.source_name,
299
+ "error": str(exc),
300
+ }
301
+ return self.local_document_sync_status
302
+
303
+ def _build_local_document_indexer(self) -> Optional[Any]:
304
+ global SessionLocal, LocalDocumentIndexer
305
+
306
+ root_dir = runtime_config.paths.local_docs_root
307
+ if not root_dir:
308
+ return None
309
+ if self.index_manager is None or self.embed_model is None:
310
+ logger.warning(
311
+ "LOCAL_DOCS_ROOT๊ฐ€ ์„ค์ •๋์ง€๋งŒ index_manager ๋˜๋Š” embed_model์ด ์—†์Šต๋‹ˆ๋‹ค."
312
+ )
313
+ return None
314
+ if self.local_document_indexer is None:
315
+ if SessionLocal is None:
316
+ from .db.database import SessionLocal as _SessionLocal
317
+
318
+ SessionLocal = _SessionLocal
319
+ if LocalDocumentIndexer is None:
320
+ from .local_document_indexer import LocalDocumentIndexer as _LocalDocumentIndexer
321
+
322
+ LocalDocumentIndexer = _LocalDocumentIndexer
323
+
324
+ self.local_document_indexer = LocalDocumentIndexer(
325
+ root_dir=root_dir,
326
+ index_manager=self.index_manager,
327
+ embed_model=self.embed_model,
328
+ session_factory=SessionLocal,
329
+ )
330
+ return self.local_document_indexer
331
+
332
+ def sync_local_documents(self) -> Optional[Dict[str, Any]]:
333
+ indexer = self._build_local_document_indexer()
334
+ if indexer is None:
335
+ return None
336
+
337
+ try:
338
+ summary = indexer.sync()
339
+ except Exception as exc:
340
+ logger.error(f"๋กœ์ปฌ ๋ฌธ์„œ ์ธ๋ฑ์‹ฑ ์‹คํŒจ: {exc}", exc_info=True)
341
+ self.local_document_sync_status = {
342
+ "status": "error",
343
+ "root_dir": str(indexer.root_dir),
344
+ "source_name": indexer.source_name,
345
+ "error": str(exc),
346
+ }
347
+ return self.local_document_sync_status
348
+
349
+ self.local_document_sync_status = {
350
+ "status": "ok",
351
+ "root_dir": str(indexer.root_dir),
352
+ "source_name": indexer.source_name,
353
+ **asdict(summary),
354
+ }
355
+ logger.info(
356
+ "๋กœ์ปฌ ๋ฌธ์„œ ์ธ๋ฑ์‹ฑ ์™„๋ฃŒ: "
357
+ f"root={indexer.root_dir}, scanned={summary.scanned_files}, "
358
+ f"indexed={summary.indexed_files}, unchanged={summary.unchanged_files}, "
359
+ f"removed={summary.removed_files}"
360
+ )
361
+ return self.local_document_sync_status
362
+
363
+ def _escape_special_tokens(self, text: str) -> str:
364
+ tokens = [
365
+ "[|user|]",
366
+ "[|assistant|]",
367
+ "[|system|]",
368
+ "[|endofturn|]",
369
+ "<thought>",
370
+ "</thought>",
371
+ ]
372
+ for token in tokens:
373
+ text = text.replace(
374
+ token,
375
+ token.replace("[", "\\[")
376
+ .replace("]", "\\]")
377
+ .replace("<", "\\<")
378
+ .replace(">", "\\>"),
379
+ )
380
+ return text
381
+
382
+ @staticmethod
383
+ def _strip_thought_blocks(text: str) -> str:
384
+ return re.sub(r"<thought>.*?</thought>\s*", "", text, flags=re.DOTALL).strip()
385
+
386
+ def _build_rag_context(self, retrieved_cases: List[dict]) -> str:
387
+ if not retrieved_cases:
388
+ return ""
389
+ rag_context = "### ์ฐธ๊ณ  ์‚ฌ๋ก€ (์œ ์‚ฌ ๋ฏผ์› ๋ฐ ๋‹ต๋ณ€):\n"
390
+ for i, case in enumerate(retrieved_cases, start=1):
391
+ complaint = self._escape_special_tokens(case.get("complaint", ""))
392
+ answer = self._escape_special_tokens(case.get("answer", ""))
393
+ rag_context += f"{i}. [๋ฏผ์›]: {complaint}\n [๋‹ต๋ณ€]: {answer}\n\n"
394
+ return rag_context
395
+
396
+ def _augment_prompt(self, prompt: str, retrieved_cases: List[dict]) -> str:
397
+ rag_context = self._build_rag_context(retrieved_cases)
398
+ if not rag_context:
399
+ return prompt
400
+ user_tag = "[|user|]"
401
+ if user_tag in prompt:
402
+ return prompt.replace(user_tag, f"{user_tag}{rag_context}\n", 1)
403
+ return f"{rag_context}\n{prompt}"
404
+
405
+ def _build_search_result_context(self, search_results: List[SearchResult], heading: str) -> str:
406
+ if not search_results:
407
+ return ""
408
+
409
+ lines = [heading]
410
+ for index, result in enumerate(search_results, start=1):
411
+ safe_title = self._escape_special_tokens(result.title)
412
+ safe_content = self._escape_special_tokens(result.content[:300])
413
+ lines.append(f"{index}. [{result.source_type.value}] {safe_title}")
414
+ lines.append(f" ๊ทผ๊ฑฐ: {safe_content}")
415
+ return "\n".join(lines)
416
+
417
+ def _build_persona_prompt(self, agent_name: str, user_message: str) -> str:
418
+ if self.agent_manager and self.agent_manager.get_agent(agent_name):
419
+ return self.agent_manager.build_prompt(agent_name, user_message)
420
+ return user_message
421
+
422
+ def _extract_query(self, prompt: str) -> str:
423
+ user_match = re.search(r"\[\|user\|\](.*?)\[\|endofturn\|\]", prompt, re.DOTALL)
424
+ if user_match:
425
+ user_block = user_match.group(1)
426
+ complaint_match = re.search(r"๋ฏผ์›\s*๋‚ด์šฉ\s*:\s*(.+)", user_block, re.DOTALL)
427
+ if complaint_match:
428
+ return complaint_match.group(1).strip()
429
+ return user_block.strip()
430
+ return prompt
431
+
432
+ def _search_results_to_cases(self, search_results: List[SearchResult]) -> List[dict]:
433
+ retrieved_cases: List[dict] = []
434
+ for result in search_results:
435
+ if result.source_type != IndexType.CASE:
436
+ continue
437
+ metadata = result.metadata or {}
438
+ complaint = (
439
+ metadata.get("complaint_text") or metadata.get("complaint") or result.content
440
+ )
441
+ answer = metadata.get("answer_text") or metadata.get("answer") or result.content
442
+ retrieved_cases.append(
443
+ {
444
+ "id": result.doc_id,
445
+ "category": metadata.get("category", ""),
446
+ "complaint": complaint,
447
+ "answer": answer,
448
+ "score": result.score,
449
+ }
450
+ )
451
+ return retrieved_cases
452
+
453
+ @staticmethod
454
+ def _is_evidence_request(query: str) -> bool:
455
+ return any(token in query for token in ("๊ทผ๊ฑฐ", "์ถœ์ฒ˜", "์™œ", "์ด์œ ", "๋งํฌ"))
456
+
457
+ @staticmethod
458
+ def _is_revision_request(query: str) -> bool:
459
+ return any(token in query for token in ("๋‹ค์‹œ", "์ˆ˜์ •", "๊ณ ์ณ", "์ •์ค‘", "๊ณต์†", "๋ณด๊ฐ•"))
460
+
461
+ def _latest_prior_turns(
462
+ self,
463
+ session: SessionContext,
464
+ current_query: str,
465
+ ) -> tuple[Optional[str], Optional[str]]:
466
+ turns = list(session.recent_history)
467
+ if turns and turns[-1].role == "user" and turns[-1].content == current_query:
468
+ turns = turns[:-1]
469
+
470
+ previous_user = next(
471
+ (turn.content for turn in reversed(turns) if turn.role == "user"), None
472
+ )
473
+ previous_assistant = next(
474
+ (turn.content for turn in reversed(turns) if turn.role == "assistant"),
475
+ None,
476
+ )
477
+ return previous_user, previous_assistant
478
+
479
+ def _build_working_query(self, query: str, session: SessionContext) -> str:
480
+ query = query.strip()
481
+ if not query:
482
+ return query
483
+
484
+ if not (self._is_evidence_request(query) or self._is_revision_request(query)):
485
+ return query
486
+
487
+ previous_user, previous_assistant = self._latest_prior_turns(session, query)
488
+ parts: List[str] = []
489
+ if previous_user:
490
+ parts.append(f"์›๋ž˜ ์š”์ฒญ: {previous_user}")
491
+ if previous_assistant:
492
+ parts.append(f"์ด์ „ ๋‹ต๋ณ€: {previous_assistant[:600]}")
493
+
494
+ if self._is_revision_request(query):
495
+ parts.append(f"์ˆ˜์ • ์š”์ฒญ: {query}")
496
+
497
+ return "\n\n".join(parts) if parts else query
498
+
499
+ async def _retrieve_search_results(
500
+ self,
501
+ query: str,
502
+ index_types: List[IndexType],
503
+ top_k_per_type: int = 2,
504
+ ) -> List[SearchResult]:
505
+ if not query.strip():
506
+ return []
507
+
508
+ collected: List[SearchResult] = []
509
+
510
+ if self.hybrid_engine:
511
+
512
+ async def _search_index(index_type: IndexType) -> List[SearchResult]:
513
+ results_raw, _ = await self.hybrid_engine.search(
514
+ query=query,
515
+ index_type=index_type,
516
+ top_k=top_k_per_type,
517
+ mode=SearchMode.HYBRID,
518
+ )
519
+ return [
520
+ SearchResult(
521
+ doc_id=item.get("doc_id", ""),
522
+ source_type=IndexType(item.get("doc_type", index_type.value)),
523
+ title=item.get("title", ""),
524
+ content=_extract_content_by_type(item, index_type),
525
+ score=item.get("score", 0.0),
526
+ reliability_score=item.get("reliability_score", 1.0),
527
+ metadata=item.get("extras", {}),
528
+ chunk_index=item.get("chunk_index", 0),
529
+ total_chunks=item.get("chunk_total", 1),
530
+ )
531
+ for item in results_raw
532
+ ]
533
+
534
+ grouped = await asyncio.gather(
535
+ *[_search_index(index_type) for index_type in index_types],
536
+ return_exceptions=True,
537
+ )
538
+ for result in grouped:
539
+ if isinstance(result, BaseException):
540
+ logger.warning(f"๋กœ์ปฌ ๊ฒ€์ƒ‰ ์‹คํŒจ: {result}")
541
+ continue
542
+ collected.extend(result)
543
+
544
+ elif self.retriever and IndexType.CASE in index_types:
545
+ for raw in self.retriever.search(query, top_k=max(3, top_k_per_type)):
546
+ collected.append(
547
+ SearchResult(
548
+ doc_id=raw.get("id", raw.get("doc_id", "")),
549
+ source_type=IndexType.CASE,
550
+ title=raw.get("category", "์œ ์‚ฌ ๋ฏผ์› ์‚ฌ๋ก€"),
551
+ content=(raw.get("complaint", "") + "\n" + raw.get("answer", "")).strip(),
552
+ score=raw.get("score", 0.0),
553
+ reliability_score=raw.get("reliability_score", 1.0),
554
+ metadata={
555
+ "complaint": raw.get("complaint", ""),
556
+ "answer": raw.get("answer", ""),
557
+ "category": raw.get("category", ""),
558
+ },
559
+ )
560
+ )
561
+
562
+ return collected
563
+
564
+ @staticmethod
565
+ def _format_evidence_items(evidence_dict: Dict[str, Any]) -> str:
566
+ """EvidenceEnvelope dict๋ฅผ ์†Œ๋น„ํ•˜์—ฌ ์ถœ์ฒ˜ ๋ชฉ๋ก ํ…์ŠคํŠธ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
567
+
568
+ EvidenceItem์ด ์žˆ์œผ๋ฉด source-specific branching ์—†์ด ๋‹จ์ผ ํฌ๋งคํ„ฐ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค.
569
+ """
570
+ items = evidence_dict.get("items", [])
571
+ if not items:
572
+ return ""
573
+
574
+ lines: list[str] = []
575
+ for idx, item in enumerate(items[:10], start=1):
576
+ source_type = item.get("source_type", "")
577
+ title = item.get("title", "")
578
+ link = item.get("link_or_path", "")
579
+ page = item.get("page")
580
+
581
+ if source_type == "rag":
582
+ loc = link or title or "๋กœ์ปฌ ๋ฌธ์„œ"
583
+ if page:
584
+ lines.append(f"[{idx}] {loc} (p.{page})")
585
+ else:
586
+ lines.append(f"[{idx}] {loc}")
587
+ elif source_type == "api":
588
+ label = title or "์™ธ๋ถ€ API ๊ฒฐ๊ณผ"
589
+ if link:
590
+ lines.append(f"[{idx}] {label} - {link}")
591
+ else:
592
+ lines.append(f"[{idx}] {label}")
593
+ else:
594
+ label = title or "์ƒ์„ฑ ์ฐธ์กฐ"
595
+ if link:
596
+ lines.append(f"[{idx}] {label} - {link}")
597
+ else:
598
+ lines.append(f"[{idx}] {label}")
599
+
600
+ return "\n".join(lines)
601
+
602
+ def _summarize_evidence(
603
+ self,
604
+ search_results: List[SearchResult],
605
+ api_lookup_data: Dict[str, Any],
606
+ ) -> str:
607
+ # EvidenceEnvelope๊ฐ€ ์žˆ์œผ๋ฉด ์šฐ์„  ์‚ฌ์šฉ
608
+ evidence = api_lookup_data.get("evidence")
609
+ if isinstance(evidence, dict) and evidence.get("items"):
610
+ lines = ["๊ทผ๊ฑฐ ์š”์•ฝ"]
611
+ rag_items = [i for i in evidence["items"] if i.get("source_type") == "rag"]
612
+ api_items = [i for i in evidence["items"] if i.get("source_type") == "api"]
613
+ if rag_items:
614
+ titles = ", ".join(i["title"] for i in rag_items[:3] if i.get("title"))
615
+ lines.append(
616
+ f"- ๋กœ์ปฌ ๋ฌธ์„œ {len(rag_items)}๊ฑด์„ ์ฐธ๊ณ ํ–ˆ์Šต๋‹ˆ๋‹ค."
617
+ + (f" ์ฃผ์š” ๋ฌธ์„œ: {titles}" if titles else "")
618
+ )
619
+ if api_items:
620
+ titles = ", ".join(i["title"] for i in api_items[:3] if i.get("title"))
621
+ lines.append(
622
+ f"- ์™ธ๋ถ€ ๋ฏผ์›๋ถ„์„ API์—์„œ ์œ ์‚ฌ ์‚ฌ๋ก€ {len(api_items)}๊ฑด์„ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค."
623
+ + (f" ๋Œ€ํ‘œ ์‚ฌ๋ก€: {titles}" if titles else "")
624
+ )
625
+ if len(lines) == 1:
626
+ lines.append(
627
+ "- ๋‚ด๋ถ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ ์ถฉ๋ถ„ํžˆ ํ™•๋ณดํ•˜์ง€ ๋ชปํ•ด ์ผ๋ฐ˜ ํ–‰์ • ์‘๋Œ€ ์›์น™ ๊ธฐ์ค€์œผ๋กœ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค."
628
+ )
629
+ return "\n".join(lines)
630
+
631
+ # Legacy ํฌ๋งคํ„ฐ (EvidenceItem ์—†์„ ๋•Œ)
632
+ lines = ["๊ทผ๊ฑฐ ์š”์•ฝ"]
633
+
634
+ if search_results:
635
+ titles = ", ".join(result.title for result in search_results[:3] if result.title)
636
+ lines.append(
637
+ f"- ๋กœ์ปฌ ๋ฌธ์„œ {len(search_results)}๊ฑด์„ ์ฐธ๊ณ ํ–ˆ์Šต๋‹ˆ๋‹ค."
638
+ + (f" ์ฃผ์š” ๋ฌธ์„œ: {titles}" if titles else "")
639
+ )
640
+
641
+ api_results = api_lookup_data.get("results", [])
642
+ if api_results:
643
+ titles = []
644
+ for item in api_results[:3]:
645
+ title = item.get("title") or item.get("qnaTitle") or item.get("question")
646
+ if title:
647
+ titles.append(title)
648
+ lines.append(
649
+ f"- ์™ธ๋ถ€ ๋ฏผ์›๋ถ„์„ API์—์„œ ์œ ์‚ฌ ์‚ฌ๋ก€ {len(api_results)}๊ฑด์„ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค."
650
+ + (f" ๋Œ€ํ‘œ ์‚ฌ๋ก€: {', '.join(titles)}" if titles else "")
651
+ )
652
+
653
+ if len(lines) == 1:
654
+ lines.append(
655
+ "- ๋‚ด๋ถ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ ์ถฉ๋ถ„ํžˆ ํ™•๋ณดํ•˜์ง€ ๋ชปํ•ด ์ผ๋ฐ˜ ํ–‰์ • ์‘๋Œ€ ์›์น™ ๊ธฐ์ค€์œผ๋กœ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค."
656
+ )
657
+
658
+ return "\n".join(lines)
659
+
660
+ @staticmethod
661
+ def _rag_source_line(index: int, item: Dict[str, Any]) -> str:
662
+ metadata = item.get("metadata", {}) or {}
663
+ location = (
664
+ metadata.get("file_path")
665
+ or metadata.get("source_path")
666
+ or metadata.get("path")
667
+ or metadata.get("source")
668
+ or item.get("title")
669
+ or item.get("doc_id")
670
+ or "๋กœ์ปฌ ๋ฌธ์„œ"
671
+ )
672
+ page = metadata.get("page") or metadata.get("page_number") or metadata.get("page_no")
673
+ if page:
674
+ return f"[{index}] {location} (p.{page})"
675
+ return f"[{index}] {location}"
676
+
677
+ @staticmethod
678
+ def _api_source_line(index: int, item: Dict[str, Any]) -> str:
679
+ title = item.get("title") or item.get("qnaTitle") or item.get("question") or "์™ธ๋ถ€ API ๊ฒฐ๊ณผ"
680
+ url = item.get("url") or item.get("detailUrl") or ""
681
+ if url:
682
+ return f"[{index}] {title} - {url}"
683
+ return f"[{index}] {title}"
684
+
685
+ def _build_evidence_section(
686
+ self,
687
+ session: SessionContext,
688
+ current_query: str,
689
+ rag_data: Dict[str, Any],
690
+ api_data: Dict[str, Any],
691
+ ) -> str:
692
+ _, previous_answer = self._latest_prior_turns(session, current_query)
693
+ lines = ["๊ทผ๊ฑฐ/์ถœ์ฒ˜"]
694
+ cursor = 1
695
+
696
+ # EvidenceEnvelope๊ฐ€ ์žˆ์œผ๋ฉด ๋‹จ์ผ ํฌ๋งคํ„ฐ๋กœ ์šฐ์„  ์ฒ˜๋ฆฌ
697
+ rag_evidence = rag_data.get("evidence")
698
+ api_evidence = api_data.get("evidence")
699
+
700
+ if rag_evidence and isinstance(rag_evidence, dict) and rag_evidence.get("items"):
701
+ for item in rag_evidence["items"][:5]:
702
+ source_type = item.get("source_type", "rag")
703
+ if source_type == "rag":
704
+ link = item.get("link_or_path", "")
705
+ page = item.get("page")
706
+ loc = link or item.get("title", "") or "๋กœ์ปฌ ๋ฌธ์„œ"
707
+ if page:
708
+ lines.append(f"[{cursor}] {loc} (p.{page})")
709
+ else:
710
+ lines.append(f"[{cursor}] {loc}")
711
+ cursor += 1
712
+ else:
713
+ # Legacy RAG ํฌ๋งคํ„ฐ
714
+ for item in rag_data.get("results", [])[:5]:
715
+ lines.append(self._rag_source_line(cursor, item))
716
+ cursor += 1
717
+
718
+ if api_evidence and isinstance(api_evidence, dict) and api_evidence.get("items"):
719
+ for item in api_evidence["items"][:5]:
720
+ title = item.get("title", "") or "์™ธ๋ถ€ API ๊ฒฐ๊ณผ"
721
+ link = item.get("link_or_path", "")
722
+ if link:
723
+ lines.append(f"[{cursor}] {title} - {link}")
724
+ else:
725
+ lines.append(f"[{cursor}] {title}")
726
+ cursor += 1
727
+ else:
728
+ # Legacy API ํฌ๋งคํ„ฐ
729
+ api_items = api_data.get("citations") or api_data.get("results") or []
730
+ for item in api_items[:5]:
731
+ lines.append(self._api_source_line(cursor, item))
732
+ cursor += 1
733
+
734
+ if cursor == 1:
735
+ lines.append("- ๊ฒ€์ƒ‰ ๊ฐ€๋Šฅํ•œ ๊ทผ๊ฑฐ๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.")
736
+
737
+ section = "\n".join(lines)
738
+ if previous_answer:
739
+ return f"{previous_answer}\n\n{section}"
740
+ return section
741
+
742
+ async def _prepare_civil_response_generation(
743
+ self,
744
+ request: GenerateCivilResponseRequest,
745
+ flags: Optional[FeatureFlags] = None,
746
+ external_cases: Optional[List[dict]] = None,
747
+ ) -> PreparedGeneration:
748
+ effective_flags = flags or self.feature_flags
749
+ query = self._escape_special_tokens(self._extract_query(request.prompt))
750
+ search_results: List[SearchResult] = []
751
+
752
+ if request.use_rag and effective_flags.use_rag_pipeline:
753
+ search_results = await self._retrieve_search_results(
754
+ query,
755
+ [IndexType.CASE, IndexType.LAW, IndexType.MANUAL, IndexType.NOTICE],
756
+ )
757
+
758
+ retrieved_cases = self._search_results_to_cases(search_results)
759
+ if external_cases:
760
+ retrieved_cases.extend(external_cases)
761
+
762
+ safe_message = self._escape_special_tokens(request.prompt)
763
+ sections = []
764
+ if search_results:
765
+ sections.append(
766
+ self._build_search_result_context(
767
+ search_results,
768
+ "### ๋ฏผ์› ๋‹ต๋ณ€ ์ฐธ๊ณ  ์ž๋ฃŒ (์‚ฌ๋ก€/๋ฒ•๋ฅ /๋งค๋‰ด์–ผ/๊ณต์‹œ์ •๋ณด):",
769
+ )
770
+ )
771
+ if retrieved_cases:
772
+ sections.append(self._build_rag_context(retrieved_cases[:5]))
773
+ sections.append(
774
+ "์œ„ ๊ทผ๊ฑฐ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ๋ฏผ์›์ธ์˜ ๋ถˆํŽธ์— ๊ณต๊ฐํ•˜๊ณ , ํ˜„์žฌ ์กฐ์น˜ ์ƒํ™ฉ๊ณผ ์ฒ˜๋ฆฌ ์ ˆ์ฐจ๋ฅผ ํฌํ•จํ•œ ํšŒ์‹  ์ดˆ์•ˆ์„ ์ž‘์„ฑํ•˜์„ธ์š”."
775
+ )
776
+ sections.append(safe_message)
777
+ augmented_prompt = self._build_persona_prompt(
778
+ "generator_civil_response",
779
+ "\n\n".join(section for section in sections if section),
780
+ )
781
+
782
+ gen_defaults = runtime_config.generation
783
+ sampling_params = SamplingParams(
784
+ temperature=request.temperature,
785
+ top_p=request.top_p,
786
+ max_tokens=request.max_tokens,
787
+ stop=request.stop or gen_defaults.stop_sequences,
788
+ repetition_penalty=gen_defaults.repetition_penalty,
789
+ )
790
+
791
+ return PreparedGeneration(
792
+ prompt=augmented_prompt,
793
+ sampling_params=sampling_params,
794
+ retrieved_cases=retrieved_cases[:5],
795
+ search_results=search_results,
796
+ )
797
+
798
+ async def _run_engine(
799
+ self,
800
+ prompt: str,
801
+ sampling_params: SamplingParams,
802
+ request_id: str,
803
+ lora_request=None,
804
+ ):
805
+ if self.engine is None:
806
+ return None
807
+
808
+ result = self.engine.generate(
809
+ prompt, sampling_params, request_id, lora_request=lora_request
810
+ )
811
+ if hasattr(result, "__aiter__"):
812
+ final_output = None
813
+ async for output in result:
814
+ final_output = output
815
+ return final_output
816
+ return await result
817
+
818
+ async def generate(
819
+ self,
820
+ request: GenerateRequest,
821
+ request_id: str,
822
+ flags: Optional[FeatureFlags] = None,
823
+ ) -> tuple[Any, List[dict]]:
824
+ output, retrieved_cases, _ = await self.generate_civil_response(request, request_id, flags)
825
+ return output, retrieved_cases
826
+
827
+ async def generate_civil_response(
828
+ self,
829
+ request: GenerateCivilResponseRequest,
830
+ request_id: str,
831
+ flags: Optional[FeatureFlags] = None,
832
+ external_cases: Optional[List[dict]] = None,
833
+ lora_request=None,
834
+ ) -> tuple[Any, List[dict], List[SearchResult]]:
835
+ prepared = await self._prepare_civil_response_generation(request, flags, external_cases)
836
+ output = await self._run_engine(
837
+ prepared.prompt, prepared.sampling_params, request_id, lora_request=lora_request
838
+ )
839
+ return output, prepared.retrieved_cases, prepared.search_results
840
+
841
+ async def generate_stream(
842
+ self,
843
+ request: GenerateRequest,
844
+ request_id: str,
845
+ flags: Optional[FeatureFlags] = None,
846
+ ) -> tuple[Any, List[dict], List[SearchResult]]:
847
+ prepared = await self._prepare_civil_response_generation(request, flags)
848
+ if self.engine is None:
849
+ raise RuntimeError("๋ชจ๋ธ ์—”์ง„์ด ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
850
+ if hasattr(self.engine, "stream"):
851
+ stream = self.engine.stream(prepared.prompt, prepared.sampling_params, request_id)
852
+ else:
853
+ stream = self.engine.generate(prepared.prompt, prepared.sampling_params, request_id)
854
+ return stream, prepared.retrieved_cases, prepared.search_results
855
+
856
+ def _init_agent_loop(self) -> None:
857
+ from src.inference.actions.data_go_kr import MinwonAnalysisAction
858
+
859
+ engine_ref = self
860
+ minwon_action = MinwonAnalysisAction()
861
+
862
+ async def _rag_search_tool(query: str, context: dict, session: SessionContext) -> dict:
863
+ working_query = query.strip()
864
+ search_results = await engine_ref._retrieve_search_results(
865
+ working_query,
866
+ [IndexType.CASE, IndexType.LAW, IndexType.MANUAL, IndexType.NOTICE],
867
+ )
868
+ return {
869
+ "query": working_query,
870
+ "count": len(search_results),
871
+ "results": [result.model_dump() for result in search_results],
872
+ "context_text": engine_ref._build_search_result_context(
873
+ search_results,
874
+ "### ๋กœ์ปฌ ๋ฌธ์„œ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ:",
875
+ ),
876
+ }
877
+
878
+ async def _api_lookup_tool(query: str, context: dict, session: SessionContext) -> dict:
879
+ working_query = query.strip()
880
+ payload = await minwon_action.fetch_similar_cases(
881
+ working_query,
882
+ {
883
+ **context,
884
+ "session_context": session.build_context_summary(),
885
+ },
886
+ )
887
+ results = payload["results"] or []
888
+ return {
889
+ "query": payload["query"],
890
+ "count": len(results),
891
+ "results": results,
892
+ "context_text": payload["context_text"],
893
+ "citations": [citation.to_dict() for citation in payload["citations"]],
894
+ "source": "data.go.kr",
895
+ }
896
+
897
+ async def _draft_civil_response_tool(
898
+ query: str,
899
+ context: dict,
900
+ session: SessionContext,
901
+ ) -> dict:
902
+ working_query = engine_ref._build_working_query(query, session)
903
+ api_lookup_data = context.get(ToolType.API_LOOKUP.value, {})
904
+
905
+ external_cases = []
906
+ for item in api_lookup_data.get("results", [])[:3]:
907
+ complaint = (
908
+ item.get("content") or item.get("qnaContent") or item.get("question", "")
909
+ )
910
+ answer = item.get("answer") or item.get("qnaAnswer") or item.get("title", "")
911
+ if complaint or answer:
912
+ external_cases.append(
913
+ {
914
+ "complaint": complaint,
915
+ "answer": answer,
916
+ "score": float(item.get("score", 0.0)),
917
+ }
918
+ )
919
+
920
+ # Multi-LoRA: civil ์–ด๋Œ‘ํ„ฐ๊ฐ€ ์„ค์ •๋˜์–ด ์žˆ์œผ๋ฉด LoRARequest ์ƒ์„ฑ
921
+ civil_adapter_path = runtime_config.model.adapter_paths.get("civil")
922
+ lora_req = None
923
+ if civil_adapter_path and LoRARequest is not None:
924
+ lora_req = LoRARequest("civil", _LORA_ID_MAP["civil"], civil_adapter_path)
925
+
926
+ gen_request = GenerateCivilResponseRequest(
927
+ prompt=working_query,
928
+ max_tokens=512,
929
+ temperature=0.7,
930
+ use_rag=True,
931
+ )
932
+ request_id = str(uuid.uuid4())
933
+ final_output, retrieved_cases, search_results = (
934
+ await engine_ref.generate_civil_response(
935
+ gen_request,
936
+ request_id,
937
+ external_cases=external_cases,
938
+ lora_request=lora_req,
939
+ )
940
+ )
941
+ if final_output is None:
942
+ return {"text": "", "error": "๋ฏผ์› ๋‹ต๋ณ€ ์ƒ์„ฑ ์‹คํŒจ"}
943
+
944
+ draft_text = engine_ref._strip_thought_blocks(final_output.outputs[0].text)
945
+ text = (
946
+ engine_ref._summarize_evidence(search_results, api_lookup_data)
947
+ + "\n\n์ตœ์ข… ์ดˆ์•ˆ\n"
948
+ + draft_text
949
+ )
950
+ return {
951
+ "text": text,
952
+ "draft_text": draft_text,
953
+ "retrieved_cases": retrieved_cases,
954
+ "search_results": [result.model_dump() for result in search_results],
955
+ "prompt_tokens": len(final_output.prompt_token_ids),
956
+ "completion_tokens": len(final_output.outputs[0].token_ids),
957
+ }
958
+
959
+ async def _append_evidence_tool(
960
+ query: str,
961
+ context: dict,
962
+ session: SessionContext,
963
+ ) -> dict:
964
+ rag_data = context.get(ToolType.RAG_SEARCH.value, {})
965
+ api_data = context.get(ToolType.API_LOOKUP.value, {})
966
+
967
+ # ๊ธฐ์กด evidence ํ…์ŠคํŠธ (fallback์šฉ)
968
+ fallback_text = engine_ref._build_evidence_section(session, query, rag_data, api_data)
969
+
970
+ # LLM์œผ๋กœ evidence ๋ณด๊ฐ• ์‹œ๋„
971
+ enhanced_text = fallback_text
972
+ if engine_ref.engine is not None:
973
+ try:
974
+ _, previous_answer = engine_ref._latest_prior_turns(session, query)
975
+ existing_response = engine_ref._escape_special_tokens(previous_answer or "")
976
+ rag_context = engine_ref._escape_special_tokens(
977
+ rag_data.get("context_text", "")
978
+ )
979
+ api_context = ""
980
+ for item in api_data.get("results", [])[:3]:
981
+ title = item.get("title", "")
982
+ content = item.get("content", "") or item.get("qnaContent", "")
983
+ if title or content:
984
+ api_context += (
985
+ f"- {engine_ref._escape_special_tokens(title)}"
986
+ f": {engine_ref._escape_special_tokens(content[:200])}\n"
987
+ )
988
+
989
+ evidence_prompt = (
990
+ "[|system|]๋‹น์‹ ์€ ๋Œ€ํ•œ๋ฏผ๊ตญ ๊ณต๋ฌด์› ๋ฏผ์› ๋‹ต๋ณ€ ๋ณด๊ฐ• ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค. "
991
+ "๋ฒ•์  ๊ทผ๊ฑฐ์™€ ๊ด€๋ จ ๊ทœ์ •์„ ์ •ํ™•ํ•˜๊ฒŒ ์ธ์šฉํ•˜์—ฌ evidence ์„น์…˜์„ ์ž‘์„ฑํ•˜์„ธ์š”."
992
+ "[|endofturn|]\n"
993
+ "[|user|]๋‹ค์Œ ๋ฏผ์› ๋‹ต๋ณ€๏ฟฝ๏ฟฝ๏ฟฝ ๋Œ€ํ•ด ๋ฒ•์  ๊ทผ๊ฑฐ์™€ ๊ด€๋ จ ๊ทœ์ •์„ ๋ณด๊ฐ•ํ•˜์—ฌ "
994
+ "evidence ์„น์…˜์„ ์ž‘์„ฑํ•˜์„ธ์š”.\n\n"
995
+ f"[๊ธฐ์กด ๋‹ต๋ณ€]\n{existing_response[:800]}\n\n"
996
+ f"[๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ]\n{rag_context[:800]}\n\n"
997
+ f"[API ์กฐํšŒ ๊ฒฐ๊ณผ]\n{api_context[:800]}"
998
+ "[|endofturn|]\n[|assistant|]"
999
+ )
1000
+
1001
+ # legal ์–ด๋Œ‘ํ„ฐ LoRA ์„ค์ •
1002
+ legal_adapter_path = runtime_config.model.adapter_paths.get("legal")
1003
+ lora_req = None
1004
+ if legal_adapter_path and LoRARequest is not None:
1005
+ lora_req = LoRARequest("legal", _LORA_ID_MAP["legal"], legal_adapter_path)
1006
+
1007
+ if SamplingParams is not None:
1008
+ sp = SamplingParams(
1009
+ max_tokens=512,
1010
+ temperature=0.5,
1011
+ top_p=0.9,
1012
+ stop=["[|endofturn|]"],
1013
+ )
1014
+ request_id = str(uuid.uuid4())
1015
+ output = await engine_ref._run_engine(
1016
+ evidence_prompt, sp, request_id, lora_request=lora_req
1017
+ )
1018
+ if output is not None and output.outputs:
1019
+ enhanced_text = engine_ref._strip_thought_blocks(output.outputs[0].text)
1020
+ except Exception as exc:
1021
+ logger.warning(f"Evidence LLM ๋ณด๊ฐ• ์‹คํŒจ, fallback ์‚ฌ์šฉ: {exc}")
1022
+ enhanced_text = fallback_text
1023
+
1024
+ return {
1025
+ "text": enhanced_text,
1026
+ "rag_results": rag_data.get("results", []),
1027
+ "api_citations": api_data.get("citations", []),
1028
+ }
1029
+
1030
+ tool_registry = {
1031
+ ToolType.RAG_SEARCH: _rag_search_tool,
1032
+ ToolType.API_LOOKUP: _api_lookup_tool,
1033
+ ToolType.DRAFT_CIVIL_RESPONSE: _draft_civil_response_tool,
1034
+ ToolType.APPEND_EVIDENCE: _append_evidence_tool,
1035
+ }
1036
+ self.agent_loop = AgentLoop(tool_registry=tool_registry)
1037
+
1038
+ def _build_tool_registry(self) -> Dict[str, Any]:
1039
+ """CapabilityBase ๊ธฐ๋ฐ˜ MVP tool registry๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
1040
+
1041
+ build_mvp_registry()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋‹จ์ผ ์†Œ์Šค์—์„œ registry๋ฅผ ๊ตฌ์„ฑํ•œ๋‹ค.
1042
+ planner metadata์™€ executor binding์ด ๋™์ผํ•œ CapabilityBase ์ธ์Šคํ„ด์Šค์—์„œ ๋‚˜์˜จ๋‹ค.
1043
+ AgentLoop ํ•˜์œ„ํ˜ธํ™˜: AgentLoop._tools์—์„œ closure๋ฅผ ์ถ”์ถœํ•˜์—ฌ wrapper๋กœ ๋ž˜ํ•‘ํ•œ๋‹ค.
1044
+ """
1045
+ if self.agent_loop is None:
1046
+ return {}
1047
+
1048
+ from src.inference.graph.capabilities.registry import build_mvp_registry
1049
+
1050
+ # AgentLoop์˜ tool_registry์—์„œ ๊ธฐ์กด closure๋ฅผ ์ถ”์ถœ
1051
+ raw_tools = {
1052
+ str(k.value if hasattr(k, "value") else k): v for k, v in self.agent_loop._tools.items()
1053
+ }
1054
+
1055
+ return build_mvp_registry(
1056
+ rag_search_fn=raw_tools.get("rag_search", _noop_tool),
1057
+ api_lookup_action=self._get_api_lookup_action(),
1058
+ draft_civil_response_fn=raw_tools.get("draft_civil_response", _noop_tool),
1059
+ append_evidence_fn=raw_tools.get("append_evidence", _noop_tool),
1060
+ )
1061
+
1062
+ def _get_api_lookup_action(self) -> Any:
1063
+ """AgentLoop์— ๋“ฑ๋ก๋œ api_lookup์˜ MinwonAnalysisAction์„ ์ถ”์ถœํ•œ๋‹ค."""
1064
+ if self.agent_loop is None:
1065
+ return None
1066
+ tool_fn = self.agent_loop._tools.get(ToolType.API_LOOKUP)
1067
+ # ApiLookupCapability์ธ ๊ฒฝ์šฐ action์„ ์ง์ ‘ ์ถ”์ถœ
1068
+ if hasattr(tool_fn, "_action"):
1069
+ return tool_fn._action
1070
+ # closure์ธ ๊ฒฝ์šฐ action์„ ์ถ”์ถœํ•  ์ˆ˜ ์—†์œผ๋ฏ€๋กœ None ๋ฐ˜ํ™˜
1071
+ # (MinwonAnalysisAction์€ _init_agent_loop์—์„œ ์ƒˆ๋กœ ์ƒ์„ฑํ•œ๋‹ค)
1072
+ try:
1073
+ from src.inference.actions.data_go_kr import MinwonAnalysisAction
1074
+
1075
+ return MinwonAnalysisAction()
1076
+ except Exception:
1077
+ return None
1078
+
1079
+ def _init_graph_with_async_checkpointer(self, checkpointer: object) -> None:
1080
+ """lifespan์—์„œ AsyncSqliteSaver๊ฐ€ ์ค€๋น„๋œ ํ›„ graph๋ฅผ ์žฌ๊ตฌ์„ฑํ•œ๋‹ค."""
1081
+ self._init_graph(checkpointer=checkpointer)
1082
+
1083
+ def _init_graph(self, checkpointer: Optional[object] = None) -> None:
1084
+ """LangGraph StateGraph๋ฅผ ์ดˆ๊ธฐํ™”ํ•œ๋‹ค.
1085
+
1086
+ ์šด์˜ ํ™˜๊ฒฝ์—์„œ๋Š” LLMPlannerAdapter(vLLM OpenAI-compatible endpoint)๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.
1087
+ SKIP_MODEL_LOAD=true ํ™˜๊ฒฝ(CI/ํ…Œ์ŠคํŠธ)์—์„œ๋Š” RegexPlannerAdapter๊ฐ€ CI fallback์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.
1088
+ RegistryExecutorAdapter๋Š” ๊ธฐ์กด tool_registry๋ฅผ ์žฌ์‚ฌ์šฉํ•œ๋‹ค.
1089
+
1090
+ Parameters
1091
+ ----------
1092
+ checkpointer : optional
1093
+ ์™ธ๋ถ€์—์„œ ์ฃผ์ž…ํ•  LangGraph checkpointer.
1094
+ None์ด๋ฉด SqliteSaver(๋™๊ธฐ sqlite3)๋ฅผ ์‹œ๋„ํ•˜๊ณ ,
1095
+ import ์‹คํŒจ ์‹œ MemorySaver๋กœ fallbackํ•œ๋‹ค.
1096
+ SqliteSaver DB ๊ฒฝ๋กœ๋Š” SessionStore DB์™€ ๊ฐ™์€ ๋””๋ ‰ํ„ฐ๋ฆฌ์—
1097
+ ``langgraph_checkpoints.db``๋กœ ์ƒ์„ฑ๋œ๋‹ค (๊ด€์‹ฌ์‚ฌ ๋ถ„๋ฆฌ).
1098
+ """
1099
+ try:
1100
+ from src.inference.graph.builder import build_govon_graph
1101
+ from src.inference.graph.executor_adapter import RegistryExecutorAdapter
1102
+ from src.inference.graph.planner_adapter import LLMPlannerAdapter
1103
+ except ImportError as exc:
1104
+ logger.warning(f"LangGraph graph ์ดˆ๊ธฐํ™” ์‹คํŒจ (import ์˜ค๋ฅ˜): {exc}")
1105
+ return
1106
+
1107
+ tool_registry = self._build_tool_registry()
1108
+
1109
+ if SKIP_MODEL_LOAD:
1110
+ # CI/ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ: LLM์ด ์—†์œผ๋ฏ€๋กœ RegexPlannerAdapter๋ฅผ CI fallback์œผ๋กœ ์‚ฌ์šฉ
1111
+ from src.inference.graph.planner_adapter import RegexPlannerAdapter
1112
+
1113
+ planner = RegexPlannerAdapter(registry=tool_registry)
1114
+ else:
1115
+ # ์šด์˜ ํ™˜๊ฒฝ: vLLM OpenAI-compatible endpoint๋ฅผ LLMPlannerAdapter๋กœ ์—ฐ๊ฒฐ
1116
+ # NOTE: ChatOpenAI๋Š” lazy connection์ด๋ฏ€๋กœ ์ƒ์„ฑ ์‹œ์ ์— vLLM์ด ๋ฏธ์‹œ์ž‘์ด์–ด๋„ ์•ˆ์ „ํ•˜๋‹ค.
1117
+ # ์‹ค์ œ LLM ํ˜ธ์ถœ์€ graph invoke ์‹œ์ ์— ๋ฐœ์ƒํ•˜๋ฉฐ,
1118
+ # ๊ทธ๋•Œ๋Š” lifespan์—์„œ vLLM์ด ์ด๋ฏธ ์‹œ์ž‘๋œ ์ƒํƒœ๋‹ค.
1119
+ from langchain_openai import ChatOpenAI
1120
+
1121
+ planner_base_url = os.getenv(
1122
+ "LANGGRAPH_MODEL_BASE_URL",
1123
+ f"http://127.0.0.1:{runtime_config.port}/v1",
1124
+ )
1125
+ planner_api_key = os.getenv("LANGGRAPH_MODEL_API_KEY", "EMPTY")
1126
+ planner_model = os.getenv("LANGGRAPH_PLANNER_MODEL", runtime_config.model.model_path)
1127
+ llm = ChatOpenAI(
1128
+ base_url=planner_base_url,
1129
+ api_key=planner_api_key,
1130
+ model=planner_model,
1131
+ temperature=0.0,
1132
+ )
1133
+ planner = LLMPlannerAdapter(llm=llm, registry=tool_registry)
1134
+ executor = RegistryExecutorAdapter(
1135
+ tool_registry=tool_registry,
1136
+ session_store=self.session_store,
1137
+ )
1138
+
1139
+ # checkpointer๊ฐ€ ์™ธ๋ถ€์—์„œ ์ฃผ์ž…๋˜์ง€ ์•Š์œผ๋ฉด SqliteSaver๋ฅผ ์‹œ๋„ํ•œ๋‹ค.
1140
+ # SqliteSaver๋Š” ํ”„๋กœ์„ธ์Šค ์žฌ์‹œ์ž‘ ํ›„์—๋„ interrupt ์ƒํƒœ๋ฅผ ๋ณต์›ํ•˜๋ฏ€๋กœ
1141
+ # MemorySaver์™€ ๋‹ฌ๋ฆฌ ์žฌ์‹œ์ž‘-์•ˆ์ „(restart-safe)ํ•˜๋‹ค.
1142
+ if checkpointer is None:
1143
+ checkpointer, conn = _build_sync_sqlite_checkpointer(self.session_store.db_path)
1144
+ # ์ด์ „ ๋™๊ธฐ connection์ด ์žˆ์œผ๋ฉด ๋‹ซ์•„ leak์„ ๋ฐฉ์ง€ํ•œ๋‹ค.
1145
+ if self._sync_checkpointer_conn is not None:
1146
+ try:
1147
+ self._sync_checkpointer_conn.close()
1148
+ except Exception:
1149
+ pass
1150
+ self._sync_checkpointer_conn = conn
1151
+
1152
+ self.graph = build_govon_graph(
1153
+ planner_adapter=planner,
1154
+ executor_adapter=executor,
1155
+ session_store=self.session_store,
1156
+ checkpointer=checkpointer,
1157
+ )
1158
+ logger.info("LangGraph graph ์ดˆ๊ธฐํ™” ์™„๋ฃŒ")
1159
+
1160
+
1161
+ def _build_sync_sqlite_checkpointer(
1162
+ session_db_path: str,
1163
+ ) -> tuple:
1164
+ """SqliteSaver(๋™๊ธฐ) ๋˜๋Š” MemorySaver(fallback)๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
1165
+
1166
+ LangGraph checkpointer์šฉ SQLite DB๋Š” SessionStore์˜ sessions.sqlite3์™€
1167
+ ๊ฐ™์€ ๋””๋ ‰ํ„ฐ๋ฆฌ์— ๋ณ„๋„ ํŒŒ์ผ ``langgraph_checkpoints.db``๋กœ ์ƒ์„ฑํ•œ๋‹ค.
1168
+ ๋‘ DB๋ฅผ ๋ถ„๋ฆฌํ•จ์œผ๋กœ์จ ๊ด€์‹ฌ์‚ฌ(์„ธ์…˜ ๋ฉ”ํƒ€ vs. graph ์ฒดํฌํฌ์ธํŠธ)๋ฅผ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„ํ•œ๋‹ค.
1169
+
1170
+ SqliteSaver๋Š” ํ”„๋กœ์„ธ์Šค ์žฌ์‹œ์ž‘ ํ›„์—๋„ interrupt ์ƒํƒœ๋ฅผ SQLite์—์„œ ๋ณต์›ํ•˜๋ฏ€๋กœ
1171
+ MemorySaver์™€ ๋‹ฌ๋ฆฌ ์žฌ์‹œ์ž‘-์•ˆ์ „(restart-safe)ํ•˜๋‹ค.
1172
+
1173
+ Parameters
1174
+ ----------
1175
+ session_db_path : str
1176
+ SessionStore๊ฐ€ ์‚ฌ์šฉ ์ค‘์ธ sessions.sqlite3 ํŒŒ์ผ ๊ฒฝ๋กœ.
1177
+ ์ด ๊ฒฝ๋กœ์˜ ๋ถ€๋ชจ ๋””๋ ‰ํ„ฐ๋ฆฌ์— langgraph_checkpoints.db๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
1178
+
1179
+ Returns
1180
+ -------
1181
+ tuple[SqliteSaver | MemorySaver, sqlite3.Connection | None]
1182
+ (checkpointer, conn) ํŠœํ”Œ.
1183
+ SqliteSaver ์‚ฌ์šฉ ์‹œ conn์€ ์—ด๋ฆฐ sqlite3.Connection์ด๋ฉฐ,
1184
+ ํ˜ธ์ถœ์ž๊ฐ€ ์ ์ ˆํ•œ ์‹œ์ ์— closeํ•ด์•ผ ํ•œ๋‹ค.
1185
+ MemorySaver fallback ์‹œ conn์€ None์ด๋‹ค.
1186
+ """
1187
+ cp_db_path = str(Path(session_db_path).parent / "langgraph_checkpoints.db")
1188
+ try:
1189
+ from langgraph.checkpoint.sqlite import SqliteSaver
1190
+
1191
+ conn = __import__("sqlite3").connect(cp_db_path, check_same_thread=False)
1192
+ saver = SqliteSaver(conn)
1193
+ logger.info(f"LangGraph checkpointer: SqliteSaver ({cp_db_path})")
1194
+ return saver, conn
1195
+ except ImportError:
1196
+ logger.warning(
1197
+ "langgraph-checkpoint-sqlite ๋ฏธ์„ค์น˜ โ€” MemorySaver๋กœ fallbackํ•ฉ๋‹ˆ๋‹ค. "
1198
+ "ํ”„๋กœ์„ธ์Šค ์žฌ์‹œ์ž‘ ์‹œ interrupt ์ƒํƒœ๊ฐ€ ์†Œ๋ฉธ๋ฉ๋‹ˆ๋‹ค."
1199
+ )
1200
+ from langgraph.checkpoint.memory import MemorySaver
1201
+
1202
+ return MemorySaver(), None
1203
+
1204
+
1205
+ manager = vLLMEngineManager()
1206
+
1207
+
1208
+ @asynccontextmanager
1209
+ async def lifespan(app: FastAPI):
1210
+ """FastAPI lifespan: ๋ชจ๋ธ/์ธ๋ฑ์Šค ์ดˆ๊ธฐํ™” ๋ฐ AsyncSqliteSaver ์—…๊ทธ๋ ˆ์ด๋“œ.
1211
+
1212
+ startup ๋‹จ๊ณ„์—์„œ AsyncSqliteSaver๊ฐ€ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๋ฉด graph๋ฅผ ์žฌ๊ตฌ์„ฑํ•œ๋‹ค.
1213
+ AsyncSqliteSaver๋Š” async ์ปจํ…์ŠคํŠธ ๋งค๋‹ˆ์ €๋กœ ๊ด€๋ฆฌํ•˜๋ฉฐ, shutdown ์‹œ ์ •๋ฆฌํ•œ๋‹ค.
1214
+ AsyncSqliteSaver import ์‹คํŒจ ์‹œ _init_graph์—์„œ ์ด๋ฏธ ์„ค์ •๋œ
1215
+ SqliteSaver(๋˜๋Š” MemorySaver fallback)๋ฅผ ๊ทธ๋Œ€๋กœ ์œ ์ง€ํ•œ๋‹ค.
1216
+ """
1217
+ await manager.initialize()
1218
+
1219
+ # AsyncSqliteSaver๋กœ graph ์žฌ๊ตฌ์„ฑ ์‹œ๋„ (๋” ๋†’์€ async ์„ฑ๋Šฅ)
1220
+ async_cp_db = str(Path(manager.session_store.db_path).parent / "langgraph_checkpoints.db")
1221
+ try:
1222
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
1223
+
1224
+ async with AsyncSqliteSaver.from_conn_string(async_cp_db) as async_saver:
1225
+ # ๋™๊ธฐ SqliteSaver๊ฐ€ ๋ณด์œ ํ•˜๋˜ connection์„ ๋‹ซ์•„ leak์„ ๋ฐฉ์ง€ํ•œ๋‹ค.
1226
+ if manager._sync_checkpointer_conn is not None:
1227
+ try:
1228
+ manager._sync_checkpointer_conn.close()
1229
+ except Exception:
1230
+ pass
1231
+ manager._sync_checkpointer_conn = None
1232
+ manager._checkpointer_ctx = async_saver
1233
+ manager._init_graph_with_async_checkpointer(async_saver)
1234
+ logger.info(f"LangGraph checkpointer: AsyncSqliteSaver ({async_cp_db})")
1235
+ yield
1236
+ manager._checkpointer_ctx = None
1237
+ except ImportError:
1238
+ logger.info("AsyncSqliteSaver ๋ฏธ์„ค์น˜ โ€” SqliteSaver(๋™๊ธฐ) ๋˜๋Š” MemorySaver๋กœ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.")
1239
+ yield
1240
+
1241
+
1242
+ app = FastAPI(
1243
+ title="GovOn Local Runtime",
1244
+ description="Local FastAPI daemon for the GovOn Agentic Shell MVP.",
1245
+ lifespan=lifespan,
1246
+ )
1247
+
1248
+ ALLOWED_ORIGINS = os.getenv("CORS_ORIGINS", "").split(",")
1249
+ if ALLOWED_ORIGINS and ALLOWED_ORIGINS[0]:
1250
+ app.add_middleware(
1251
+ CORSMiddleware,
1252
+ allow_origins=ALLOWED_ORIGINS,
1253
+ allow_credentials=True,
1254
+ allow_methods=["*"],
1255
+ allow_headers=["*"],
1256
+ )
1257
+
1258
+ if _RATE_LIMIT_AVAILABLE and limiter is not None:
1259
+ app.state.limiter = limiter
1260
+ app.add_middleware(SlowAPIMiddleware)
1261
+
1262
+
1263
+ @app.get("/health")
1264
+ async def health():
1265
+ index_summary = None
1266
+ if manager.index_manager:
1267
+ stats = manager.index_manager.get_index_stats()
1268
+ index_summary = {
1269
+ idx_type: {
1270
+ "loaded": info.get("loaded", False),
1271
+ "doc_count": info.get("doc_count", 0),
1272
+ }
1273
+ for idx_type, info in stats.get("indexes", {}).items()
1274
+ }
1275
+
1276
+ bm25_summary = {}
1277
+ for idx_type in IndexType:
1278
+ indexer = manager.bm25_indexers.get(idx_type)
1279
+ if indexer and indexer.is_ready():
1280
+ bm25_summary[idx_type.value] = {"loaded": True, "doc_count": indexer.doc_count}
1281
+ else:
1282
+ bm25_summary[idx_type.value] = {"loaded": False}
1283
+
1284
+ return {
1285
+ "status": "healthy",
1286
+ "profile": runtime_config.profile.value,
1287
+ "model": runtime_config.model.model_path,
1288
+ "rag_enabled": manager.index_manager is not None or manager.retriever is not None,
1289
+ "agents_loaded": manager.agent_manager.list_agents() if manager.agent_manager else [],
1290
+ "indexes": index_summary,
1291
+ "bm25_indexes": bm25_summary,
1292
+ "hybrid_search_enabled": manager.hybrid_engine is not None,
1293
+ "local_documents": {
1294
+ "enabled": bool(runtime_config.paths.local_docs_root),
1295
+ "root_dir": runtime_config.paths.local_docs_root or None,
1296
+ "last_sync": manager.local_document_sync_status,
1297
+ },
1298
+ "feature_flags": {
1299
+ "use_rag_pipeline": manager.feature_flags.use_rag_pipeline,
1300
+ "model_version": manager.feature_flags.model_version,
1301
+ },
1302
+ "session_store": {
1303
+ "driver": "sqlite",
1304
+ "path": manager.session_store.db_path,
1305
+ },
1306
+ }
1307
+
1308
+
1309
+ def _rate_limit(limit_string: str):
1310
+ if _RATE_LIMIT_AVAILABLE and limiter is not None:
1311
+ return limiter.limit(limit_string)
1312
+
1313
+ def _noop(func):
1314
+ return func
1315
+
1316
+ return _noop
1317
+
1318
+
1319
+ def get_feature_flags(request: Request) -> FeatureFlags:
1320
+ header = request.headers.get("X-Feature-Flag")
1321
+ return manager.feature_flags.override_from_header(header)
1322
+
1323
+
1324
+ @app.post("/v1/generate-civil-response", response_model=GenerateCivilResponseResponse)
1325
+ @_rate_limit("30/minute")
1326
+ async def generate_civil_response(
1327
+ request: GenerateCivilResponseRequest,
1328
+ _: None = Depends(verify_api_key),
1329
+ flags: FeatureFlags = Depends(get_feature_flags),
1330
+ ):
1331
+ if request.stream:
1332
+ raise HTTPException(status_code=400, detail="๋ฏผ์› ๋‹ต๋ณ€ ์ŠคํŠธ๋ฆฌ๋ฐ์€ /v1/stream์„ ์‚ฌ์šฉํ•˜์„ธ์š”.")
1333
+
1334
+ request_id = str(uuid.uuid4())
1335
+ final_output, retrieved_cases, search_results = await manager.generate_civil_response(
1336
+ request,
1337
+ request_id,
1338
+ flags,
1339
+ )
1340
+ if final_output is None:
1341
+ raise HTTPException(status_code=500, detail="๋ฏผ์› ๋‹ต๋ณ€ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.")
1342
+
1343
+ return GenerateCivilResponseResponse(
1344
+ request_id=request_id,
1345
+ complaint_id=request.complaint_id,
1346
+ text=manager._strip_thought_blocks(final_output.outputs[0].text),
1347
+ prompt_tokens=len(final_output.prompt_token_ids),
1348
+ completion_tokens=len(final_output.outputs[0].token_ids),
1349
+ retrieved_cases=[RetrievedCase(**case) for case in retrieved_cases],
1350
+ search_results=search_results,
1351
+ )
1352
+
1353
+
1354
+ @app.post("/v1/generate", response_model=GenerateResponse)
1355
+ @_rate_limit("30/minute")
1356
+ async def generate(
1357
+ request: GenerateRequest,
1358
+ _: None = Depends(verify_api_key),
1359
+ flags: FeatureFlags = Depends(get_feature_flags),
1360
+ ):
1361
+ if request.stream:
1362
+ raise HTTPException(status_code=400, detail="Use /v1/stream for streaming.")
1363
+
1364
+ request_id = str(uuid.uuid4())
1365
+ final_output, retrieved_cases = await manager.generate(request, request_id, flags)
1366
+ if final_output is None:
1367
+ raise HTTPException(status_code=500, detail="Generation failed.")
1368
+
1369
+ return GenerateResponse(
1370
+ request_id=request_id,
1371
+ complaint_id=request.complaint_id,
1372
+ text=manager._strip_thought_blocks(final_output.outputs[0].text),
1373
+ prompt_tokens=len(final_output.prompt_token_ids),
1374
+ completion_tokens=len(final_output.outputs[0].token_ids),
1375
+ retrieved_cases=[RetrievedCase(**case) for case in retrieved_cases],
1376
+ )
1377
+
1378
+
1379
+ @app.post("/v1/stream")
1380
+ @_rate_limit("30/minute")
1381
+ async def stream_generate(
1382
+ request: GenerateRequest,
1383
+ _: None = Depends(verify_api_key),
1384
+ flags: FeatureFlags = Depends(get_feature_flags),
1385
+ ):
1386
+ if not request.stream:
1387
+ request.stream = True
1388
+
1389
+ request_id = str(uuid.uuid4())
1390
+ results_stream, retrieved_cases, search_results = await manager.generate_stream(
1391
+ request,
1392
+ request_id,
1393
+ flags,
1394
+ )
1395
+
1396
+ async def stream_results() -> AsyncGenerator[str, None]:
1397
+ cases_data = [RetrievedCase(**case).model_dump() for case in retrieved_cases]
1398
+ search_data = [result.model_dump() for result in search_results]
1399
+
1400
+ async for request_output in results_stream:
1401
+ text = request_output.outputs[0].text
1402
+ finished = request_output.finished
1403
+ if finished:
1404
+ text = manager._strip_thought_blocks(text)
1405
+
1406
+ response_obj = {"request_id": request_id, "text": text, "finished": finished}
1407
+ if finished:
1408
+ response_obj["retrieved_cases"] = cases_data
1409
+ response_obj["search_results"] = search_data
1410
+
1411
+ yield f"data: {json.dumps(response_obj, ensure_ascii=False)}\n\n"
1412
+
1413
+ return StreamingResponse(stream_results(), media_type="text/event-stream")
1414
+
1415
+
1416
+ @app.post("/v1/search", response_model=SearchResponse)
1417
+ @app.post("/search", response_model=SearchResponse)
1418
+ @_rate_limit("60/minute")
1419
+ async def search(request: SearchRequest, _: Request, __: None = Depends(verify_api_key)):
1420
+ start_time = time.monotonic()
1421
+ try:
1422
+ if manager.hybrid_engine:
1423
+ results_raw, actual_mode = await manager.hybrid_engine.search(
1424
+ query=request.query,
1425
+ index_type=request.doc_type,
1426
+ top_k=request.top_k,
1427
+ mode=request.search_mode,
1428
+ )
1429
+ results = [
1430
+ SearchResult(
1431
+ doc_id=result.get("doc_id", ""),
1432
+ source_type=IndexType(result.get("doc_type", request.doc_type.value)),
1433
+ title=result.get("title", ""),
1434
+ content=_extract_content_by_type(result, request.doc_type),
1435
+ score=result.get("score", 0.0),
1436
+ reliability_score=result.get("reliability_score", 1.0),
1437
+ metadata=result.get("extras", {}),
1438
+ chunk_index=result.get("chunk_index", 0),
1439
+ total_chunks=result.get("chunk_total", 1),
1440
+ )
1441
+ for result in results_raw
1442
+ ]
1443
+ elif manager.retriever:
1444
+ raw_results = manager.retriever.search(request.query, top_k=request.top_k)
1445
+ results = [
1446
+ SearchResult(
1447
+ doc_id=raw.get("id", raw.get("doc_id", "")),
1448
+ source_type=request.doc_type,
1449
+ title=raw.get("category", ""),
1450
+ content=raw.get("complaint", "") + "\n" + raw.get("answer", ""),
1451
+ score=raw.get("score", 0.0),
1452
+ reliability_score=raw.get("reliability_score", 1.0),
1453
+ )
1454
+ for raw in raw_results
1455
+ ]
1456
+ actual_mode = SearchMode.DENSE
1457
+ else:
1458
+ raise HTTPException(status_code=503, detail="๊ฒ€์ƒ‰ ์—”์ง„์ด ์•„์ง ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
1459
+
1460
+ elapsed_ms = (time.monotonic() - start_time) * 1000
1461
+ actual_search_mode = actual_mode if actual_mode != request.search_mode else None
1462
+ return SearchResponse(
1463
+ query=request.query,
1464
+ doc_type=request.doc_type,
1465
+ search_mode=request.search_mode,
1466
+ actual_search_mode=actual_search_mode,
1467
+ results=results,
1468
+ total=len(results),
1469
+ search_time_ms=round(elapsed_ms, 2),
1470
+ )
1471
+ except HTTPException:
1472
+ raise
1473
+ except Exception as exc:
1474
+ logger.error(f"๊ฒ€์ƒ‰ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {exc}", exc_info=True)
1475
+ raise HTTPException(status_code=500, detail="๊ฒ€์ƒ‰ ์ฒ˜๋ฆฌ ์ค‘ ๋‚ด๋ถ€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")
1476
+
1477
+
1478
+ def _trace_to_schema(trace: AgentTrace) -> AgentTraceSchema:
1479
+ return AgentTraceSchema(
1480
+ request_id=trace.request_id,
1481
+ session_id=trace.session_id,
1482
+ plan=trace.plan.tool_names if trace.plan else [],
1483
+ plan_reason=trace.plan.reason if trace.plan else "",
1484
+ tool_results=[
1485
+ ToolResultSchema(
1486
+ tool=tool_name(result.tool),
1487
+ success=result.success,
1488
+ latency_ms=round(result.latency_ms, 2),
1489
+ data=result.data,
1490
+ error=result.error,
1491
+ )
1492
+ for result in trace.tool_results
1493
+ ],
1494
+ total_latency_ms=round(trace.total_latency_ms, 2),
1495
+ error=trace.error,
1496
+ )
1497
+
1498
+
1499
+ @app.post("/v1/agent/run", response_model=AgentRunResponse)
1500
+ @_rate_limit("30/minute")
1501
+ async def agent_run(
1502
+ request: AgentRunRequest,
1503
+ _: None = Depends(verify_api_key),
1504
+ ):
1505
+ if not manager.agent_loop:
1506
+ raise HTTPException(status_code=503, detail="์—์ด์ „ํŠธ ๋ฃจํ”„๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
1507
+ if request.stream:
1508
+ raise HTTPException(status_code=400, detail="์ŠคํŠธ๋ฆฌ๋ฐ์€ /v1/agent/stream์„ ์‚ฌ์šฉํ•˜์„ธ์š”.")
1509
+
1510
+ session = manager.session_store.get_or_create(session_id=request.session_id)
1511
+ request_id = str(uuid.uuid4())
1512
+ trace = await manager.agent_loop.run(
1513
+ query=request.query,
1514
+ session=session,
1515
+ request_id=request_id,
1516
+ force_tools=request.force_tools,
1517
+ )
1518
+
1519
+ search_results = None
1520
+ for result in trace.tool_results:
1521
+ if tool_name(result.tool) == ToolType.RAG_SEARCH.value and result.success:
1522
+ search_results = result.data.get("results")
1523
+ elif (
1524
+ tool_name(result.tool) == ToolType.API_LOOKUP.value
1525
+ and result.success
1526
+ and not search_results
1527
+ ):
1528
+ search_results = result.data.get("results")
1529
+
1530
+ return AgentRunResponse(
1531
+ request_id=request_id,
1532
+ session_id=session.session_id,
1533
+ text=trace.final_text,
1534
+ trace=_trace_to_schema(trace),
1535
+ search_results=search_results,
1536
+ )
1537
+
1538
+
1539
+ @app.post("/v1/agent/stream")
1540
+ @_rate_limit("30/minute")
1541
+ async def agent_stream(
1542
+ request: AgentRunRequest,
1543
+ _: None = Depends(verify_api_key),
1544
+ ):
1545
+ if not manager.agent_loop:
1546
+ raise HTTPException(status_code=503, detail="์—์ด์ „ํŠธ ๋ฃจํ”„๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
1547
+
1548
+ session = manager.session_store.get_or_create(session_id=request.session_id)
1549
+ request_id = str(uuid.uuid4())
1550
+
1551
+ async def stream_events() -> AsyncGenerator[str, None]:
1552
+ async for event in manager.agent_loop.run_stream(
1553
+ query=request.query,
1554
+ session=session,
1555
+ request_id=request_id,
1556
+ force_tools=request.force_tools,
1557
+ ):
1558
+ yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
1559
+
1560
+ return StreamingResponse(stream_events(), media_type="text/event-stream")
1561
+
1562
+
1563
+ # ---------------------------------------------------------------------------
1564
+ # v2 ์—”๋“œํฌ์ธํŠธ: LangGraph ๊ธฐ๋ฐ˜ agent ์‹คํ–‰ (interrupt/approve ํŒจํ„ด)
1565
+ # ---------------------------------------------------------------------------
1566
+
1567
+
1568
+ @app.post("/v2/agent/stream")
1569
+ async def v2_agent_stream(
1570
+ request: AgentRunRequest,
1571
+ _: None = Depends(verify_api_key),
1572
+ ):
1573
+ """LangGraph ๊ธฐ๋ฐ˜ agent SSE ์ŠคํŠธ๋ฆฌ๋ฐ ์‹คํ–‰.
1574
+
1575
+ graph.astream()์„ ์‚ฌ์šฉํ•ด ๋…ธ๋“œ๋ณ„ ์™„๋ฃŒ ์ด๋ฒคํŠธ๋ฅผ SSE๋กœ ์ „์†กํ•œ๋‹ค.
1576
+
1577
+ ์ด๋ฒคํŠธ ํ˜•์‹ (๊ฐ ์ค„: ``data: <JSON>\\n\\n``):
1578
+ - ๋…ธ๋“œ ์ง„ํ–‰: ``{"node": "<name>", "status": "completed", ...}``
1579
+ - approval_wait ๋„๋‹ฌ:
1580
+ ``{"node": "approval_wait", "status": "awaiting_approval",
1581
+ "approval_request": {...}, "thread_id": "..."}``
1582
+ - ์˜ค๋ฅ˜: ``{"node": "error", "status": "error", "error": "..."}``
1583
+
1584
+ ์Šน์ธ ํ๋ฆ„:
1585
+ - ํด๋ผ์ด์–ธํŠธ๋Š” ``awaiting_approval`` ์ด๋ฒคํŠธ ์ˆ˜์‹  ํ›„ ์ŠคํŠธ๋ฆผ์ด ์ข…๋ฃŒ๋จ์„ ์ธ์ง€ํ•˜๊ณ 
1586
+ ``/v2/agent/approve``๋กœ ์Šน์ธ/๊ฑฐ์ ˆ์„ ์ „๋‹ฌํ•œ๋‹ค.
1587
+ """
1588
+ if not manager.graph:
1589
+
1590
+ async def _no_graph():
1591
+ yield 'data: {"node": "error", "status": "error", "error": "LangGraph graph๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."}\n\n'
1592
+
1593
+ return StreamingResponse(_no_graph(), media_type="text/event-stream")
1594
+
1595
+ from langchain_core.messages import HumanMessage
1596
+
1597
+ thread_id = request.session_id or str(uuid.uuid4())
1598
+ session_id = thread_id
1599
+ request_id = str(uuid.uuid4())
1600
+ config = {"configurable": {"thread_id": thread_id}}
1601
+ initial_state = {
1602
+ "session_id": session_id,
1603
+ "request_id": request_id,
1604
+ "messages": [HumanMessage(content=request.query)],
1605
+ }
1606
+
1607
+ async def _generate() -> AsyncGenerator[str, None]:
1608
+ try:
1609
+ async for chunk in manager.graph.astream(initial_state, config, stream_mode="updates"):
1610
+ # chunk: {node_name: state_delta}
1611
+ for node_name, state_delta in chunk.items():
1612
+ event: dict = {
1613
+ "node": node_name,
1614
+ "status": "completed",
1615
+ }
1616
+ # synthesis ์™„๋ฃŒ ์‹œ evidence_items์™€ task_type์„ ์ด๋ฒคํŠธ์— ํฌํ•จ.
1617
+ # ์ „์ œ: stream_mode="updates"์—์„œ state_delta๋Š” ๋…ธ๋“œ์˜ raw return dict๋‹ค.
1618
+ # LangGraph ๋ฒ„์ „ ์—…๊ทธ๋ ˆ์ด๋“œ ์‹œ ์ด ๊ตฌ์กฐ๊ฐ€ ๋ณ€๊ฒฝ๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ฃผ์˜.
1619
+ # evidence_items ์Šคํ‚ค๋งˆ: EvidenceItem.to_dict() ํ•„๋“œ๋ฅผ ๋”ฐ๋ฅธ๋‹ค.
1620
+ # source_type: "rag" | "api" | "llm_generated"
1621
+ # title, excerpt, link_or_path, page, score, provider_meta
1622
+ # (์›น ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ์ง์ ‘ ๋ Œ๋”๋ง ์‹œ XSS ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด ์ด์Šค์ผ€์ดํ”„ ํ•„์š”)
1623
+ if node_name == "synthesis" and isinstance(state_delta, dict):
1624
+ if state_delta.get("final_text"):
1625
+ event["final_text"] = state_delta["final_text"]
1626
+ if state_delta.get("evidence_items"):
1627
+ event["evidence_items"] = state_delta["evidence_items"]
1628
+ if state_delta.get("task_type"):
1629
+ event["task_type"] = state_delta["task_type"]
1630
+ if node_name == "approval_wait":
1631
+ try:
1632
+ graph_state = await manager.graph.aget_state(config)
1633
+ if graph_state.next:
1634
+ event = {
1635
+ "node": "approval_wait",
1636
+ "status": "awaiting_approval",
1637
+ "approval_request": _extract_approval_request(graph_state),
1638
+ "thread_id": thread_id,
1639
+ "session_id": session_id,
1640
+ }
1641
+ except Exception as exc:
1642
+ logger.warning(f"[v2/agent/stream] aget_state ์‹คํŒจ: {exc}")
1643
+ event["status"] = "awaiting_approval"
1644
+ event["approval_request"] = {
1645
+ "prompt": "์Šน์ธ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. /v2/agent/approve๋กœ ์ง„ํ–‰ํ•˜์„ธ์š”."
1646
+ }
1647
+
1648
+ yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
1649
+
1650
+ # Stop streaming after awaiting_approval (client must call /v2/agent/approve)
1651
+ if event.get("status") == "awaiting_approval":
1652
+ return
1653
+ except Exception as exc:
1654
+ logger.error(f"[v2/agent/stream] ์ŠคํŠธ๋ฆผ ์˜ˆ์™ธ: {exc}")
1655
+ error_event = {"node": "error", "status": "error", "error": str(exc)}
1656
+ yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
1657
+
1658
+ return StreamingResponse(_generate(), media_type="text/event-stream")
1659
+
1660
+
1661
+ @app.post("/v2/agent/run")
1662
+ async def v2_agent_run(
1663
+ request: AgentRunRequest,
1664
+ _: None = Depends(verify_api_key),
1665
+ ):
1666
+ """LangGraph ๊ธฐ๋ฐ˜ agent ์‹คํ–‰ (1๋‹จ๊ณ„: interrupt๊นŒ์ง€).
1667
+
1668
+ graph๋ฅผ ์‹คํ–‰ํ•˜์—ฌ `approval_wait` ๋…ธ๋“œ์—์„œ interrupt๋˜๋ฉด
1669
+ `status: awaiting_approval`๊ณผ ํ•จ๊ป˜ ์Šน์ธ ์š”์ฒญ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
1670
+
1671
+ ํด๋ผ์ด์–ธํŠธ๋Š” ๋ฐ˜ํ™˜๋œ `thread_id`๋ฅผ ์ €์žฅํ•ด๋‘๊ณ 
1672
+ `/v2/agent/approve`๋กœ ์Šน์ธ/๊ฑฐ์ ˆ์„ ์ „๋‹ฌํ•ด์•ผ ํ•œ๋‹ค.
1673
+
1674
+ Session Resume Contract
1675
+ -----------------------
1676
+ ๋™์ผ session_id๋กœ ์žฌ์š”์ฒญํ•˜๋Š” ๊ฒฝ์šฐ ๋‹ค์Œ ๊ทœ์น™์„ ๋”ฐ๋ฅธ๋‹ค:
1677
+
1678
+ 1. **interrupt ๋Œ€๊ธฐ ์ค‘**: graph๊ฐ€ approval_wait์—์„œ interrupt ์ƒํƒœ์ด๋ฉด
1679
+ ํ˜„์žฌ checkpoint์—์„œ resumeํ•˜์ง€ ์•Š๊ณ  ์ƒˆ ๋ฉ”์‹œ์ง€๋ฅผ *์ถ”๊ฐ€ํ•˜์—ฌ* ์ด์–ด์„œ ์‹คํ–‰ํ•œ๋‹ค.
1680
+ (์žฌ์š”์ฒญ์€ ์ƒˆ graph_run์œผ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค.)
1681
+ ์Šน์ธ/๊ฑฐ์ ˆ์€ ๋ฐ˜๋“œ์‹œ `/v2/agent/approve`๋ฅผ ํ†ตํ•ด ์ฒ˜๋ฆฌํ•ด์•ผ ํ•œ๋‹ค.
1682
+
1683
+ 2. **์™„๋ฃŒ๋œ graph**: graph๊ฐ€ END์— ๋„๋‹ฌํ•œ ์ƒํƒœ(state.next == [])์ด๋ฉด
1684
+ ๋™์ผ thread_id์— ์ƒˆ graph_run์„ ์‹œ์ž‘ํ•œ๋‹ค. LangGraph checkpointer๊ฐ€
1685
+ ๋™์ผ thread_id์—์„œ ์ด์ „ ์ƒํƒœ๋ฅผ ๋ˆ„์ ํ•˜๋ฏ€๋กœ ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ๊ฐ€ ๋ณด์กด๋œ๋‹ค.
1686
+
1687
+ 3. **ํ”„๋กœ์„ธ์Šค ์žฌ์‹œ์ž‘ ํ›„**: SqliteSaver ์‚ฌ์šฉ ์‹œ DB์—์„œ checkpoint๊ฐ€ ๋ณต์›๋˜๋ฏ€๋กœ
1688
+ interrupt ์ƒํƒœ๊ฐ€ ์œ ์ง€๋œ๋‹ค. ํด๋ผ์ด์–ธํŠธ๋Š” ๊ธฐ์กด thread_id๋กœ `/v2/agent/approve`
1689
+ ๋ฅผ ๋‹ค์‹œ ํ˜ธ์ถœํ•˜๋ฉด ์ค‘๋‹จ๋œ ์ง€์ ์—์„œ resumeํ•  ์ˆ˜ ์žˆ๋‹ค.
1690
+
1691
+ Note: session_id == thread_id. ๋‘ ๊ฐ’์€ ํ•ญ์ƒ ๋™์ผํ•˜๊ฒŒ ์œ ์ง€๋œ๋‹ค.
1692
+ """
1693
+ if not manager.graph:
1694
+ raise HTTPException(status_code=503, detail="LangGraph graph๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
1695
+
1696
+ from langchain_core.messages import HumanMessage
1697
+
1698
+ thread_id = request.session_id or str(uuid.uuid4())
1699
+ session_id = thread_id # thread_id๋ฅผ session_id๋กœ ํ™•์ • (session_id == thread_id ๋ถˆ๋ณ€)
1700
+ request_id = str(uuid.uuid4())
1701
+ config = {"configurable": {"thread_id": thread_id}}
1702
+ initial_state = {
1703
+ "session_id": session_id,
1704
+ "request_id": request_id,
1705
+ "messages": [HumanMessage(content=request.query)],
1706
+ }
1707
+
1708
+ try:
1709
+ await manager.graph.ainvoke(initial_state, config)
1710
+
1711
+ # interrupt ์ƒํƒœ ํ™•์ธ
1712
+ graph_state = await manager.graph.aget_state(config)
1713
+ if graph_state.next:
1714
+ # interrupt ๋Œ€๊ธฐ ์ค‘: approval_request ์ •๋ณด๋ฅผ ํด๋ผ์ด์–ธํŠธ์— ๋ฐ˜ํ™˜
1715
+ return {
1716
+ "status": "awaiting_approval",
1717
+ "thread_id": thread_id,
1718
+ "session_id": session_id,
1719
+ "graph_run_id": request_id,
1720
+ "approval_request": _extract_approval_request(graph_state),
1721
+ }
1722
+
1723
+ # interrupt ์—†์ด ์™„๋ฃŒ๋œ ๊ฒฝ์šฐ (rejected ๋˜๋Š” ์˜ค๋ฅ˜)
1724
+ final_state = graph_state.values
1725
+ return {
1726
+ "status": "completed",
1727
+ "thread_id": thread_id,
1728
+ "session_id": session_id,
1729
+ "graph_run_id": request_id,
1730
+ "text": final_state.get("final_text", ""),
1731
+ "evidence_items": final_state.get("evidence_items", []),
1732
+ "task_type": final_state.get("task_type", ""),
1733
+ }
1734
+ except Exception as exc:
1735
+ logger.error(f"[v2/agent/run] ์˜ˆ์™ธ ๋ฐœ์ƒ: {exc}")
1736
+ # graph_run์„ "error" status๋กœ ๊ธฐ๋ก ์‹œ๋„
1737
+ try:
1738
+ if manager.session_store:
1739
+ session = manager.session_store.get_or_create(session_id)
1740
+ session.add_graph_run(
1741
+ request_id=request_id,
1742
+ plan_summary=f"[error] {exc}",
1743
+ approval_status="",
1744
+ executed_capabilities=[],
1745
+ status="error",
1746
+ total_latency_ms=0.0,
1747
+ )
1748
+ except Exception as persist_exc:
1749
+ logger.warning(f"[v2/agent/run] error persist ์‹คํŒจ: {persist_exc}")
1750
+ return {
1751
+ "status": "error",
1752
+ "thread_id": thread_id,
1753
+ "session_id": session_id,
1754
+ "graph_run_id": request_id,
1755
+ "error": str(exc),
1756
+ }
1757
+
1758
+
1759
+ @app.post("/v2/agent/approve")
1760
+ async def v2_agent_approve(
1761
+ thread_id: str,
1762
+ approved: bool,
1763
+ _: None = Depends(verify_api_key),
1764
+ ):
1765
+ """interrupt๋œ graph๋ฅผ resumeํ•œ๋‹ค (2๋‹จ๊ณ„: ์Šน์ธ/๊ฑฐ์ ˆ).
1766
+
1767
+ Parameters
1768
+ ----------
1769
+ thread_id : str
1770
+ `/v2/agent/run`์—์„œ ๋ฐ˜ํ™˜๋œ thread_id.
1771
+ approved : bool
1772
+ True๋ฉด tool_execute๋กœ ์ง„ํ–‰, False๋ฉด graph๊ฐ€ END๋กœ ์ข…๋ฃŒ.
1773
+ """
1774
+ if not manager.graph:
1775
+ raise HTTPException(status_code=503, detail="LangGraph graph๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
1776
+
1777
+ from langgraph.types import Command
1778
+
1779
+ config = {"configurable": {"thread_id": thread_id}}
1780
+
1781
+ try:
1782
+ result = await manager.graph.ainvoke(
1783
+ Command(resume={"approved": approved}),
1784
+ config,
1785
+ )
1786
+
1787
+ # ๊ฑฐ์ ˆ์ด๋ฉด "rejected", ์Šน์ธ ์™„๋ฃŒ๋ฉด "completed"
1788
+ approval_status = result.get("approval_status", "")
1789
+ if not approved:
1790
+ response_status = "rejected"
1791
+ else:
1792
+ response_status = "completed"
1793
+
1794
+ return {
1795
+ "status": response_status,
1796
+ "thread_id": thread_id,
1797
+ "session_id": result.get("session_id", ""),
1798
+ "graph_run_id": result.get("request_id", ""),
1799
+ "text": result.get("final_text", ""),
1800
+ "evidence_items": result.get("evidence_items", []),
1801
+ "task_type": result.get("task_type", ""),
1802
+ "tool_results": result.get("tool_results", {}),
1803
+ "approval_status": approval_status,
1804
+ }
1805
+ except Exception as exc:
1806
+ logger.error(f"[v2/agent/approve] ์˜ˆ์™ธ ๋ฐœ์ƒ: {exc}")
1807
+ # graph_run์„ "error" status๋กœ ๊ธฐ๋ก ์‹œ๋„
1808
+ session_id = ""
1809
+ request_id = ""
1810
+ try:
1811
+ if manager.session_store:
1812
+ graph_state = await manager.graph.aget_state(config)
1813
+ state_values = graph_state.values if graph_state else {}
1814
+ session_id = state_values.get("session_id", "")
1815
+ request_id = state_values.get("request_id", "")
1816
+ if session_id:
1817
+ session = manager.session_store.get_or_create(session_id)
1818
+ session.add_graph_run(
1819
+ request_id=request_id,
1820
+ plan_summary=f"[error] {exc}",
1821
+ approval_status="",
1822
+ executed_capabilities=[],
1823
+ status="error",
1824
+ total_latency_ms=0.0,
1825
+ )
1826
+ except Exception as persist_exc:
1827
+ logger.warning(f"[v2/agent/approve] error persist ์‹คํŒจ: {persist_exc}")
1828
+ return {
1829
+ "status": "error",
1830
+ "thread_id": thread_id,
1831
+ "session_id": session_id,
1832
+ "graph_run_id": request_id,
1833
+ "error": str(exc),
1834
+ }
1835
+
1836
+
1837
+ @app.post("/v2/agent/cancel")
1838
+ async def v2_agent_cancel(
1839
+ thread_id: str,
1840
+ _: None = Depends(verify_api_key),
1841
+ ):
1842
+ """interrupt ๋Œ€๊ธฐ ์ค‘์ธ graph๋ฅผ ๊ฐ•์ œ ์ทจ์†Œํ•œ๋‹ค.
1843
+
1844
+ interrupt ์ƒํƒœ์—์„œ ๊ฑฐ์ ˆ ์ฒ˜๋ฆฌ(Command(resume={"approved": False}))๋ฅผ ์ˆ˜ํ–‰ํ•˜๋˜,
1845
+ state์— interrupt_reason="user_cancel"์„ ์ „๋‹ฌํ•˜์—ฌ
1846
+ persist ๋…ธ๋“œ๊ฐ€ graph_run status๋ฅผ "interrupted"๋กœ ๊ธฐ๋กํ•˜๊ฒŒ ํ•œ๋‹ค.
1847
+
1848
+ Parameters
1849
+ ----------
1850
+ thread_id : str
1851
+ `/v2/agent/run`์—์„œ ๋ฐ˜ํ™˜๋œ thread_id.
1852
+ """
1853
+ if not manager.graph:
1854
+ raise HTTPException(status_code=503, detail="LangGraph graph๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
1855
+
1856
+ from langgraph.types import Command
1857
+
1858
+ config = {"configurable": {"thread_id": thread_id}}
1859
+
1860
+ try:
1861
+ # interrupt ์ƒํƒœ ํ™•์ธ
1862
+ graph_state = await manager.graph.aget_state(config)
1863
+ if not graph_state or not graph_state.next:
1864
+ raise HTTPException(
1865
+ status_code=409,
1866
+ detail="ํ•ด๋‹น thread๋Š” ํ˜„์žฌ interrupt ๋Œ€๊ธฐ ์ƒํƒœ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค.",
1867
+ )
1868
+
1869
+ session_id = graph_state.values.get("session_id", "")
1870
+
1871
+ # ๊ฐ•์ œ ๊ฑฐ์ ˆ + interrupt_reason ์ „๋‹ฌ๋กœ resume
1872
+ result = await manager.graph.ainvoke(
1873
+ Command(resume={"approved": False, "cancel": True}),
1874
+ config,
1875
+ )
1876
+
1877
+ # persist ๋…ธ๋“œ์—์„œ "interrupted" ๊ธฐ๋ก์„ ์œ„ํ•ด state update
1878
+ # (approval_wait_node๊ฐ€ cancel ์‹ ํ˜ธ๋ฅผ interrupt_reason์œผ๋กœ ๋ณ€ํ™˜)
1879
+ return {
1880
+ "status": "cancelled",
1881
+ "thread_id": thread_id,
1882
+ "session_id": session_id,
1883
+ "graph_run_id": result.get("request_id", ""),
1884
+ }
1885
+ except HTTPException:
1886
+ raise
1887
+ except Exception as exc:
1888
+ logger.error(f"[v2/agent/cancel] ์˜ˆ์™ธ ๋ฐœ์ƒ: {exc}")
1889
+ return {
1890
+ "status": "error",
1891
+ "thread_id": thread_id,
1892
+ "error": str(exc),
1893
+ }
1894
+
1895
+
1896
+ if __name__ == "__main__":
1897
+ import uvicorn
1898
+
1899
+ uvicorn.run(app, **runtime_config.to_uvicorn_kwargs())
src/inference/bm25_indexer.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BM25 Indexer for Korean civil complaint search.
3
+
4
+ Provides sparse keyword-based retrieval using morpheme analysis (Okt/Mecab)
5
+ and BM25Okapi ranking. Complements the dense FAISS retriever for hybrid search.
6
+
7
+ Issue: #153
8
+
9
+ Known limitation:
10
+ BM25Okapi assigns negative IDF when a term appears in every document
11
+ (df == N). search() returns only positive-scoring results, so a single-
12
+ document corpus may return empty results for exact-match queries.
13
+ In practice this does not occur at production scale (10k+ documents).
14
+
15
+ Security:
16
+ Uses pickle for BM25Okapi serialization. Only load index files from
17
+ trusted sources within the closed-network environment. When the
18
+ BM25_INDEX_HMAC_KEY environment variable is set, save() signs the
19
+ payload and load() verifies the HMAC before deserialization.
20
+ """
21
+
22
+ import hashlib
23
+ import hmac
24
+ import json
25
+ import os
26
+ import pickle
27
+ from typing import List, Optional, Tuple
28
+
29
+ import numpy as np
30
+ from loguru import logger
31
+ from rank_bm25 import BM25Okapi
32
+
33
+ # Minimal Korean stopwords relevant to civil complaints
34
+ # Defined before KoreanTokenizer to avoid forward-reference maintenance hazard.
35
+ _STOPWORDS = frozenset(
36
+ {
37
+ "์ด๋‹ค",
38
+ "์žˆ๋‹ค",
39
+ "ํ•˜๋‹ค",
40
+ "๋˜๋‹ค",
41
+ "์—†๋‹ค",
42
+ "์•Š๋‹ค",
43
+ "์ด๋Ÿฐ",
44
+ "์ €๋Ÿฐ",
45
+ "๊ทธ๋Ÿฐ",
46
+ "ํ•ฉ๋‹ˆ๋‹ค",
47
+ "์ž…๋‹ˆ๋‹ค",
48
+ "์Šต๋‹ˆ๋‹ค",
49
+ "๋ฉ๋‹ˆ๋‹ค",
50
+ "์žˆ์Šต๋‹ˆ๋‹ค",
51
+ "์—†์Šต๋‹ˆ๋‹ค",
52
+ "์—์„œ",
53
+ "์œผ๋กœ",
54
+ "์—๊ฒŒ",
55
+ "๊นŒ์ง€",
56
+ "๋ถ€ํ„ฐ",
57
+ "์—์„œ๋Š”",
58
+ "์œผ๋กœ๋Š”",
59
+ "๊ทธ๋ฆฌ๊ณ ",
60
+ "ํ•˜์ง€๋งŒ",
61
+ "๊ทธ๋Ÿฌ๋‚˜",
62
+ "๋”ฐ๋ผ์„œ",
63
+ "๊ทธ๋ž˜์„œ",
64
+ }
65
+ )
66
+
67
+
68
+ class KoreanTokenizer:
69
+ """
70
+ Korean morpheme tokenizer with Mecab (preferred) and Okt (fallback).
71
+ In closed-network environments where Mecab is not installed, Okt is used.
72
+ """
73
+
74
+ def __init__(self, tokenizer_type: str = "auto"):
75
+ """
76
+ Args:
77
+ tokenizer_type: "mecab", "okt", or "auto" (tries Mecab first, falls back to Okt)
78
+ """
79
+ self.tokenizer_type = tokenizer_type
80
+ self._tagger = None
81
+ self._init_tokenizer(tokenizer_type)
82
+
83
+ def _init_tokenizer(self, tokenizer_type: str) -> None:
84
+ if tokenizer_type in ("mecab", "auto"):
85
+ try:
86
+ from konlpy.tag import Mecab
87
+
88
+ self._tagger = Mecab()
89
+ self.tokenizer_type = "mecab"
90
+ logger.info("Tokenizer initialized: Mecab")
91
+ return
92
+ except Exception:
93
+ if tokenizer_type == "mecab":
94
+ raise RuntimeError(
95
+ "Mecab is not installed. Install it or use tokenizer_type='okt'."
96
+ )
97
+ logger.warning("Mecab unavailable, falling back to Okt.")
98
+
99
+ # Okt path
100
+ try:
101
+ from konlpy.tag import Okt
102
+
103
+ self._tagger = Okt()
104
+ self.tokenizer_type = "okt"
105
+ logger.info("Tokenizer initialized: Okt")
106
+ except Exception as e:
107
+ raise RuntimeError(f"Failed to initialize any Korean tokenizer: {e}")
108
+
109
+ def morphs(self, text: str) -> List[str]:
110
+ """Tokenize text into morphemes, filtering stopwords and short tokens."""
111
+ if not text or not text.strip():
112
+ return []
113
+ try:
114
+ tokens = self._tagger.morphs(str(text))
115
+ # Filter single characters and common stopwords
116
+ return [t for t in tokens if len(t) > 1 and t not in _STOPWORDS]
117
+ except Exception as e:
118
+ logger.warning(
119
+ f"Tokenization error (len={len(text)}): {type(e).__name__}. "
120
+ "Falling back to whitespace split."
121
+ )
122
+ return [t for t in str(text).split() if len(t) > 1]
123
+
124
+
125
+ class BM25Indexer:
126
+ """
127
+ BM25 keyword index for civil complaint documents.
128
+
129
+ Builds a sparse BM25Okapi index over tokenized Korean text,
130
+ enabling keyword-exact matching for terms like law article numbers,
131
+ department names, and specific complaint keywords.
132
+
133
+ Return type note:
134
+ search() returns List[Tuple[int, float]] โ€” raw corpus indices and BM25
135
+ scores. This is intentionally lower-level than CivilComplaintRetriever
136
+ which returns List[Dict]. The HybridSearchEngine is responsible for
137
+ mapping indices to metadata and fusing scores across both retrievers.
138
+
139
+ Usage:
140
+ indexer = BM25Indexer()
141
+ indexer.build_index(documents)
142
+ results = indexer.search("๋„๋กœ ํฌ์žฅ ๊ท ์—ด ์‹ ๊ณ ", top_k=10)
143
+ indexer.save("models/bm25_index/complaints.pkl")
144
+
145
+ # Later:
146
+ indexer2 = BM25Indexer()
147
+ indexer2.load("models/bm25_index/complaints.pkl")
148
+ """
149
+
150
+ _PAYLOAD_VERSION = 1
151
+ _HMAC_KEY_ENV = "BM25_INDEX_HMAC_KEY"
152
+
153
+ def __init__(self, tokenizer_type: str = "auto"):
154
+ self.tokenizer = KoreanTokenizer(tokenizer_type)
155
+ self.bm25: Optional[BM25Okapi] = None
156
+ self._tokenized_corpus: Optional[List[List[str]]] = None
157
+ self._doc_count: int = 0
158
+
159
+ def __repr__(self) -> str:
160
+ return (
161
+ f"BM25Indexer(docs={self._doc_count}, "
162
+ f"tokenizer={self.tokenizer.tokenizer_type}, "
163
+ f"ready={self.is_ready()})"
164
+ )
165
+
166
+ # ------------------------------------------------------------------
167
+ # Index construction
168
+ # ------------------------------------------------------------------
169
+
170
+ def build_index(self, documents: List[str]) -> None:
171
+ """
172
+ Build BM25 index from a list of document strings.
173
+
174
+ Args:
175
+ documents: Raw text documents (one per entry).
176
+
177
+ Raises:
178
+ ValueError: If documents list is empty or all documents tokenize
179
+ to empty token lists (would cause ZeroDivisionError
180
+ inside BM25Okapi).
181
+ """
182
+ if not documents:
183
+ raise ValueError("Document list is empty.")
184
+
185
+ if self.bm25 is not None:
186
+ logger.warning("Rebuilding BM25 index โ€” existing index will be replaced.")
187
+
188
+ logger.info(f"Tokenizing {len(documents)} documents...")
189
+ tokenized = [self.tokenizer.morphs(doc) for doc in documents]
190
+
191
+ empty_count = sum(1 for t in tokenized if not t)
192
+ if empty_count:
193
+ logger.warning(f"{empty_count} documents produced empty token lists.")
194
+
195
+ # Guard against all-empty corpus which causes ZeroDivisionError in BM25Okapi
196
+ if all(len(t) == 0 for t in tokenized):
197
+ raise ValueError(
198
+ "All documents produced empty token lists. "
199
+ "Check that documents contain valid Korean text."
200
+ )
201
+
202
+ logger.info("Building BM25 index...")
203
+ self._tokenized_corpus = tokenized
204
+ self.bm25 = BM25Okapi(self._tokenized_corpus)
205
+ self._doc_count = len(documents)
206
+ logger.info(f"BM25 index built: {self._doc_count} documents.")
207
+
208
+ def build_index_from_jsonl(self, data_path: str, text_field: str = "text") -> None:
209
+ """
210
+ Build index by loading documents from a JSONL file.
211
+
212
+ Each line must be a JSON object with a field matching `text_field`.
213
+ For files using EXAONE chat template format, the complaint content
214
+ is extracted from the [|user|] section automatically.
215
+
216
+ Args:
217
+ data_path: Path to JSONL file.
218
+ text_field: JSON field containing the text ("text" or "complaint").
219
+ """
220
+ if not os.path.exists(data_path):
221
+ raise FileNotFoundError(f"Data file not found: {data_path}")
222
+
223
+ documents = []
224
+ with open(data_path, "r", encoding="utf-8") as f:
225
+ for line_no, line in enumerate(f, 1):
226
+ line = line.strip()
227
+ if not line:
228
+ continue
229
+ try:
230
+ item = json.loads(line)
231
+ if text_field in item:
232
+ raw = item[text_field]
233
+ # Auto-extract complaint from EXAONE chat template
234
+ if isinstance(raw, str) and "[|user|]" in raw:
235
+ text = self._extract_complaint_from_template(raw)
236
+ else:
237
+ text = raw
238
+ elif "complaint" in item:
239
+ text = item["complaint"]
240
+ elif "input" in item:
241
+ text = item["input"]
242
+ else:
243
+ text = self._extract_complaint_from_template(item.get("text", ""))
244
+ # Ensure text is always a string
245
+ if not isinstance(text, str):
246
+ text = str(text) if text is not None else ""
247
+ documents.append(text)
248
+ except (json.JSONDecodeError, KeyError) as e:
249
+ logger.warning(f"Line {line_no}: skipping due to error: {e}")
250
+
251
+ logger.info(f"Loaded {len(documents)} documents from {data_path}")
252
+ self.build_index(documents)
253
+
254
+ @staticmethod
255
+ def _extract_complaint_from_template(text: str) -> str:
256
+ """Extract complaint content from EXAONE chat template format."""
257
+ if not text:
258
+ return text
259
+ try:
260
+ if "[|user|]" in text:
261
+ user_part = text.split("[|user|]")[1].split("[|endofturn|]")[0]
262
+ if "๋ฏผ์› ๋‚ด์šฉ:" in user_part:
263
+ return user_part.split("๋ฏผ์› ๋‚ด์šฉ:")[1].strip()
264
+ return user_part.strip()
265
+ except Exception as e:
266
+ logger.debug(f"Template extraction fallback: {type(e).__name__}")
267
+ return text
268
+
269
+ # ------------------------------------------------------------------
270
+ # Search
271
+ # ------------------------------------------------------------------
272
+
273
+ def search(self, query: str, top_k: int = 10) -> List[Tuple[int, float]]:
274
+ """
275
+ Search the BM25 index and return top-k (index, score) pairs.
276
+
277
+ Only positive-scoring documents are returned. Scores are raw BM25
278
+ values and are not normalized โ€” the HybridSearchEngine handles
279
+ score fusion (e.g., RRF) across dense and sparse retrievers.
280
+
281
+ Args:
282
+ query: Korean query string.
283
+ top_k: Number of results to return.
284
+
285
+ Returns:
286
+ List of (document_index, bm25_score) tuples, sorted by score desc.
287
+
288
+ Raises:
289
+ RuntimeError: If index has not been built or loaded.
290
+ """
291
+ if self.bm25 is None:
292
+ raise RuntimeError("Index not built. Call build_index() first.")
293
+ if not query or not query.strip():
294
+ return []
295
+
296
+ tokenized_query = self.tokenizer.morphs(query)
297
+ if not tokenized_query:
298
+ logger.warning("Query tokenized to empty list. Returning no results.")
299
+ return []
300
+
301
+ scores: np.ndarray = self.bm25.get_scores(tokenized_query)
302
+
303
+ # Use argpartition O(N) instead of argsort O(N log N) for top-k selection
304
+ actual_k = min(top_k, len(scores))
305
+ if actual_k == 0:
306
+ return []
307
+
308
+ top_indices = np.argpartition(scores, -actual_k)[-actual_k:]
309
+ top_indices = top_indices[np.argsort(scores[top_indices])[::-1]]
310
+
311
+ results = [(int(idx), float(scores[idx])) for idx in top_indices if scores[idx] > 0.0]
312
+ return results
313
+
314
+ # ------------------------------------------------------------------
315
+ # Persistence
316
+ # ------------------------------------------------------------------
317
+
318
+ def save(self, path: str) -> None:
319
+ """
320
+ Serialize and save the BM25 index to disk.
321
+
322
+ Security: Uses pickle for BM25Okapi serialization. When the
323
+ ``BM25_INDEX_HMAC_KEY`` environment variable is set, the payload is
324
+ signed with HMAC-SHA256 and a ``.sig`` sidecar file is written. Only
325
+ load index files from trusted sources within the closed-network
326
+ environment.
327
+
328
+ Args:
329
+ path: Destination file path (e.g., "models/bm25_index/complaints.pkl").
330
+ """
331
+ if self.bm25 is None:
332
+ raise RuntimeError("Index not built. Call build_index() first.")
333
+
334
+ # Fix: use abspath to avoid makedirs("") crash on bare filenames
335
+ parent = os.path.dirname(os.path.abspath(path))
336
+ os.makedirs(parent, exist_ok=True)
337
+
338
+ payload = {
339
+ "version": self._PAYLOAD_VERSION,
340
+ "bm25": self.bm25,
341
+ "tokenized_corpus": self._tokenized_corpus,
342
+ "doc_count": self._doc_count,
343
+ "tokenizer_type": self.tokenizer.tokenizer_type,
344
+ }
345
+ data = pickle.dumps(payload, protocol=pickle.HIGHEST_PROTOCOL)
346
+
347
+ # HMAC signing (when key is configured)
348
+ hmac_key = os.getenv(self._HMAC_KEY_ENV)
349
+ if hmac_key:
350
+ sig = hmac.new(hmac_key.encode(), data, hashlib.sha256).hexdigest()
351
+ sig_path = path + ".sig"
352
+ with open(sig_path, "w", encoding="utf-8") as sf:
353
+ sf.write(sig)
354
+ logger.info(f"HMAC signature written to {sig_path}")
355
+
356
+ with open(path, "wb") as f:
357
+ f.write(data)
358
+ logger.info(f"BM25 index saved to {path} ({self._doc_count} documents).")
359
+
360
+ def load(self, path: str) -> None:
361
+ """
362
+ Load a previously saved BM25 index from disk.
363
+
364
+ Security: When the ``BM25_INDEX_HMAC_KEY`` environment variable is
365
+ set, the HMAC-SHA256 signature is verified before deserialization.
366
+ Pickle deserialization can execute arbitrary code โ€” only load files
367
+ from trusted sources within the closed-network environment.
368
+
369
+ Args:
370
+ path: Path to the pickle file saved by `save()`.
371
+
372
+ Raises:
373
+ FileNotFoundError: If the index file does not exist.
374
+ ValueError: If the file is corrupt, has an incompatible schema,
375
+ or fails HMAC verification.
376
+ """
377
+ if not os.path.exists(path):
378
+ raise FileNotFoundError(f"BM25 index file not found: {path}")
379
+
380
+ with open(path, "rb") as f:
381
+ data = f.read()
382
+
383
+ # HMAC verification (when key is configured)
384
+ hmac_key = os.getenv(self._HMAC_KEY_ENV)
385
+ if hmac_key:
386
+ sig_path = path + ".sig"
387
+ if not os.path.exists(sig_path):
388
+ raise ValueError(
389
+ f"HMAC signature file missing: {sig_path}. "
390
+ "Index file cannot be verified โ€” rebuild the index."
391
+ )
392
+ with open(sig_path, "r", encoding="utf-8") as sf:
393
+ expected_sig = sf.read().strip()
394
+ actual_sig = hmac.new(hmac_key.encode(), data, hashlib.sha256).hexdigest()
395
+ if not hmac.compare_digest(actual_sig, expected_sig):
396
+ raise ValueError(
397
+ "BM25 index HMAC verification failed โ€” file may be tampered. "
398
+ "Rebuild the index with a trusted data source."
399
+ )
400
+ logger.info("HMAC signature verified.")
401
+
402
+ try:
403
+ payload = pickle.loads(data)
404
+ except Exception as e:
405
+ raise ValueError(f"Failed to load BM25 index (corrupt or incompatible): {e}") from e
406
+
407
+ # Payload version check
408
+ saved_version = payload.get("version")
409
+ if saved_version != self._PAYLOAD_VERSION:
410
+ raise ValueError(
411
+ f"BM25 index version mismatch: file has v{saved_version}, "
412
+ f"expected v{self._PAYLOAD_VERSION}. Rebuild the index."
413
+ )
414
+
415
+ try:
416
+ self.bm25 = payload["bm25"]
417
+ self._tokenized_corpus = payload["tokenized_corpus"]
418
+ self._doc_count = payload["doc_count"]
419
+ except (KeyError, TypeError) as e:
420
+ raise ValueError(
421
+ f"BM25 index file has incompatible schema (missing key: {e}). " "Rebuild the index."
422
+ ) from e
423
+
424
+ saved_tokenizer = payload.get("tokenizer_type", "unknown")
425
+ if saved_tokenizer != self.tokenizer.tokenizer_type:
426
+ logger.warning(
427
+ f"Tokenizer mismatch: index was built with '{saved_tokenizer}' "
428
+ f"but current tokenizer is '{self.tokenizer.tokenizer_type}'. "
429
+ "Search recall may be degraded. Rebuild the index to resolve."
430
+ )
431
+
432
+ logger.info(
433
+ f"BM25 index loaded from {path} ({self._doc_count} documents, "
434
+ f"tokenizer: {saved_tokenizer})."
435
+ )
436
+
437
+ # ------------------------------------------------------------------
438
+ # Utilities
439
+ # ------------------------------------------------------------------
440
+
441
+ @property
442
+ def doc_count(self) -> int:
443
+ return self._doc_count
444
+
445
+ def is_ready(self) -> bool:
446
+ return self.bm25 is not None
src/inference/db/__init__.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GovOn RAG ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ชจ๋“ˆ.
3
+
4
+ SQLAlchemy 2.0 ๊ธฐ๋ฐ˜ ORM ๋ชจ๋ธ, CRUD ๋ ˆ์ด์–ด, ๋ณ€ํ™˜ ํ—ฌํผ๋ฅผ ์ œ๊ณตํ•œ๋‹ค.
5
+ """
6
+
7
+ from src.inference.db.converters import (
8
+ dataclass_to_orm,
9
+ orm_to_dataclass,
10
+ orm_to_pydantic,
11
+ )
12
+ from src.inference.db.crud import ( # DocumentSource; IndexingQueue; IndexVersion
13
+ activate_version,
14
+ create_document_source,
15
+ create_index_version,
16
+ create_indexing_queue_item,
17
+ deactivate_versions,
18
+ delete_document_source,
19
+ get_active_version,
20
+ get_by_source_type_and_id,
21
+ get_document_source,
22
+ get_document_sources,
23
+ get_pending_items,
24
+ get_queue_stats,
25
+ update_document_source,
26
+ update_queue_status,
27
+ )
28
+ from src.inference.db.database import SessionLocal, engine, get_db
29
+ from src.inference.db.models import (
30
+ Base,
31
+ DocumentSource,
32
+ IndexingQueue,
33
+ IndexVersion,
34
+ )
35
+
36
+ __all__ = [
37
+ # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ธํ”„๋ผ
38
+ "engine",
39
+ "SessionLocal",
40
+ "get_db",
41
+ "Base",
42
+ # ORM ๋ชจ๋ธ
43
+ "DocumentSource",
44
+ "IndexingQueue",
45
+ "IndexVersion",
46
+ # DocumentSource CRUD
47
+ "create_document_source",
48
+ "get_document_source",
49
+ "get_document_sources",
50
+ "update_document_source",
51
+ "delete_document_source",
52
+ "get_by_source_type_and_id",
53
+ # IndexingQueue CRUD
54
+ "create_indexing_queue_item",
55
+ "get_pending_items",
56
+ "update_queue_status",
57
+ "get_queue_stats",
58
+ # IndexVersion CRUD
59
+ "create_index_version",
60
+ "get_active_version",
61
+ "deactivate_versions",
62
+ "activate_version",
63
+ # ๋ณ€ํ™˜ ํ—ฌํผ
64
+ "orm_to_dataclass",
65
+ "dataclass_to_orm",
66
+ "orm_to_pydantic",
67
+ ]
src/inference/db/alembic/env.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Alembic ํ™˜๊ฒฝ ์„ค์ •.
3
+
4
+ DATABASE_URL ํ™˜๊ฒฝ๋ณ€์ˆ˜๋ฅผ ํ†ตํ•ด ์—ฐ๊ฒฐ ๋ฌธ์ž์—ด์„ ์ฃผ์ž…๋ฐ›๋Š”๋‹ค.
5
+ ๊ธฐ๋ณธ๊ฐ’์€ ๋กœ์ปฌ GovOn ํ™ˆ ๋””๋ ‰ํ„ฐ๋ฆฌ ์•„๋ž˜ SQLite ํŒŒ์ผ์„ ์‚ฌ์šฉํ•œ๋‹ค.
6
+ """
7
+
8
+ # isort:skip_file
9
+ import logging
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from logging.config import fileConfig
14
+
15
+ from alembic import context
16
+ from sqlalchemy import engine_from_config, pool
17
+
18
+ # ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋ฅผ sys.path์— ์ถ”๊ฐ€ํ•˜์—ฌ ๋ชจ๋ธ import ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•จ
19
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")))
20
+
21
+ from src.inference.db.models import Base # noqa: E402
22
+
23
+ # Alembic Config ๊ฐ์ฒด
24
+ config = context.config
25
+
26
+ # ๋กœ๊น… ์„ค์ •
27
+ if config.config_file_name is not None:
28
+ fileConfig(config.config_file_name)
29
+
30
+ # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์„ค์ • (์ž๋™ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ƒ์„ฑ์šฉ)
31
+ target_metadata = Base.metadata
32
+
33
+ # ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ DB URL ๊ฐ€์ ธ์˜ค๊ธฐ
34
+ _DEFAULT_GOVON_HOME = Path(os.getenv("GOVON_HOME", Path.home() / ".govon"))
35
+ _DEFAULT_DATABASE_URL = f"sqlite:///{_DEFAULT_GOVON_HOME / 'metadata.sqlite3'}"
36
+ database_url = os.getenv("DATABASE_URL", _DEFAULT_DATABASE_URL)
37
+
38
+ if database_url == _DEFAULT_DATABASE_URL:
39
+ logging.getLogger(__name__).warning(
40
+ "DATABASE_URL ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•„ ๋กœ์ปฌ SQLite ๊ธฐ๋ณธ๊ฐ’์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. "
41
+ "๋ณ„๋„ RDBMS๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด DATABASE_URL์„ ๋ช…์‹œ์ ์œผ๋กœ ์„ค์ •ํ•˜์„ธ์š”."
42
+ )
43
+
44
+ config.set_main_option("sqlalchemy.url", database_url)
45
+
46
+
47
+ def run_migrations_offline() -> None:
48
+ """์˜คํ”„๋ผ์ธ ๋ชจ๋“œ: DB ์—ฐ๊ฒฐ ์—†์ด SQL ์Šคํฌ๋ฆฝํŠธ๋งŒ ์ƒ์„ฑ."""
49
+ url = config.get_main_option("sqlalchemy.url")
50
+ context.configure(
51
+ url=url,
52
+ target_metadata=target_metadata,
53
+ literal_binds=True,
54
+ dialect_opts={"paramstyle": "named"},
55
+ )
56
+
57
+ with context.begin_transaction():
58
+ context.run_migrations()
59
+
60
+
61
+ def run_migrations_online() -> None:
62
+ """์˜จ๋ผ์ธ ๋ชจ๋“œ: DB์— ์ง์ ‘ ์—ฐ๊ฒฐํ•˜์—ฌ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹คํ–‰."""
63
+ connectable = engine_from_config(
64
+ config.get_section(config.config_ini_section, {}),
65
+ prefix="sqlalchemy.",
66
+ poolclass=pool.NullPool,
67
+ )
68
+
69
+ with connectable.connect() as connection:
70
+ context.configure(
71
+ connection=connection,
72
+ target_metadata=target_metadata,
73
+ )
74
+
75
+ with context.begin_transaction():
76
+ context.run_migrations()
77
+
78
+
79
+ if context.is_offline_mode():
80
+ run_migrations_offline()
81
+ else:
82
+ run_migrations_online()
src/inference/db/alembic/script.py.mako ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+ from typing import Sequence, Union
8
+
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+ ${imports if imports else ""}
12
+
13
+ # revision identifiers, used by Alembic.
14
+ revision: str = ${repr(up_revision)}
15
+ down_revision: Union[str, None] = ${repr(down_revision)}
16
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
17
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
18
+
19
+
20
+ def upgrade() -> None:
21
+ ${upgrades if upgrades else "pass"}
22
+
23
+
24
+ def downgrade() -> None:
25
+ ${downgrades if downgrades else "pass"}
src/inference/db/alembic/versions/001_create_rag_tables.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG ํ•ต์‹ฌ ํ…Œ์ด๋ธ” ์ƒ์„ฑ: document_source, indexing_queue, index_version
2
+
3
+ ADR-004 Section D ์Šคํ‚ค๋งˆ์™€ Issue #152 ์š”๊ตฌ์‚ฌํ•ญ์„ ๋ณ‘ํ•ฉํ•œ ์ดˆ๊ธฐ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜.
4
+ ORM ๋ชจ๋ธ(models.py)๊ณผ ์ •ํ•ฉ์„ฑ์„ ์œ ์ง€ํ•œ๋‹ค.
5
+
6
+ Revision ID: 001
7
+ Revises: None
8
+ Create Date: 2026-03-22
9
+ """
10
+
11
+ from typing import Sequence, Union
12
+
13
+ import sqlalchemy as sa
14
+ from alembic import op
15
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = "001"
19
+ down_revision: Union[str, None] = None
20
+ branch_labels: Union[str, Sequence[str], None] = None
21
+ depends_on: Union[str, Sequence[str], None] = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ # ------------------------------------------------------------------
26
+ # 1. document_source ํ…Œ์ด๋ธ”
27
+ # ------------------------------------------------------------------
28
+ op.create_table(
29
+ "document_source",
30
+ # PK
31
+ sa.Column(
32
+ "id",
33
+ UUID(as_uuid=True),
34
+ server_default=sa.text("gen_random_uuid()"),
35
+ primary_key=True,
36
+ comment="๋ฌธ์„œ ๊ณ ์œ  ์‹๋ณ„์ž",
37
+ ),
38
+ # ๊ณตํ†ต ํ•„๋“œ
39
+ sa.Column(
40
+ "source_type",
41
+ sa.String(20),
42
+ nullable=False,
43
+ comment="๋ฌธ์„œ ํƒ€์ž…: case, law, manual, notice",
44
+ ),
45
+ sa.Column(
46
+ "source_id",
47
+ sa.String(255),
48
+ nullable=False,
49
+ comment="์›๋ณธ ๋ฌธ์„œ ์‹๋ณ„์ž",
50
+ ),
51
+ sa.Column(
52
+ "source_name",
53
+ sa.String(200),
54
+ nullable=True,
55
+ comment="์ถœ์ฒ˜๋ช… (AI Hub, ๋ฒ•์ œ์ฒ˜ ๋“ฑ)",
56
+ ),
57
+ sa.Column("title", sa.String(500), nullable=False, comment="๋ฌธ์„œ ์ œ๋ชฉ"),
58
+ sa.Column("content", sa.Text, nullable=False, comment="๋ฌธ์„œ ๋ณธ๋ฌธ"),
59
+ sa.Column("category", sa.String(50), nullable=True, comment="์นดํ…Œ๊ณ ๋ฆฌ"),
60
+ sa.Column(
61
+ "chunk_index",
62
+ sa.Integer,
63
+ server_default="0",
64
+ comment="์ฒญํฌ ์ธ๋ฑ์Šค",
65
+ ),
66
+ sa.Column(
67
+ "total_chunks",
68
+ sa.Integer,
69
+ server_default="1",
70
+ comment="์ „์ฒด ์ฒญํฌ ์ˆ˜",
71
+ ),
72
+ sa.Column(
73
+ "reliability_score",
74
+ sa.Float,
75
+ server_default="0.6",
76
+ comment="์‹ ๋ขฐ๋„ ์ ์ˆ˜ (0.0~1.0)",
77
+ ),
78
+ sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True, comment="์œ ํšจ ์‹œ์ž‘์ผ"),
79
+ sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True, comment="์œ ํšจ ์ข…๋ฃŒ์ผ"),
80
+ sa.Column(
81
+ "status",
82
+ sa.String(20),
83
+ server_default=sa.text("'active'"),
84
+ comment="๋ฌธ์„œ ์ƒํƒœ: active, expired, deprecated",
85
+ ),
86
+ sa.Column(
87
+ "version",
88
+ sa.String(20),
89
+ server_default=sa.text("'1.0'"),
90
+ comment="๋ฌธ์„œ ๋ฒ„์ „",
91
+ ),
92
+ # ํ™•์žฅ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (JSONB)
93
+ sa.Column(
94
+ "metadata",
95
+ JSONB,
96
+ server_default=sa.text("'{}'::jsonb"),
97
+ comment="์ถ”๊ฐ€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (JSONB)",
98
+ ),
99
+ # CASE ํƒ€์ž… ์ „์šฉ
100
+ sa.Column("complaint_text", sa.Text, nullable=True, comment="๋ฏผ์› ํ…์ŠคํŠธ (CASE ์ „์šฉ)"),
101
+ sa.Column("answer_text", sa.Text, nullable=True, comment="๋‹ต๋ณ€ ํ…์ŠคํŠธ (CASE ์ „์šฉ)"),
102
+ # LAW ํƒ€์ž… ์ „์šฉ
103
+ sa.Column("law_number", sa.String(100), nullable=True, comment="๋ฒ•๋ฅ  ๋ฒˆํ˜ธ (LAW ์ „์šฉ)"),
104
+ sa.Column("article_number", sa.String(50), nullable=True, comment="์กฐํ•ญ ๋ฒˆํ˜ธ (LAW ์ „์šฉ)"),
105
+ sa.Column("enforcement_date", sa.Date, nullable=True, comment="์‹œํ–‰์ผ (LAW ์ „์šฉ)"),
106
+ # MANUAL ํƒ€์ž… ์ „์šฉ
107
+ sa.Column("department", sa.String(100), nullable=True, comment="๋‹ด๋‹น ๋ถ€์„œ (MANUAL ์ „์šฉ)"),
108
+ # NOTICE ํƒ€์ž… ์ „์šฉ
109
+ sa.Column(
110
+ "notice_number", sa.String(100), nullable=True, comment="๊ณต์‹œ ๋ฒˆํ˜ธ (NOTICE ์ „์šฉ)"
111
+ ),
112
+ sa.Column("effective_date", sa.Date, nullable=True, comment="์‹œํ–‰์ผ (NOTICE ์ „์šฉ)"),
113
+ # ์ธ๋ฑ์‹ฑ ๊ด€๋ จ
114
+ sa.Column("faiss_index_id", sa.Integer, nullable=True, comment="FAISS ์ธ๋ฑ์Šค ๋‚ด ID"),
115
+ sa.Column(
116
+ "embedding_version",
117
+ sa.String(50),
118
+ server_default=sa.text("'e5-large-v1'"),
119
+ comment="์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ๋ฒ„์ „",
120
+ ),
121
+ # ํƒ€์ž„์Šคํƒฌํ”„
122
+ sa.Column(
123
+ "created_at",
124
+ sa.DateTime(timezone=True),
125
+ server_default=sa.func.now(),
126
+ comment="์ƒ์„ฑ ์‹œ๊ฐ",
127
+ ),
128
+ sa.Column(
129
+ "updated_at",
130
+ sa.DateTime(timezone=True),
131
+ server_default=sa.func.now(),
132
+ comment="์ˆ˜์ • ์‹œ๊ฐ",
133
+ ),
134
+ # UNIQUE ์ œ์•ฝ์กฐ๊ฑด
135
+ sa.UniqueConstraint(
136
+ "source_type",
137
+ "source_id",
138
+ "chunk_index",
139
+ name="uq_source_type_source_id_chunk",
140
+ ),
141
+ # CHECK ์ œ์•ฝ์กฐ๊ฑด
142
+ sa.CheckConstraint(
143
+ "source_type IN ('case', 'law', 'manual', 'notice')",
144
+ name="ck_source_type_valid",
145
+ ),
146
+ sa.CheckConstraint(
147
+ "status IN ('active', 'expired', 'deprecated')",
148
+ name="ck_status_valid",
149
+ ),
150
+ sa.CheckConstraint(
151
+ "reliability_score >= 0.0 AND reliability_score <= 1.0",
152
+ name="ck_reliability_score_range",
153
+ ),
154
+ )
155
+
156
+ # document_source B-tree ์ธ๋ฑ์Šค
157
+ op.create_index("idx_docsource_source_type", "document_source", ["source_type"])
158
+ op.create_index("idx_docsource_status", "document_source", ["status"])
159
+ op.create_index("idx_docsource_category", "document_source", ["category"])
160
+ op.create_index("idx_docsource_valid_range", "document_source", ["valid_from", "valid_until"])
161
+
162
+ # document_source GIN ์ธ๋ฑ์Šค - JSONB ๊ฒ€์ƒ‰์šฉ
163
+ op.create_index(
164
+ "idx_docsource_metadata",
165
+ "document_source",
166
+ ["metadata"],
167
+ postgresql_using="gin",
168
+ )
169
+
170
+ # ------------------------------------------------------------------
171
+ # 2. indexing_queue ํ…Œ์ด๋ธ”
172
+ # ------------------------------------------------------------------
173
+ op.create_table(
174
+ "indexing_queue",
175
+ # PK
176
+ sa.Column(
177
+ "id",
178
+ UUID(as_uuid=True),
179
+ server_default=sa.text("gen_random_uuid()"),
180
+ primary_key=True,
181
+ comment="ํ ํ•ญ๋ชฉ ๊ณ ์œ  ์‹๋ณ„์ž",
182
+ ),
183
+ # document_source FK
184
+ sa.Column(
185
+ "document_id",
186
+ UUID(as_uuid=True),
187
+ sa.ForeignKey("document_source.id", ondelete="SET NULL"),
188
+ nullable=True,
189
+ comment="์—ฐ๊ฒฐ๋œ ๋ฌธ์„œ ์›๋ณธ ID",
190
+ ),
191
+ # ์ฐธ์กฐ ํ•„๋“œ (FK ์—†๋Š” UUID - ์ฐธ์กฐ ํ…Œ์ด๋ธ” ๋ฏธ์กด์žฌ)
192
+ sa.Column(
193
+ "session_id",
194
+ UUID(as_uuid=True),
195
+ nullable=True,
196
+ comment="์ƒ๋‹ด ์„ธ์…˜ ID (FK ์—†์Œ)",
197
+ ),
198
+ sa.Column(
199
+ "message_id",
200
+ UUID(as_uuid=True),
201
+ nullable=True,
202
+ comment="๋ฉ”์‹œ์ง€ ID (FK ์—†์Œ)",
203
+ ),
204
+ # ํ ๋ฐ์ดํ„ฐ
205
+ sa.Column(
206
+ "doc_type",
207
+ sa.String(20),
208
+ server_default=sa.text("'CASE'"),
209
+ comment="๋ฌธ์„œ ํƒ€์ž…",
210
+ ),
211
+ sa.Column("complaint_text", sa.Text, nullable=False, comment="๋ฏผ์› ํ…์ŠคํŠธ"),
212
+ sa.Column("answer_text", sa.Text, nullable=False, comment="๋‹ต๋ณ€ ํ…์ŠคํŠธ"),
213
+ sa.Column("category", sa.String(50), nullable=True, comment="์นดํ…Œ๊ณ ๋ฆฌ"),
214
+ sa.Column(
215
+ "status",
216
+ sa.String(20),
217
+ server_default=sa.text("'pending'"),
218
+ comment="์ฒ˜๋ฆฌ ์ƒํƒœ: pending, processing, completed, skipped, failed",
219
+ ),
220
+ sa.Column(
221
+ "priority",
222
+ sa.Integer,
223
+ server_default="0",
224
+ comment="์šฐ์„ ์ˆœ์œ„ (๋†’์„์ˆ˜๋ก ๋จผ์ €)",
225
+ ),
226
+ sa.Column("skip_reason", sa.String(200), nullable=True, comment="๊ฑด๋„ˆ๋›ฐ๊ธฐ ์‚ฌ์œ "),
227
+ # ํƒ€์ž„์Šคํƒฌํ”„
228
+ sa.Column(
229
+ "created_at",
230
+ sa.DateTime(timezone=True),
231
+ server_default=sa.func.now(),
232
+ comment="์ƒ์„ฑ ์‹œ๊ฐ",
233
+ ),
234
+ sa.Column(
235
+ "processed_at",
236
+ sa.DateTime(timezone=True),
237
+ nullable=True,
238
+ comment="์ฒ˜๋ฆฌ ์™„๋ฃŒ ์‹œ๊ฐ",
239
+ ),
240
+ # CHECK ์ œ์•ฝ์กฐ๊ฑด
241
+ sa.CheckConstraint(
242
+ "status IN ('pending', 'processing', 'completed', 'skipped', 'failed')",
243
+ name="ck_queue_status_valid",
244
+ ),
245
+ )
246
+
247
+ # indexing_queue ์ธ๋ฑ์Šค
248
+ op.create_index("idx_indexqueue_status", "indexing_queue", ["status"])
249
+ op.create_index("idx_indexqueue_priority", "indexing_queue", ["priority", "created_at"])
250
+ op.create_index("idx_indexqueue_document_id", "indexing_queue", ["document_id"])
251
+
252
+ # ------------------------------------------------------------------
253
+ # 3. index_version ํ…Œ์ด๋ธ”
254
+ # ------------------------------------------------------------------
255
+ op.create_table(
256
+ "index_version",
257
+ # PK
258
+ sa.Column(
259
+ "id",
260
+ UUID(as_uuid=True),
261
+ server_default=sa.text("gen_random_uuid()"),
262
+ primary_key=True,
263
+ comment="์ธ๋ฑ์Šค ๋ฒ„์ „ ๊ณ ์œ  ์‹๋ณ„์ž",
264
+ ),
265
+ # ์ธ๋ฑ์Šค ์ •๋ณด
266
+ sa.Column(
267
+ "index_type",
268
+ sa.String(20),
269
+ nullable=False,
270
+ comment="์ธ๋ฑ์Šค ํƒ€์ž… (case, law, manual, notice)",
271
+ ),
272
+ sa.Column("version", sa.String(50), nullable=False, comment="์ธ๋ฑ์Šค ๋ฒ„์ „"),
273
+ sa.Column("total_documents", sa.Integer, nullable=False, comment="ํฌํ•จ ๋ฌธ์„œ ์ˆ˜"),
274
+ sa.Column(
275
+ "index_file_path",
276
+ sa.String(500),
277
+ nullable=False,
278
+ comment="FAISS ๏ฟฝ๏ฟฝ๏ฟฝ๋ฑ์Šค ํŒŒ์ผ ๊ฒฝ๋กœ",
279
+ ),
280
+ sa.Column(
281
+ "meta_file_path",
282
+ sa.String(500),
283
+ nullable=False,
284
+ comment="๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํŒŒ์ผ ๊ฒฝ๋กœ",
285
+ ),
286
+ sa.Column(
287
+ "snapshot_path",
288
+ sa.Text,
289
+ nullable=True,
290
+ comment="์Šค๋ƒ…์ƒท ๊ฒฝ๋กœ",
291
+ ),
292
+ # ์ƒํƒœ
293
+ sa.Column(
294
+ "built_at",
295
+ sa.DateTime(timezone=True),
296
+ server_default=sa.func.now(),
297
+ comment="๋นŒ๋“œ ์‹œ๊ฐ",
298
+ ),
299
+ sa.Column(
300
+ "is_active",
301
+ sa.Boolean,
302
+ server_default=sa.text("true"),
303
+ comment="ํ™œ์„ฑ ๋ฒ„์ „ ์—ฌ๋ถ€",
304
+ ),
305
+ sa.Column(
306
+ "build_duration_seconds",
307
+ sa.Float,
308
+ nullable=True,
309
+ comment="๋นŒ๋“œ ์†Œ์š” ์‹œ๊ฐ„ (์ดˆ)",
310
+ ),
311
+ sa.Column("notes", sa.Text, nullable=True, comment="๋น„๊ณ "),
312
+ )
313
+
314
+ # index_version ์ธ๋ฑ์Šค
315
+ op.create_index("idx_indexversion_active", "index_version", ["index_type", "is_active"])
316
+
317
+ # ------------------------------------------------------------------
318
+ # 4. updated_at ์ž๋™ ๊ฐฑ์‹  ํŠธ๋ฆฌ๊ฑฐ (document_source)
319
+ # ------------------------------------------------------------------
320
+ op.execute("""
321
+ CREATE OR REPLACE FUNCTION update_updated_at_column()
322
+ RETURNS TRIGGER AS $$
323
+ BEGIN
324
+ NEW.updated_at = NOW();
325
+ RETURN NEW;
326
+ END;
327
+ $$ LANGUAGE plpgsql;
328
+ """)
329
+
330
+ op.execute("""
331
+ CREATE TRIGGER trg_docsource_updated_at
332
+ BEFORE UPDATE ON document_source
333
+ FOR EACH ROW
334
+ EXECUTE FUNCTION update_updated_at_column();
335
+ """)
336
+
337
+
338
+ def downgrade() -> None:
339
+ # ํŠธ๋ฆฌ๊ฑฐ ๋ฐ ํ•จ์ˆ˜ ์ œ๊ฑฐ
340
+ op.execute("DROP TRIGGER IF EXISTS trg_docsource_updated_at ON document_source;")
341
+ op.execute("DROP FUNCTION IF EXISTS update_updated_at_column();")
342
+
343
+ # ํ…Œ์ด๋ธ” ์ œ๊ฑฐ (์˜์กด์„ฑ ์—ญ์ˆœ)
344
+ op.drop_table("index_version")
345
+ op.drop_table("indexing_queue")
346
+ op.drop_table("document_source")
src/inference/db/converters.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ORM <-> Dataclass / Pydantic ๋ณ€ํ™˜ ํ—ฌํผ.
3
+
4
+ DocumentSource ORM ์ธ์Šคํ„ด์Šค๋ฅผ ๊ธฐ์กด DocumentMetadata(dataclass) ๋˜๋Š”
5
+ DocumentMetadataSchema(Pydantic) ๋ชจ๋ธ๋กœ ์ƒํ˜ธ ๋ณ€ํ™˜ํ•œ๋‹ค.
6
+ """
7
+
8
+ from typing import Any, Dict
9
+
10
+ from src.inference.db.models import DocumentSource
11
+ from src.inference.index_manager import DocumentMetadata, IndexType
12
+ from src.inference.schemas import DocumentMetadataSchema
13
+
14
+ # ํƒ€์ž…๋ณ„ ์ „์šฉ ํ•„๋“œ ๋ชฉ๋ก (ORM <-> Dataclass/Pydantic ๋ณ€ํ™˜ ์‹œ ๊ณตํ†ต ์‚ฌ์šฉ)
15
+ _TYPE_SPECIFIC_FIELDS: tuple = (
16
+ "complaint_text",
17
+ "answer_text", # CASE
18
+ "law_number",
19
+ "article_number", # LAW
20
+ "enforcement_date", # LAW
21
+ "department", # MANUAL
22
+ "notice_number",
23
+ "effective_date", # NOTICE
24
+ )
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # ORM -> Dataclass
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def orm_to_dataclass(doc_source: DocumentSource) -> DocumentMetadata:
33
+ """DocumentSource ORM -> DocumentMetadata dataclass ๋ณ€ํ™˜.
34
+
35
+ ORM์˜ ํƒ€์ž…๋ณ„ ์ „์šฉ ํ•„๋“œ(complaint_text, law_number ๋“ฑ)๋Š”
36
+ extras dict์— ๋ชจ์•„์„œ ์ „๋‹ฌํ•œ๋‹ค.
37
+ """
38
+ # ํƒ€์ž…๋ณ„ ์ถ”๊ฐ€ ํ•„๋“œ๋ฅผ extras๋กœ ์ˆ˜์ง‘
39
+ extras: Dict[str, Any] = {}
40
+ if doc_source.metadata_:
41
+ extras.update(doc_source.metadata_)
42
+
43
+ for field_name in _TYPE_SPECIFIC_FIELDS:
44
+ value = getattr(doc_source, field_name, None)
45
+ if value is not None:
46
+ # date/datetime ๊ฐ์ฒด๋Š” ISO ๋ฌธ์ž์—ด๋กœ ์ง๋ ฌํ™”
47
+ extras[field_name] = value.isoformat() if hasattr(value, "isoformat") else value
48
+
49
+ return DocumentMetadata(
50
+ doc_id=str(doc_source.id),
51
+ doc_type=doc_source.source_type,
52
+ source=doc_source.source_name or "",
53
+ title=doc_source.title,
54
+ category=doc_source.category or "",
55
+ reliability_score=doc_source.reliability_score,
56
+ created_at=doc_source.created_at.isoformat(),
57
+ updated_at=doc_source.updated_at.isoformat(),
58
+ valid_from=(doc_source.valid_from.isoformat() if doc_source.valid_from else None),
59
+ valid_until=(doc_source.valid_until.isoformat() if doc_source.valid_until else None),
60
+ chunk_index=doc_source.chunk_index,
61
+ chunk_total=doc_source.total_chunks,
62
+ extras=extras,
63
+ )
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Dataclass -> ORM create kwargs
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def dataclass_to_orm(meta: DocumentMetadata, content: str) -> Dict[str, Any]:
72
+ """DocumentMetadata dataclass -> DocumentSource ์ƒ์„ฑ์šฉ kwargs ๋”•์…”๋„ˆ๋ฆฌ.
73
+
74
+ Parameters
75
+ ----------
76
+ meta : DocumentMetadata
77
+ ๋‚ด๋ถ€ dataclass ์ธ์Šคํ„ด์Šค.
78
+ content : str
79
+ ๋ฌธ์„œ ๋ณธ๋ฌธ ํ…์ŠคํŠธ (dataclass์—๋Š” content๊ฐ€ ์—†์Œ).
80
+
81
+ Returns
82
+ -------
83
+ dict
84
+ crud.create_document_source()์— ์ „๋‹ฌํ•  kwargs.
85
+ """
86
+ extras = dict(meta.extras) if meta.extras else {}
87
+
88
+ kwargs: Dict[str, Any] = {
89
+ "source_type": meta.doc_type,
90
+ "source_id": meta.doc_id,
91
+ "source_name": meta.source,
92
+ "title": meta.title,
93
+ "content": content,
94
+ "category": meta.category,
95
+ "chunk_index": meta.chunk_index,
96
+ "total_chunks": meta.chunk_total,
97
+ "reliability_score": meta.reliability_score,
98
+ "metadata_": {},
99
+ }
100
+
101
+ # ISO ๋ฌธ์ž์—ด -> datetime ๋ณ€ํ™˜ (valid_from/valid_until)
102
+ from datetime import datetime
103
+
104
+ if meta.valid_from:
105
+ kwargs["valid_from"] = datetime.fromisoformat(meta.valid_from)
106
+ if meta.valid_until:
107
+ kwargs["valid_until"] = datetime.fromisoformat(meta.valid_until)
108
+
109
+ # extras์—์„œ ํƒ€์ž…๋ณ„ ์ „์šฉ ํ•„๋“œ ์ถ”์ถœ
110
+ _type_field_map = {
111
+ "complaint_text": str,
112
+ "answer_text": str,
113
+ "law_number": str,
114
+ "article_number": str,
115
+ "enforcement_date": str, # DATE ์ปฌ๋Ÿผ์ด๋ฏ€๋กœ ๋ฌธ์ž์—ด ๊ทธ๋Œ€๋กœ ์ „๋‹ฌ
116
+ "department": str,
117
+ "notice_number": str,
118
+ "effective_date": str,
119
+ }
120
+ remaining_extras: Dict[str, Any] = {}
121
+ for key, value in extras.items():
122
+ if key in _type_field_map:
123
+ kwargs[key] = value
124
+ else:
125
+ remaining_extras[key] = value
126
+
127
+ kwargs["metadata_"] = remaining_extras
128
+ return kwargs
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # ORM -> Pydantic
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ def orm_to_pydantic(doc_source: DocumentSource) -> DocumentMetadataSchema:
137
+ """DocumentSource ORM -> DocumentMetadataSchema Pydantic ๋ชจ๋ธ ๋ณ€ํ™˜."""
138
+ # ํƒ€์ž…๋ณ„ ์ถ”๊ฐ€ ํ•„๋“œ + JSONB metadata๋ฅผ ํ•ฉ์‚ฐ
139
+ extra_meta: Dict[str, Any] = {}
140
+ if doc_source.metadata_:
141
+ extra_meta.update(doc_source.metadata_)
142
+
143
+ for field_name in _TYPE_SPECIFIC_FIELDS:
144
+ value = getattr(doc_source, field_name, None)
145
+ if value is not None:
146
+ extra_meta[field_name] = value.isoformat() if hasattr(value, "isoformat") else value
147
+
148
+ return DocumentMetadataSchema(
149
+ doc_id=str(doc_source.id),
150
+ source_type=IndexType(doc_source.source_type),
151
+ source_id=doc_source.source_id,
152
+ title=doc_source.title,
153
+ content=doc_source.content,
154
+ chunk_index=doc_source.chunk_index,
155
+ total_chunks=doc_source.total_chunks,
156
+ created_at=doc_source.created_at,
157
+ updated_at=doc_source.updated_at,
158
+ valid_from=doc_source.valid_from,
159
+ valid_until=doc_source.valid_until,
160
+ reliability_score=doc_source.reliability_score,
161
+ metadata=extra_meta,
162
+ )
src/inference/db/crud.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CRUD ๋ ˆ์ด์–ด (Unit of Work ํŒจํ„ด).
3
+
4
+ DocumentSource, IndexingQueue, IndexVersion ํ…Œ์ด๋ธ”์— ๋Œ€ํ•œ
5
+ ์ƒ์„ฑ/์กฐํšŒ/์ˆ˜์ •/์‚ญ์ œ ํ•จ์ˆ˜๋ฅผ ์ œ๊ณตํ•œ๋‹ค.
6
+ ๋ชจ๋“  ํ•จ์ˆ˜๋Š” ๋™๊ธฐ Session์„ ์ธ์ž๋กœ ๋ฐ›๋Š”๋‹ค.
7
+
8
+ ์ด ๋ชจ๋“ˆ์˜ ํ•จ์ˆ˜๋“ค์€ ๋‚ด๋ถ€์—์„œ commit์„ ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค.
9
+ ํŠธ๋žœ์žญ์…˜์˜ commit/rollback ์ œ์–ด๋Š” caller(์„œ๋น„์Šค ๊ณ„์ธต)์˜ ์ฑ…์ž„์ด๋‹ค.
10
+ ๋ณตํ•ฉ ์ž‘์—…์˜ ์›์ž์„ฑ์„ ๋ณด์žฅํ•˜๊ธฐ ์œ„ํ•ด flush๋งŒ ์ˆ˜ํ–‰ํ•˜์—ฌ DB์— SQL์„ ์ „์†กํ•˜๋˜,
11
+ ์ตœ์ข… ํ™•์ •์€ caller๊ฐ€ ๊ฒฐ์ •ํ•œ๋‹ค.
12
+ """
13
+
14
+ import uuid
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from sqlalchemy import func, select, update
19
+ from sqlalchemy.orm import Session
20
+
21
+ from src.inference.db.models import DocumentSource, IndexingQueue, IndexVersion
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # ์ƒ์ˆ˜ ์ •์˜
25
+ # ---------------------------------------------------------------------------
26
+
27
+ MAX_LIMIT = 1000
28
+
29
+ _ALLOWED_FILTER_COLUMNS = frozenset(
30
+ {
31
+ "source_type",
32
+ "source_id",
33
+ "status",
34
+ "category",
35
+ "source_name",
36
+ "embedding_version",
37
+ "version",
38
+ }
39
+ )
40
+
41
+ _IMMUTABLE_FIELDS = frozenset({"id", "created_at"})
42
+
43
+ _VALID_QUEUE_STATUSES = frozenset(
44
+ {
45
+ "pending",
46
+ "processing",
47
+ "completed",
48
+ "skipped",
49
+ "failed",
50
+ }
51
+ )
52
+
53
+
54
+ # ============================================================================
55
+ # DocumentSource CRUD
56
+ # ============================================================================
57
+
58
+
59
+ def create_document_source(db: Session, **kwargs: Any) -> DocumentSource:
60
+ """์ƒˆ ๋ฌธ์„œ ์›๋ณธ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค."""
61
+ doc = DocumentSource(**kwargs)
62
+ db.add(doc)
63
+ db.flush()
64
+ db.refresh(doc)
65
+ return doc
66
+
67
+
68
+ def get_document_source(db: Session, doc_id: uuid.UUID) -> Optional[DocumentSource]:
69
+ """ID๋กœ ๋ฌธ์„œ ์›๋ณธ์„ ์กฐํšŒํ•œ๋‹ค."""
70
+ return db.get(DocumentSource, doc_id)
71
+
72
+
73
+ def get_document_sources(
74
+ db: Session,
75
+ filters: Optional[Dict[str, Any]] = None,
76
+ skip: int = 0,
77
+ limit: int = 100,
78
+ ) -> List[DocumentSource]:
79
+ """ํ•„ํ„ฐ ์กฐ๊ฑด์— ๋งž๋Š” ๋ฌธ์„œ ์›๋ณธ ๋ชฉ๋ก์„ ์กฐํšŒํ•œ๋‹ค.
80
+
81
+ Parameters
82
+ ----------
83
+ filters : dict, optional
84
+ ์ปฌ๋Ÿผ๋ช…-๊ฐ’ ์Œ์˜ ํ•„ํ„ฐ ๋”•์…”๋„ˆ๋ฆฌ.
85
+ ์˜ˆ: {"source_type": "case", "status": "active"}
86
+ skip : int
87
+ ๊ฑด๋„ˆ๋›ธ ํ–‰ ์ˆ˜ (ํŽ˜์ด์ง€๋„ค์ด์…˜ ์˜คํ”„์…‹).
88
+ limit : int
89
+ ์ตœ๋Œ€ ๋ฐ˜ํ™˜ ํ–‰ ์ˆ˜.
90
+ """
91
+ limit = min(limit, MAX_LIMIT)
92
+ stmt = select(DocumentSource)
93
+
94
+ if filters:
95
+ for col_name, value in filters.items():
96
+ if col_name in _ALLOWED_FILTER_COLUMNS:
97
+ stmt = stmt.where(getattr(DocumentSource, col_name) == value)
98
+
99
+ stmt = stmt.offset(skip).limit(limit).order_by(DocumentSource.created_at.desc())
100
+ return list(db.scalars(stmt).all())
101
+
102
+
103
+ def update_document_source(
104
+ db: Session, doc_id: uuid.UUID, **kwargs: Any
105
+ ) -> Optional[DocumentSource]:
106
+ """๋ฌธ์„œ ์›๋ณธ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•œ๋‹ค.
107
+
108
+ ๋ณ€๊ฒฝํ•  ์ปฌ๋Ÿผ-๊ฐ’์„ kwargs๋กœ ์ „๋‹ฌํ•œ๋‹ค.
109
+ """
110
+ doc = db.get(DocumentSource, doc_id)
111
+ if doc is None:
112
+ return None
113
+
114
+ for key, value in kwargs.items():
115
+ if key in _IMMUTABLE_FIELDS:
116
+ continue
117
+ if hasattr(doc, key):
118
+ setattr(doc, key, value)
119
+
120
+ db.flush()
121
+ db.refresh(doc)
122
+ return doc
123
+
124
+
125
+ def delete_document_source(db: Session, doc_id: uuid.UUID) -> bool:
126
+ """๋ฌธ์„œ ์›๋ณธ ๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•œ๋‹ค. ์„ฑ๊ณต ์‹œ True ๋ฐ˜ํ™˜."""
127
+ doc = db.get(DocumentSource, doc_id)
128
+ if doc is None:
129
+ return False
130
+
131
+ db.delete(doc)
132
+ db.flush()
133
+ return True
134
+
135
+
136
+ def get_by_source_type_and_id(
137
+ db: Session, source_type: str, source_id: str
138
+ ) -> List[DocumentSource]:
139
+ """source_type + source_id ์กฐํ•ฉ์œผ๋กœ ๋ฌธ์„œ๋ฅผ ์กฐํšŒํ•œ๋‹ค.
140
+
141
+ ๋™์ผ ๋ฌธ์„œ์˜ ์—ฌ๋Ÿฌ ์ฒญํฌ๊ฐ€ ๋ฐ˜ํ™˜๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
142
+ """
143
+ stmt = (
144
+ select(DocumentSource)
145
+ .where(
146
+ DocumentSource.source_type == source_type,
147
+ DocumentSource.source_id == source_id,
148
+ )
149
+ .order_by(DocumentSource.chunk_index)
150
+ )
151
+ return list(db.scalars(stmt).all())
152
+
153
+
154
+ # ============================================================================
155
+ # IndexingQueue CRUD
156
+ # ============================================================================
157
+
158
+
159
+ def create_indexing_queue_item(db: Session, **kwargs: Any) -> IndexingQueue:
160
+ """์ธ๋ฑ์‹ฑ ๋Œ€๊ธฐ์—ด์— ์ƒˆ ํ•ญ๋ชฉ์„ ์ถ”๊ฐ€ํ•œ๋‹ค."""
161
+ item = IndexingQueue(**kwargs)
162
+ db.add(item)
163
+ db.flush()
164
+ db.refresh(item)
165
+ return item
166
+
167
+
168
+ def get_pending_items(db: Session, limit: int = 50) -> List[IndexingQueue]:
169
+ """pending ์ƒํƒœ์˜ ๋Œ€๊ธฐ์—ด ํ•ญ๋ชฉ์„ ์šฐ์„ ์ˆœ์œ„ ๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ ์กฐํšŒํ•œ๋‹ค."""
170
+ limit = min(limit, MAX_LIMIT)
171
+ stmt = (
172
+ select(IndexingQueue)
173
+ .where(IndexingQueue.status == "pending")
174
+ .order_by(IndexingQueue.priority.desc(), IndexingQueue.created_at)
175
+ .limit(limit)
176
+ )
177
+ return list(db.scalars(stmt).all())
178
+
179
+
180
+ def update_queue_status(
181
+ db: Session,
182
+ item_id: uuid.UUID,
183
+ status: str,
184
+ skip_reason: Optional[str] = None,
185
+ ) -> Optional[IndexingQueue]:
186
+ """๋Œ€๊ธฐ์—ด ํ•ญ๋ชฉ์˜ ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•œ๋‹ค.
187
+
188
+ completed/failed ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ ์‹œ processed_at์„ ์ž๋™ ์„ค์ •ํ•œ๋‹ค.
189
+ """
190
+ if status not in _VALID_QUEUE_STATUSES:
191
+ raise ValueError(
192
+ f"์œ ํšจํ•˜์ง€ ์•Š์€ ์ƒํƒœ: {status!r}. "
193
+ f"ํ—ˆ์šฉ ๊ฐ’: {', '.join(sorted(_VALID_QUEUE_STATUSES))}"
194
+ )
195
+
196
+ item = db.get(IndexingQueue, item_id)
197
+ if item is None:
198
+ return None
199
+
200
+ item.status = status
201
+ if skip_reason is not None:
202
+ item.skip_reason = skip_reason
203
+
204
+ if status in ("completed", "failed", "skipped"):
205
+ item.processed_at = datetime.now(timezone.utc)
206
+
207
+ db.flush()
208
+ db.refresh(item)
209
+ return item
210
+
211
+
212
+ def get_queue_stats(db: Session) -> Dict[str, int]:
213
+ """๋Œ€๊ธฐ์—ด ์ƒํƒœ๋ณ„ ๊ฑด์ˆ˜๋ฅผ ์ง‘๊ณ„ํ•œ๋‹ค.
214
+
215
+ Returns
216
+ -------
217
+ dict
218
+ {"pending": 10, "processing": 2, "completed": 50, ...}
219
+ """
220
+ stmt = select(IndexingQueue.status, func.count()).group_by(IndexingQueue.status)
221
+ rows = db.execute(stmt).all()
222
+ return {status: count for status, count in rows}
223
+
224
+
225
+ # ============================================================================
226
+ # IndexVersion CRUD
227
+ # ============================================================================
228
+
229
+
230
+ def create_index_version(db: Session, **kwargs: Any) -> IndexVersion:
231
+ """์ƒˆ ์ธ๋ฑ์Šค ๋ฒ„์ „ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค."""
232
+ ver = IndexVersion(**kwargs)
233
+ db.add(ver)
234
+ db.flush()
235
+ db.refresh(ver)
236
+ return ver
237
+
238
+
239
+ def get_active_version(db: Session, index_type: str) -> Optional[IndexVersion]:
240
+ """ํŠน์ • index_type์˜ ํ™œ์„ฑ ๋ฒ„์ „์„ ์กฐํšŒํ•œ๋‹ค.
241
+
242
+ index_type๋ณ„๋กœ active ๋ฒ„์ „์€ ์ตœ๋Œ€ 1๊ฐœ์—ฌ์•ผ ํ•œ๋‹ค.
243
+ """
244
+ stmt = (
245
+ select(IndexVersion)
246
+ .where(
247
+ IndexVersion.index_type == index_type,
248
+ IndexVersion.is_active.is_(True),
249
+ )
250
+ .order_by(IndexVersion.built_at.desc())
251
+ .limit(1)
252
+ )
253
+ return db.scalars(stmt).first()
254
+
255
+
256
+ def deactivate_versions(db: Session, index_type: str) -> int:
257
+ """ํŠน์ • index_type์˜ ๋ชจ๋“  ํ™œ์„ฑ ๋ฒ„์ „์„ ๋น„ํ™œ์„ฑํ™”ํ•œ๋‹ค.
258
+
259
+ ์ƒˆ ์ธ๋ฑ์Šค๋ฅผ ํ™œ์„ฑํ™”ํ•˜๊ธฐ ์ „์— ํ˜ธ์ถœํ•˜์—ฌ ๋‹จ์ผ ํ™œ์„ฑ ๋ฒ„์ „์„ ๋ณด์žฅํ•œ๋‹ค.
260
+
261
+ Returns
262
+ -------
263
+ int
264
+ ๋น„ํ™œ์„ฑํ™”๋œ ๋ ˆ์ฝ”๋“œ ์ˆ˜.
265
+ """
266
+ stmt = (
267
+ update(IndexVersion)
268
+ .where(
269
+ IndexVersion.index_type == index_type,
270
+ IndexVersion.is_active.is_(True),
271
+ )
272
+ .values(is_active=False)
273
+ )
274
+ result = db.execute(stmt)
275
+ db.flush()
276
+ return result.rowcount # type: ignore[return-value]
277
+
278
+
279
+ def activate_version(db: Session, version_id: uuid.UUID) -> Optional[IndexVersion]:
280
+ """ํŠน์ • ์ธ๋ฑ์Šค ๋ฒ„์ „์„ ํ™œ์„ฑํ™”ํ•œ๋‹ค.
281
+
282
+ ๋™์ผ index_type์˜ ๊ธฐ์กด ํ™œ์„ฑ ๋ฒ„์ „์„ ๋จผ์ € ๋น„ํ™œ์„ฑํ™”ํ•œ ๋’ค ๋Œ€์ƒ์„ ํ™œ์„ฑํ™”ํ•œ๋‹ค.
283
+
284
+ Race Condition ๋ฐฉ์ง€:
285
+ SELECT ... FOR UPDATE๋กœ ๋™์ผ index_type์˜ ๋ชจ๋“  ๋ฒ„์ „์— ํ–‰ ๋ ˆ๋ฒจ ์ž ๊ธˆ์„
286
+ ํš๋“ํ•œ ๋’ค deactivate/activate๋ฅผ ์ˆ˜ํ–‰ํ•œ๋‹ค. ๋™์‹œ ํ˜ธ์ถœ ์‹œ ํ›„๋ฐœ ํŠธ๋žœ์žญ์…˜์€
287
+ ์ž ๊ธˆ ํ•ด์ œ๊นŒ์ง€ ๋Œ€๊ธฐํ•˜๋ฏ€๋กœ ๋‹ค์ค‘ active ๋ฒ„์ „์ด ์ƒ๊ธฐ๋Š” ๋ฌธ์ œ๋ฅผ ๋ฐฉ์ง€ํ•œ๋‹ค.
288
+ (PostgreSQL ์ „์šฉ โ€” SQLite๋Š” FOR UPDATE๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๋Š”๋‹ค.)
289
+ """
290
+ ver = db.get(IndexVersion, version_id)
291
+ if ver is None:
292
+ return None
293
+
294
+ # ๋™์ผ index_type์˜ ๋ชจ๋“  ๋ฒ„์ „์— ๋Œ€ํ•ด ํ–‰ ๋ ˆ๋ฒจ ์ž ๊ธˆ ํš๋“ (PostgreSQL ์ „์šฉ)
295
+ lock_stmt = (
296
+ select(IndexVersion).where(IndexVersion.index_type == ver.index_type).with_for_update()
297
+ )
298
+ db.execute(lock_stmt)
299
+
300
+ # ์ž ๊ธˆ ํš๋“ ํ›„ ๋™์ผ ํƒ€์ž…์˜ ๊ธฐ์กด ํ™œ์„ฑ ๋ฒ„์ „ ๋น„ํ™œ์„ฑํ™”
301
+ deactivate_versions(db, ver.index_type)
302
+
303
+ ver.is_active = True
304
+ db.flush()
305
+ db.refresh(ver)
306
+ return ver
src/inference/db/database.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLAlchemy 2.0 ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—”์ง„/์„ธ์…˜ ์„ค์ •.
3
+
4
+ ๋™๊ธฐ ์„ธ์…˜ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์„ฑํ•˜๋ฉฐ, FastAPI ์˜์กด์„ฑ ์ฃผ์ž…(get_db)์„ ์ œ๊ณตํ•œ๋‹ค.
5
+ ๊ธฐ๋ณธ๊ฐ’์€ ๋กœ์ปฌ ๋‹จ์ผ ์‚ฌ์šฉ์ž MVP์— ๋งž์ถฐ GovOn ํ™ˆ ๋””๋ ‰ํ„ฐ๋ฆฌ ์•„๋ž˜ SQLite ํŒŒ์ผ์„ ์‚ฌ์šฉํ•œ๋‹ค.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Generator
12
+
13
+ from sqlalchemy import create_engine
14
+ from sqlalchemy.orm import Session, sessionmaker
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # ์—”์ง„ & ์„ธ์…˜ ํŒฉํ† ๋ฆฌ
20
+ # ---------------------------------------------------------------------------
21
+
22
+ _DEFAULT_GOVON_HOME = Path(os.getenv("GOVON_HOME", Path.home() / ".govon"))
23
+ _DEFAULT_DATABASE_URL = f"sqlite:///{_DEFAULT_GOVON_HOME / 'metadata.sqlite3'}"
24
+
25
+ DATABASE_URL: str = os.getenv("DATABASE_URL", _DEFAULT_DATABASE_URL)
26
+
27
+ if DATABASE_URL == _DEFAULT_DATABASE_URL:
28
+ logger.warning(
29
+ "DATABASE_URL ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•„ ๋กœ์ปฌ SQLite ๊ธฐ๋ณธ๊ฐ’์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. "
30
+ "๋ณ„๋„ RDBMS๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด DATABASE_URL์„ ๋ช…์‹œ์ ์œผ๋กœ ์„ค์ •ํ•˜์„ธ์š”."
31
+ )
32
+
33
+ engine_kwargs = {
34
+ "echo": os.getenv("SQL_ECHO", "").lower() in ("1", "true"),
35
+ }
36
+ if DATABASE_URL.startswith("sqlite:///"):
37
+ _DEFAULT_GOVON_HOME.mkdir(parents=True, exist_ok=True)
38
+ engine_kwargs["connect_args"] = {"check_same_thread": False}
39
+ else:
40
+ engine_kwargs.update(
41
+ {
42
+ "pool_size": 10,
43
+ "max_overflow": 20,
44
+ "pool_pre_ping": True,
45
+ "pool_recycle": 3600,
46
+ }
47
+ )
48
+
49
+ engine = create_engine(DATABASE_URL, **engine_kwargs)
50
+
51
+ SessionLocal = sessionmaker(
52
+ bind=engine,
53
+ autocommit=False,
54
+ autoflush=False,
55
+ )
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # FastAPI ์˜์กด์„ฑ ์ฃผ์ž…
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ def get_db() -> Generator[Session, None, None]:
64
+ """FastAPI Depends()์šฉ ์„ธ์…˜ ์ œ๋„ˆ๋ ˆ์ดํ„ฐ.
65
+
66
+ ์‚ฌ์šฉ ์˜ˆ์‹œ::
67
+
68
+ @router.get("/docs")
69
+ def list_docs(db: Session = Depends(get_db)):
70
+ ...
71
+ """
72
+ db = SessionLocal()
73
+ try:
74
+ yield db
75
+ finally:
76
+ db.rollback()
77
+ db.close()
src/inference/db/models.py ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLAlchemy 2.0 ORM ๋ชจ๋ธ.
3
+
4
+ ADR-004 + Issue #152 ๋ณ‘ํ•ฉ ์Šคํ‚ค๋งˆ ๊ธฐ๋ฐ˜ 3๊ฐœ ํ…Œ์ด๋ธ”:
5
+ - DocumentSource : ๋ฌธ์„œ ์›๋ณธ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
6
+ - IndexingQueue : ์ธ๋ฑ์‹ฑ ๋Œ€๊ธฐ์—ด
7
+ - IndexVersion : FAISS ์ธ๋ฑ์Šค ๋ฒ„์ „ ๊ด€๋ฆฌ
8
+
9
+ ๋ชจ๋“  ๋ชจ๋ธ์€ SQLAlchemy 2.0 Mapped ์Šคํƒ€์ผ(mapped_column, Mapped)์„ ์‚ฌ์šฉํ•˜๋ฉฐ,
10
+ server_default๋กœ DB ๋ ˆ๋ฒจ ๊ธฐ๋ณธ๊ฐ’์„ ์ง€์ •ํ•œ๋‹ค.
11
+ """
12
+
13
+ import uuid
14
+ from datetime import date, datetime
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from sqlalchemy import (
18
+ Boolean,
19
+ CheckConstraint,
20
+ Date,
21
+ DateTime,
22
+ Float,
23
+ ForeignKey,
24
+ Index,
25
+ Integer,
26
+ String,
27
+ Text,
28
+ UniqueConstraint,
29
+ func,
30
+ text,
31
+ )
32
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
33
+ from sqlalchemy.orm import (
34
+ DeclarativeBase,
35
+ Mapped,
36
+ mapped_column,
37
+ relationship,
38
+ )
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Base ํด๋ž˜์Šค
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ class Base(DeclarativeBase):
46
+ """๋ชจ๋“  ORM ๋ชจ๋ธ์˜ ๊ณตํ†ต ๊ธฐ๋ฐ˜ ํด๋ž˜์Šค."""
47
+
48
+ pass
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # DocumentSource
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ class DocumentSource(Base):
57
+ """document_source ํ…Œ์ด๋ธ” ORM ๋ชจ๋ธ.
58
+
59
+ ๋ชจ๋“  ๋ฌธ์„œ ํƒ€์ž…(case, law, manual, notice)์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ํ†ตํ•ฉ ๊ด€๋ฆฌํ•œ๋‹ค.
60
+ ํƒ€์ž…๋ณ„ ์ „์šฉ ์ปฌ๋Ÿผ์€ nullable๋กœ ์ฒ˜๋ฆฌํ•˜๋ฉฐ, ํ•ด๋‹นํ•˜์ง€ ์•Š๋Š” ํƒ€์ž…์—์„œ๋Š” NULL์ด๋‹ค.
61
+ """
62
+
63
+ __tablename__ = "document_source"
64
+ __table_args__ = (
65
+ UniqueConstraint(
66
+ "source_type",
67
+ "source_id",
68
+ "chunk_index",
69
+ name="uq_source_type_source_id_chunk",
70
+ ),
71
+ CheckConstraint(
72
+ "source_type IN ('case', 'law', 'manual', 'notice')",
73
+ name="ck_source_type_valid",
74
+ ),
75
+ CheckConstraint(
76
+ "status IN ('active', 'expired', 'deprecated')",
77
+ name="ck_status_valid",
78
+ ),
79
+ CheckConstraint(
80
+ "reliability_score >= 0.0 AND reliability_score <= 1.0",
81
+ name="ck_reliability_score_range",
82
+ ),
83
+ # ์„ฑ๋Šฅ ์ธ๋ฑ์Šค
84
+ Index("idx_docsource_source_type", "source_type"),
85
+ Index("idx_docsource_status", "status"),
86
+ Index("idx_docsource_category", "category"),
87
+ Index("idx_docsource_valid_range", "valid_from", "valid_until"),
88
+ Index("idx_docsource_metadata", "metadata", postgresql_using="gin"),
89
+ )
90
+
91
+ # -- ๊ธฐ๋ณธ ํ‚ค --
92
+ id: Mapped[uuid.UUID] = mapped_column(
93
+ UUID(as_uuid=True),
94
+ primary_key=True,
95
+ server_default=text("gen_random_uuid()"),
96
+ comment="๋ฌธ์„œ ๊ณ ์œ  ์‹๋ณ„์ž",
97
+ )
98
+
99
+ # -- ๊ณตํ†ต ํ•„๋“œ --
100
+ source_type: Mapped[str] = mapped_column(
101
+ String(20),
102
+ nullable=False,
103
+ comment="๋ฌธ์„œ ํƒ€์ž…: case, law, manual, notice",
104
+ )
105
+ source_id: Mapped[str] = mapped_column(
106
+ String(255),
107
+ nullable=False,
108
+ comment="์›๋ณธ ๋ฌธ์„œ ์‹๋ณ„์ž",
109
+ )
110
+ source_name: Mapped[Optional[str]] = mapped_column(
111
+ String(200),
112
+ nullable=True,
113
+ comment="์ถœ์ฒ˜๋ช… (AI Hub, ๋ฒ•์ œ์ฒ˜ ๋“ฑ)",
114
+ )
115
+ title: Mapped[str] = mapped_column(
116
+ String(500),
117
+ nullable=False,
118
+ comment="๋ฌธ์„œ ์ œ๋ชฉ",
119
+ )
120
+ content: Mapped[str] = mapped_column(
121
+ Text,
122
+ nullable=False,
123
+ comment="๋ฌธ์„œ ๋ณธ๋ฌธ",
124
+ )
125
+ category: Mapped[Optional[str]] = mapped_column(
126
+ String(50),
127
+ nullable=True,
128
+ comment="์นดํ…Œ๊ณ ๋ฆฌ (๋„๋กœ/๊ตํ†ต, ํ™˜๊ฒฝ/์œ„์ƒ ๋“ฑ)",
129
+ )
130
+ chunk_index: Mapped[int] = mapped_column(
131
+ Integer,
132
+ server_default=text("0"),
133
+ comment="์ฒญํฌ ์ธ๋ฑ์Šค",
134
+ )
135
+ total_chunks: Mapped[int] = mapped_column(
136
+ Integer,
137
+ server_default=text("1"),
138
+ comment="์ „์ฒด ์ฒญํฌ ์ˆ˜",
139
+ )
140
+ reliability_score: Mapped[float] = mapped_column(
141
+ Float,
142
+ server_default=text("0.6"),
143
+ comment="์‹ ๋ขฐ๋„ ์ ์ˆ˜ (0.0~1.0)",
144
+ )
145
+ valid_from: Mapped[Optional[datetime]] = mapped_column(
146
+ DateTime(timezone=True),
147
+ nullable=True,
148
+ comment="์œ ํšจ ์‹œ์ž‘์ผ",
149
+ )
150
+ valid_until: Mapped[Optional[datetime]] = mapped_column(
151
+ DateTime(timezone=True),
152
+ nullable=True,
153
+ comment="์œ ํšจ ์ข…๋ฃŒ์ผ",
154
+ )
155
+ status: Mapped[str] = mapped_column(
156
+ String(20),
157
+ server_default=text("'active'"),
158
+ comment="๋ฌธ์„œ ์ƒํƒœ: active, expired, deprecated",
159
+ )
160
+ version: Mapped[str] = mapped_column(
161
+ String(20),
162
+ server_default=text("'1.0'"),
163
+ comment="๋ฌธ์„œ ๋ฒ„์ „",
164
+ )
165
+ # 'metadata'๋Š” SQLAlchemy ๋‚ด๋ถ€ ์˜ˆ์•ฝ์–ด์ด๋ฏ€๋กœ Python ์†์„ฑ์€ metadata_๋กœ ๋งคํ•‘
166
+ metadata_: Mapped[Dict[str, Any]] = mapped_column(
167
+ "metadata",
168
+ JSONB,
169
+ server_default=text("'{}'::jsonb"),
170
+ comment="์ถ”๊ฐ€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (JSONB)",
171
+ )
172
+
173
+ # -- CASE ์ „์šฉ --
174
+ complaint_text: Mapped[Optional[str]] = mapped_column(
175
+ Text,
176
+ nullable=True,
177
+ comment="๋ฏผ์› ํ…์ŠคํŠธ (CASE ์ „์šฉ)",
178
+ )
179
+ answer_text: Mapped[Optional[str]] = mapped_column(
180
+ Text,
181
+ nullable=True,
182
+ comment="๋‹ต๋ณ€ ํ…์ŠคํŠธ (CASE ์ „์šฉ)",
183
+ )
184
+
185
+ # -- LAW ์ „์šฉ --
186
+ law_number: Mapped[Optional[str]] = mapped_column(
187
+ String(100),
188
+ nullable=True,
189
+ comment="๋ฒ•๋ฅ  ๋ฒˆํ˜ธ (LAW ์ „์šฉ)",
190
+ )
191
+ article_number: Mapped[Optional[str]] = mapped_column(
192
+ String(50),
193
+ nullable=True,
194
+ comment="์กฐํ•ญ ๋ฒˆํ˜ธ (LAW ์ „์šฉ)",
195
+ )
196
+ enforcement_date: Mapped[Optional[date]] = mapped_column(
197
+ Date,
198
+ nullable=True,
199
+ comment="์‹œํ–‰์ผ (LAW ์ „์šฉ)",
200
+ )
201
+
202
+ # -- MANUAL ์ „์šฉ --
203
+ department: Mapped[Optional[str]] = mapped_column(
204
+ String(100),
205
+ nullable=True,
206
+ comment="๋‹ด๋‹น ๋ถ€์„œ (MANUAL ์ „์šฉ)",
207
+ )
208
+
209
+ # -- NOTICE ์ „์šฉ --
210
+ notice_number: Mapped[Optional[str]] = mapped_column(
211
+ String(100),
212
+ nullable=True,
213
+ comment="๊ณต์‹œ ๋ฒˆํ˜ธ (NOTICE ์ „์šฉ)",
214
+ )
215
+ effective_date: Mapped[Optional[date]] = mapped_column(
216
+ Date,
217
+ nullable=True,
218
+ comment="์‹œํ–‰์ผ (NOTICE ์ „์šฉ)",
219
+ )
220
+
221
+ # -- ์ธ๋ฑ์‹ฑ ๊ด€๋ จ --
222
+ faiss_index_id: Mapped[Optional[int]] = mapped_column(
223
+ Integer,
224
+ nullable=True,
225
+ comment="FAISS ์ธ๋ฑ์Šค ๋‚ด ID",
226
+ )
227
+ embedding_version: Mapped[str] = mapped_column(
228
+ String(50),
229
+ server_default=text("'e5-large-v1'"),
230
+ comment="์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ๋ฒ„์ „",
231
+ )
232
+
233
+ # -- ํƒ€์ž„์Šคํƒฌํ”„ --
234
+ created_at: Mapped[datetime] = mapped_column(
235
+ DateTime(timezone=True),
236
+ server_default=func.now(),
237
+ comment="์ƒ์„ฑ ์‹œ๊ฐ",
238
+ )
239
+ updated_at: Mapped[datetime] = mapped_column(
240
+ DateTime(timezone=True),
241
+ server_default=func.now(),
242
+ onupdate=func.now(),
243
+ comment="์ˆ˜์ • ์‹œ๊ฐ",
244
+ )
245
+
246
+ # -- ๊ด€๊ณ„ --
247
+ queue_items: Mapped[List["IndexingQueue"]] = relationship(
248
+ back_populates="document",
249
+ cascade="all, delete-orphan",
250
+ lazy="select",
251
+ )
252
+
253
+ def __repr__(self) -> str:
254
+ return f"<DocumentSource(id={self.id}, " f"type={self.source_type}, title={self.title!r})>"
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # IndexingQueue
259
+ # ---------------------------------------------------------------------------
260
+
261
+
262
+ class IndexingQueue(Base):
263
+ """indexing_queue ํ…Œ์ด๋ธ” ORM ๋ชจ๋ธ.
264
+
265
+ ์ƒˆ๋กœ์šด ๋ฏผ์› ์ƒ๋‹ด์ด ๋“ค์–ด์˜ค๋ฉด ์ธ๋ฑ์‹ฑ ๋Œ€๊ธฐ์—ด์— ์ถ”๊ฐ€๋˜๋ฉฐ,
266
+ ๋ฐฐ์น˜ ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ฃผ๊ธฐ์ ์œผ๋กœ pending ํ•ญ๋ชฉ์„ ์†Œ๋น„ํ•œ๋‹ค.
267
+ """
268
+
269
+ __tablename__ = "indexing_queue"
270
+ __table_args__ = (
271
+ CheckConstraint(
272
+ "status IN ('pending', 'processing', 'completed', 'skipped', 'failed')",
273
+ name="ck_queue_status_valid",
274
+ ),
275
+ # ์„ฑ๋Šฅ ์ธ๋ฑ์Šค
276
+ Index("idx_indexqueue_status", "status"),
277
+ Index("idx_indexqueue_priority", "priority", "created_at"),
278
+ Index("idx_indexqueue_document_id", "document_id"),
279
+ )
280
+
281
+ id: Mapped[uuid.UUID] = mapped_column(
282
+ UUID(as_uuid=True),
283
+ primary_key=True,
284
+ server_default=text("gen_random_uuid()"),
285
+ comment="ํ ํ•ญ๋ชฉ ๊ณ ์œ  ์‹๋ณ„์ž",
286
+ )
287
+ document_id: Mapped[Optional[uuid.UUID]] = mapped_column(
288
+ UUID(as_uuid=True),
289
+ ForeignKey("document_source.id", ondelete="SET NULL"),
290
+ nullable=True,
291
+ comment="์—ฐ๊ฒฐ๋œ ๋ฌธ์„œ ์›๋ณธ ID",
292
+ )
293
+ session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
294
+ UUID(as_uuid=True),
295
+ nullable=True,
296
+ comment="์ƒ๋‹ด ์„ธ์…˜ ID (FK ์—†์Œ)",
297
+ )
298
+ message_id: Mapped[Optional[uuid.UUID]] = mapped_column(
299
+ UUID(as_uuid=True),
300
+ nullable=True,
301
+ comment="๋ฉ”์‹œ์ง€ ID (FK ์—†์Œ)",
302
+ )
303
+ doc_type: Mapped[str] = mapped_column(
304
+ String(20),
305
+ server_default=text("'CASE'"),
306
+ comment="๋ฌธ์„œ ํƒ€์ž…",
307
+ )
308
+ complaint_text: Mapped[str] = mapped_column(
309
+ Text,
310
+ nullable=False,
311
+ comment="๋ฏผ์› ํ…์ŠคํŠธ",
312
+ )
313
+ answer_text: Mapped[str] = mapped_column(
314
+ Text,
315
+ nullable=False,
316
+ comment="๋‹ต๋ณ€ ํ…์ŠคํŠธ",
317
+ )
318
+ category: Mapped[Optional[str]] = mapped_column(
319
+ String(50),
320
+ nullable=True,
321
+ comment="์นดํ…Œ๊ณ ๋ฆฌ",
322
+ )
323
+ status: Mapped[str] = mapped_column(
324
+ String(20),
325
+ server_default=text("'pending'"),
326
+ comment="์ฒ˜๋ฆฌ ์ƒํƒœ: pending, processing, completed, skipped, failed",
327
+ )
328
+ priority: Mapped[int] = mapped_column(
329
+ Integer,
330
+ server_default=text("0"),
331
+ comment="์šฐ์„ ์ˆœ์œ„ (๋†’์„์ˆ˜๋ก ๋จผ์ €)",
332
+ )
333
+ skip_reason: Mapped[Optional[str]] = mapped_column(
334
+ String(200),
335
+ nullable=True,
336
+ comment="๊ฑด๋„ˆ๋›ฐ๊ธฐ ์‚ฌ์œ ",
337
+ )
338
+ created_at: Mapped[datetime] = mapped_column(
339
+ DateTime(timezone=True),
340
+ server_default=func.now(),
341
+ comment="์ƒ์„ฑ ์‹œ๊ฐ",
342
+ )
343
+ processed_at: Mapped[Optional[datetime]] = mapped_column(
344
+ DateTime(timezone=True),
345
+ nullable=True,
346
+ comment="์ฒ˜๋ฆฌ ์™„๋ฃŒ ์‹œ๊ฐ",
347
+ )
348
+
349
+ # -- ๊ด€๊ณ„ --
350
+ document: Mapped[Optional["DocumentSource"]] = relationship(
351
+ back_populates="queue_items",
352
+ lazy="select",
353
+ )
354
+
355
+ def __repr__(self) -> str:
356
+ return f"<IndexingQueue(id={self.id}, " f"status={self.status}, doc_type={self.doc_type})>"
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # IndexVersion
361
+ # ---------------------------------------------------------------------------
362
+
363
+
364
+ class IndexVersion(Base):
365
+ """index_version ํ…Œ์ด๋ธ” ORM ๋ชจ๋ธ.
366
+
367
+ FAISS ์ธ๋ฑ์Šค ๋นŒ๋“œ ์ด๋ ฅ์„ ๊ด€๋ฆฌํ•˜๋ฉฐ,
368
+ index_type๋ณ„๋กœ ํ•˜๋‚˜์˜ active ๋ฒ„์ „๋งŒ ์œ ์ง€ํ•œ๋‹ค.
369
+ """
370
+
371
+ __tablename__ = "index_version"
372
+ __table_args__ = (
373
+ CheckConstraint(
374
+ "index_type IN ('case', 'law', 'manual', 'notice')",
375
+ name="ck_index_type_valid",
376
+ ),
377
+ Index("idx_indexversion_active", "index_type", "is_active"),
378
+ Index(
379
+ "uq_indexversion_one_active_per_type",
380
+ "index_type",
381
+ unique=True,
382
+ postgresql_where=text("is_active = true"),
383
+ ),
384
+ )
385
+
386
+ id: Mapped[uuid.UUID] = mapped_column(
387
+ UUID(as_uuid=True),
388
+ primary_key=True,
389
+ server_default=text("gen_random_uuid()"),
390
+ comment="์ธ๋ฑ์Šค ๋ฒ„์ „ ๊ณ ์œ  ์‹๋ณ„์ž",
391
+ )
392
+ index_type: Mapped[str] = mapped_column(
393
+ String(20),
394
+ nullable=False,
395
+ comment="์ธ๋ฑ์Šค ํƒ€์ž… (case, law, manual, notice)",
396
+ )
397
+ version: Mapped[str] = mapped_column(
398
+ String(50),
399
+ nullable=False,
400
+ comment="์ธ๋ฑ์Šค ๋ฒ„์ „ (์˜ˆ: v1.0.0)",
401
+ )
402
+ total_documents: Mapped[int] = mapped_column(
403
+ Integer,
404
+ nullable=False,
405
+ comment="ํฌํ•จ ๋ฌธ์„œ ์ˆ˜",
406
+ )
407
+ index_file_path: Mapped[str] = mapped_column(
408
+ String(500),
409
+ nullable=False,
410
+ comment="FAISS ์ธ๋ฑ์Šค ํŒŒ์ผ ๊ฒฝ๋กœ",
411
+ )
412
+ meta_file_path: Mapped[str] = mapped_column(
413
+ String(500),
414
+ nullable=False,
415
+ comment="๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํŒŒ์ผ ๊ฒฝ๋กœ",
416
+ )
417
+ snapshot_path: Mapped[Optional[str]] = mapped_column(
418
+ Text,
419
+ nullable=True,
420
+ comment="์Šค๋ƒ…์ƒท ๊ฒฝ๋กœ",
421
+ )
422
+ built_at: Mapped[datetime] = mapped_column(
423
+ DateTime(timezone=True),
424
+ server_default=func.now(),
425
+ comment="๋นŒ๋“œ ์‹œ๊ฐ",
426
+ )
427
+ is_active: Mapped[bool] = mapped_column(
428
+ Boolean,
429
+ server_default=text("true"),
430
+ comment="ํ™œ์„ฑ ๋ฒ„์ „ ์—ฌ๋ถ€",
431
+ )
432
+ build_duration_seconds: Mapped[Optional[float]] = mapped_column(
433
+ Float,
434
+ nullable=True,
435
+ comment="๋นŒ๋“œ ์†Œ์š” ์‹œ๊ฐ„ (์ดˆ)",
436
+ )
437
+ notes: Mapped[Optional[str]] = mapped_column(
438
+ Text,
439
+ nullable=True,
440
+ comment="๋น„๊ณ ",
441
+ )
442
+
443
+ def __repr__(self) -> str:
444
+ return (
445
+ f"<IndexVersion(id={self.id}, "
446
+ f"type={self.index_type}, version={self.version}, "
447
+ f"active={self.is_active})>"
448
+ )
src/inference/document_processor.py ADDED
@@ -0,0 +1,545 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DocumentProcessor: ๋‹คํ˜•์‹ ๋ฌธ์„œ ํŒŒ์‹ฑ ๋ฐ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ์ฒญํ‚น ๋ชจ๋“ˆ.
3
+
4
+ ์ด์Šˆ #156 โ€” PDF(PyMuPDF), HWP, TXT ํŒŒ์„œ๋ฅผ ํ†ตํ•ฉํ•˜๊ณ ,
5
+ ์˜๋ฏธ ๋‹จ์œ„(์กฐ/ํ•ญ/ํ˜ธ, ๋ฌธ๋‹จ) + ๊ณ ์ • ํฌ๊ธฐ(512ํ† ํฐ, 128ํ† ํฐ ์˜ค๋ฒ„๋žฉ) ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ์ฒญํ‚น์„ ์ˆ˜ํ–‰ํ•œ๋‹ค.
6
+
7
+ ADR-004 Section B.3 ์ฐธ์กฐ.
8
+ """
9
+
10
+ import hashlib
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ from loguru import logger
18
+
19
+ from src.inference.index_manager import DocumentMetadata, IndexType
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # ํ† ํฌ๋‚˜์ด์ € (ํ† ํฐ ๊ธฐ๋ฐ˜ ์ฒญํ‚น์šฉ)
23
+ # ---------------------------------------------------------------------------
24
+
25
+ _LOAD_FAILED = object() # ์„ผํ‹ฐ๋„: ๋กœ๋“œ ์‹คํŒจ ํ™•์ •
26
+ _tokenizer = None # None=๋ฏธ์‹œ๋„, _LOAD_FAILED=์‹คํŒจํ™•์ •
27
+
28
+
29
+ def _get_tokenizer():
30
+ """transformers ํ† ํฌ๋‚˜์ด์ €๋ฅผ lazy-loadํ•œ๋‹ค.
31
+
32
+ EXAONE ํ† ํฌ๋‚˜์ด์ €๊ฐ€ ์—†์œผ๋ฉด ๋‹จ์ˆœ ๋ฌธ์ž ๊ธฐ๋ฐ˜ ๊ทผ์‚ฌ๋กœ ํด๋ฐฑ.
33
+ ๋กœ๋“œ ์‹คํŒจ ์‹œ ์„ผํ‹ฐ๋„์„ ์„ค์ •ํ•˜์—ฌ ์žฌ์‹œ๋„๋ฅผ ๋ฐฉ์ง€ํ•œ๋‹ค.
34
+ """
35
+ global _tokenizer
36
+ if _tokenizer is _LOAD_FAILED:
37
+ return None
38
+ if _tokenizer is not None:
39
+ return _tokenizer
40
+ try:
41
+ from transformers import AutoTokenizer
42
+
43
+ _tokenizer = AutoTokenizer.from_pretrained(
44
+ "LGAI-EXAONE/EXAONE-Deep-7.8B",
45
+ trust_remote_code=True,
46
+ )
47
+ logger.info("EXAONE ํ† ํฌ๋‚˜์ด์ € ๋กœ๋“œ ์™„๋ฃŒ")
48
+ except Exception:
49
+ logger.warning("EXAONE ํ† ํฌ๋‚˜์ด์ € ๋กœ๋“œ ์‹คํŒจ โ€” ๋ฌธ์ž ๊ธฐ๋ฐ˜ ํด๋ฐฑ ์‚ฌ์šฉ")
50
+ _tokenizer = _LOAD_FAILED
51
+ return None if _tokenizer is _LOAD_FAILED else _tokenizer
52
+
53
+
54
+ def _count_tokens(text: str) -> int:
55
+ """ํ…์ŠคํŠธ์˜ ํ† ํฐ ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค."""
56
+ tok = _get_tokenizer()
57
+ if tok is not None:
58
+ return len(tok.encode(text, add_special_tokens=False))
59
+ # ํด๋ฐฑ: ํ•œ๊ตญ์–ด ํ‰๊ท  1.5์ž โ‰ˆ 1ํ† ํฐ ๊ทผ์‚ฌ
60
+ return max(1, len(text) // 2)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # ํŒŒ์„œ (PDF / HWP / TXT)
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def _parse_pdf_pages(file_path: str) -> List[Tuple[int, str]]:
69
+ """PyMuPDF๋กœ PDF์˜ ํŽ˜์ด์ง€๋ณ„ ํ…์ŠคํŠธ๋ฅผ ์ถ”์ถœํ•œ๋‹ค."""
70
+ try:
71
+ import fitz # PyMuPDF
72
+ except ImportError as e:
73
+ raise ImportError("PyMuPDF๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค: pip install PyMuPDF") from e
74
+
75
+ pages: List[Tuple[int, str]] = []
76
+ with fitz.open(file_path) as doc:
77
+ for page_number, page in enumerate(doc, start=1):
78
+ text = page.get_text("text")
79
+ if text.strip():
80
+ pages.append((page_number, text))
81
+ return pages
82
+
83
+
84
+ def _parse_pdf(file_path: str) -> str:
85
+ """PyMuPDF๋กœ PDF ํ…์ŠคํŠธ๋ฅผ ์ถ”์ถœํ•œ๋‹ค."""
86
+ pages = _parse_pdf_pages(file_path)
87
+ return "\n\n".join(text for _, text in pages)
88
+
89
+
90
+ def _parse_hwp(file_path: str) -> str:
91
+ """HWP ํ…์ŠคํŠธ๋ฅผ ์ถ”์ถœํ•œ๋‹ค.
92
+
93
+ pyhwp ๋˜๋Š” ํ˜ธํ™˜ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ํ•„์š”ํ•˜๋‹ค. PyPI์— ์•ˆ์ •์ ์ธ HWP ํŒŒ์„œ๊ฐ€
94
+ ์—†์œผ๋ฏ€๋กœ ๋Ÿฐํƒ€์ž„ ImportError๋กœ ์•ˆ๋‚ดํ•œ๋‹ค.
95
+ """
96
+ try:
97
+ import hwp
98
+ except ImportError as e:
99
+ raise ImportError(
100
+ "HWP ํŒŒ์„œ๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. " "pyhwp ๋˜๋Š” ํ˜ธํ™˜ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์„ค์น˜ํ•ด ์ฃผ์„ธ์š”."
101
+ ) from e
102
+
103
+ doc = hwp.open(file_path)
104
+ try:
105
+ paragraphs: List[str] = []
106
+ for paragraph in doc.paragraphs:
107
+ text = paragraph.text.strip()
108
+ if text:
109
+ paragraphs.append(text)
110
+ return "\n\n".join(paragraphs)
111
+ finally:
112
+ if hasattr(doc, "close"):
113
+ doc.close()
114
+
115
+
116
+ def _parse_txt(file_path: str) -> str:
117
+ """TXT ํŒŒ์ผ์„ UTF-8๋กœ ์ฝ๋Š”๋‹ค. ์‹คํŒจ ์‹œ cp949 ํด๋ฐฑ."""
118
+ path = Path(file_path)
119
+ for encoding in ("utf-8", "cp949", "euc-kr"):
120
+ try:
121
+ return path.read_text(encoding=encoding)
122
+ except (UnicodeDecodeError, LookupError):
123
+ continue
124
+ raise ValueError(f"ํ…์ŠคํŠธ ํŒŒ์ผ ์ธ์ฝ”๋”ฉ์„ ์‹๋ณ„ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {file_path}")
125
+
126
+
127
+ _PARSERS = {
128
+ ".pdf": _parse_pdf,
129
+ ".hwp": _parse_hwp,
130
+ ".txt": _parse_txt,
131
+ }
132
+
133
+ _PAGE_PARSERS = {
134
+ ".pdf": _parse_pdf_pages,
135
+ }
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # ํ…์ŠคํŠธ ์ •์ œ
140
+ # ---------------------------------------------------------------------------
141
+
142
+ # ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ, ๋จธ๋ฆฌ๊ธ€/๋ฐ”๋‹ฅ๊ธ€ ํŒจํ„ด
143
+ _HEADER_FOOTER_RE = re.compile(
144
+ r"^[\s]*[-โ€“โ€”]?\s*\d+\s*[-โ€“โ€”]?\s*$", # ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ๋งŒ ์žˆ๋Š” ์ค„
145
+ re.MULTILINE,
146
+ )
147
+ _MULTI_NEWLINE_RE = re.compile(r"\n{3,}")
148
+ _MULTI_SPACE_RE = re.compile(r"[ \t]{2,}")
149
+
150
+
151
+ def _clean_text(text: str) -> str:
152
+ """์ถ”์ถœ๋œ ์›์‹œ ํ…์ŠคํŠธ๋ฅผ ์ •์ œํ•œ๋‹ค."""
153
+ text = _HEADER_FOOTER_RE.sub("", text)
154
+ text = _MULTI_NEWLINE_RE.sub("\n\n", text)
155
+ text = _MULTI_SPACE_RE.sub(" ", text)
156
+ return text.strip()
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # ์˜๋ฏธ ๋‹จ์œ„ ๋ถ„ํ• 
161
+ # ---------------------------------------------------------------------------
162
+
163
+ # ๋ฒ•๋ น: ์ œN์กฐ, ์ œNํ•ญ, ์ œNํ˜ธ
164
+ _LAW_ARTICLE_RE = re.compile(r"(?=\n\s*์ œ\s*\d+\s*์กฐ(?:์˜\d+)?\s*[\(๏ผˆ])")
165
+ # ๋ฌธ๋‹จ ๋ถ„ํ•  (๋นˆ ์ค„ ๊ธฐ์ค€)
166
+ _PARAGRAPH_RE = re.compile(r"\n\s*\n")
167
+
168
+
169
+ def _split_semantic(text: str, doc_type: IndexType) -> List[str]:
170
+ """๋ฌธ์„œ ํƒ€์ž…์— ๋”ฐ๋ผ ์˜๋ฏธ ๋‹จ์œ„๋กœ ๋ถ„ํ• ํ•œ๋‹ค.
171
+
172
+ - LAW: ์กฐ/ํ•ญ ๋‹จ์œ„
173
+ - MANUAL/NOTICE: ๋ฌธ๋‹จ(๋นˆ ์ค„) ๋‹จ์œ„
174
+ - CASE: ๋ฌธ๋‹จ ๋‹จ์œ„
175
+ """
176
+ if doc_type == IndexType.LAW:
177
+ segments = _LAW_ARTICLE_RE.split(text)
178
+ else:
179
+ segments = _PARAGRAPH_RE.split(text)
180
+
181
+ return [s.strip() for s in segments if s.strip()]
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # ๊ณ ์ • ํฌ๊ธฐ ์ฒญํ‚น (ํ† ํฐ ๊ธฐ๋ฐ˜)
186
+ # ---------------------------------------------------------------------------
187
+
188
+
189
+ def _chunk_fixed(
190
+ text: str,
191
+ chunk_size: int = 512,
192
+ chunk_overlap: int = 128,
193
+ ) -> List[str]:
194
+ """ํ† ํฐ ๊ธฐ๋ฐ˜ ๊ณ ์ • ํฌ๊ธฐ ์ฒญํ‚น.
195
+
196
+ ํ† ํฌ๋‚˜์ด์ €๊ฐ€ ๋กœ๋“œ๋œ ๊ฒฝ์šฐ ์ •ํ™•ํ•œ ํ† ํฐ ๋ถ„ํ• ,
197
+ ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ๋ฌธ์ž ๊ธฐ๋ฐ˜ ๊ทผ์‚ฌ ๋ถ„ํ• ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค.
198
+ """
199
+ # overlap์ด chunk_size ์ด์ƒ์ด๋ฉด ๋ณด์ • (๋ฌดํ•œ๋ฃจํ”„ ๋ฐฉ์ง€)
200
+ if chunk_overlap >= chunk_size:
201
+ chunk_overlap = chunk_size // 4
202
+
203
+ tok = _get_tokenizer()
204
+
205
+ if tok is not None:
206
+ token_ids = tok.encode(text, add_special_tokens=False)
207
+ if len(token_ids) <= chunk_size:
208
+ return [text]
209
+
210
+ chunks: List[str] = []
211
+ start = 0
212
+ step = max(1, chunk_size - chunk_overlap)
213
+ while start < len(token_ids):
214
+ end = min(start + chunk_size, len(token_ids))
215
+ chunk_text = tok.decode(token_ids[start:end], skip_special_tokens=True)
216
+ if chunk_text.strip():
217
+ chunks.append(chunk_text.strip())
218
+ if end >= len(token_ids):
219
+ break
220
+ start += step
221
+ return chunks
222
+
223
+ # ํด๋ฐฑ: ๋ฌธ์ž ๊ธฐ๋ฐ˜ ๊ทผ์‚ฌ (ํ•œ๊ตญ์–ด ~2์ž โ‰ˆ 1ํ† ํฐ)
224
+ char_size = chunk_size * 2
225
+ char_overlap = chunk_overlap * 2
226
+ if len(text) <= char_size:
227
+ return [text]
228
+
229
+ chunks = []
230
+ start = 0
231
+ step = max(1, char_size - char_overlap)
232
+ while start < len(text):
233
+ end = min(start + char_size, len(text))
234
+ chunk_text = text[start:end].strip()
235
+ if chunk_text:
236
+ chunks.append(chunk_text)
237
+ if end >= len(text):
238
+ break
239
+ start += step
240
+ return chunks
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ์ฒญํ‚น
245
+ # ---------------------------------------------------------------------------
246
+
247
+
248
+ def _hybrid_chunk(
249
+ text: str,
250
+ doc_type: IndexType,
251
+ chunk_size: int = 512,
252
+ chunk_overlap: int = 128,
253
+ min_chunk_tokens: int = 50,
254
+ ) -> List[str]:
255
+ """์˜๋ฏธ ๋‹จ์œ„ + ๊ณ ์ • ํฌ๊ธฐ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ์ฒญํ‚น.
256
+
257
+ 1๋‹จ๊ณ„: ์˜๋ฏธ ๋‹จ์œ„ ๋ถ„ํ•  (์กฐ/ํ•ญ, ๋ฌธ๋‹จ)
258
+ 2๋‹จ๊ณ„: ํฐ ์„ธ๊ทธ๋จผํŠธ๋Š” ๊ณ ์ • ํฌ๊ธฐ๋กœ ์žฌ๋ถ„ํ• 
259
+ 3๋‹จ๊ณ„: ์ž‘์€ ์„ธ๊ทธ๋จผํŠธ๋Š” ์ธ์ ‘ ์„ธ๊ทธ๋จผํŠธ์™€ ๋ณ‘ํ•ฉ
260
+ """
261
+ if not text.strip():
262
+ return []
263
+
264
+ segments = _split_semantic(text, doc_type)
265
+
266
+ if not segments:
267
+ return _chunk_fixed(text, chunk_size, chunk_overlap)
268
+
269
+ chunks: List[str] = []
270
+ buffer = ""
271
+
272
+ for segment in segments:
273
+ seg_tokens = _count_tokens(segment)
274
+
275
+ if seg_tokens > chunk_size:
276
+ # ๋ฒ„ํผ์— ์Œ“์ธ ๊ฒƒ ๋จผ์ € ์ฒ˜๋ฆฌ
277
+ if buffer.strip():
278
+ if _count_tokens(buffer) > chunk_size:
279
+ chunks.extend(_chunk_fixed(buffer, chunk_size, chunk_overlap))
280
+ else:
281
+ chunks.append(buffer.strip())
282
+ buffer = ""
283
+ # ํฐ ์„ธ๊ทธ๋จผํŠธ๋Š” ๊ณ ์ • ํฌ๊ธฐ๋กœ ๋ถ„ํ• 
284
+ chunks.extend(_chunk_fixed(segment, chunk_size, chunk_overlap))
285
+ elif _count_tokens(buffer + "\n\n" + segment if buffer else segment) > chunk_size:
286
+ # ๋ฒ„ํผ + ํ˜„์žฌ ์„ธ๊ทธ๋จผํŠธ๊ฐ€ chunk_size๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด ๋ฒ„ํผ flush
287
+ if buffer.strip():
288
+ chunks.append(buffer.strip())
289
+ buffer = segment
290
+ else:
291
+ # ๋ฒ„ํผ์— ์ถ”๊ฐ€
292
+ buffer = buffer + "\n\n" + segment if buffer else segment
293
+
294
+ # ๋‚จ์€ ๋ฒ„ํผ ์ฒ˜๋ฆฌ
295
+ if buffer.strip():
296
+ if _count_tokens(buffer) > chunk_size:
297
+ chunks.extend(_chunk_fixed(buffer, chunk_size, chunk_overlap))
298
+ else:
299
+ chunks.append(buffer.strip())
300
+
301
+ # ์ตœ์†Œ ํ† ํฐ ๋ฏธ๋งŒ ์ฒญํฌ ๋ณ‘ํ•ฉ
302
+ merged: List[str] = []
303
+ for chunk in chunks:
304
+ if merged and _count_tokens(chunk) < min_chunk_tokens:
305
+ candidate = merged[-1] + "\n\n" + chunk
306
+ if _count_tokens(candidate) <= chunk_size:
307
+ merged[-1] = candidate
308
+ continue
309
+ merged.append(chunk)
310
+
311
+ return merged if merged else []
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # BatchResult
316
+ # ---------------------------------------------------------------------------
317
+
318
+
319
+ @dataclass
320
+ class BatchResult:
321
+ """process_batch ๋ฐ˜ํ™˜ ํƒ€์ž…. ์„ฑ๊ณต/์‹คํŒจ ์ •๋ณด๋ฅผ ๋ชจ๋‘ ํฌํ•จํ•œ๋‹ค."""
322
+
323
+ succeeded: List[DocumentMetadata] = field(default_factory=list)
324
+ failed: List[Tuple[str, str]] = field(default_factory=list) # [(file_path, error)]
325
+
326
+ @property
327
+ def total_chunks(self) -> int:
328
+ return len(self.succeeded)
329
+
330
+ @property
331
+ def success_count(self) -> int:
332
+ return self.total_chunks - len(self.failed) if not self.failed else self._count_files()
333
+
334
+ def _count_files(self) -> int:
335
+ seen = set()
336
+ for m in self.succeeded:
337
+ seen.add(m.extras.get("file_path", ""))
338
+ return len(seen)
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # DocumentProcessor
343
+ # ---------------------------------------------------------------------------
344
+
345
+ # ๋ฌธ์„œ ํƒ€์ž…๋ณ„ ๊ธฐ๋ณธ ์‹ ๋ขฐ๋„ (ADR-004 Table)
346
+ _DEFAULT_RELIABILITY: Dict[IndexType, float] = {
347
+ IndexType.CASE: 0.6,
348
+ IndexType.LAW: 1.0,
349
+ IndexType.MANUAL: 0.9,
350
+ IndexType.NOTICE: 0.7,
351
+ }
352
+
353
+
354
+ class DocumentProcessor:
355
+ """๋‹คํ˜•์‹ ๋ฌธ์„œ๋ฅผ ํŒŒ์‹ฑํ•˜๊ณ  ์ฒญํฌ ๋ถ„ํ• ํ•˜์—ฌ DocumentMetadata ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
356
+
357
+ Parameters
358
+ ----------
359
+ chunk_size : int
360
+ ์ฒญํฌ๋‹น ์ตœ๋Œ€ ํ† ํฐ ์ˆ˜ (๊ธฐ๋ณธ 512).
361
+ chunk_overlap : int
362
+ ์ฒญํฌ ๊ฐ„ ์˜ค๋ฒ„๋žฉ ํ† ํฐ ์ˆ˜ (๊ธฐ๋ณธ 128, ADR-004).
363
+ min_chunk_tokens : int
364
+ ์ตœ์†Œ ์ฒญํฌ ํฌ๊ธฐ. ์ด๋ณด๋‹ค ์ž‘์œผ๋ฉด ์ธ์ ‘ ์ฒญํฌ์™€ ๋ณ‘ํ•ฉ (๊ธฐ๋ณธ 50).
365
+ """
366
+
367
+ SUPPORTED_EXTENSIONS = frozenset(_PARSERS.keys())
368
+
369
+ def __init__(
370
+ self,
371
+ chunk_size: int = 512,
372
+ chunk_overlap: int = 128,
373
+ min_chunk_tokens: int = 50,
374
+ ) -> None:
375
+ self.chunk_size = chunk_size
376
+ self.chunk_overlap = chunk_overlap
377
+ self.min_chunk_tokens = min_chunk_tokens
378
+
379
+ # ------------------------------------------------------------------
380
+ # Public API
381
+ # ------------------------------------------------------------------
382
+
383
+ def process(
384
+ self,
385
+ file_path: str,
386
+ doc_type: IndexType,
387
+ *,
388
+ source: str = "",
389
+ title: Optional[str] = None,
390
+ category: str = "",
391
+ reliability_score: Optional[float] = None,
392
+ valid_from: Optional[str] = None,
393
+ valid_until: Optional[str] = None,
394
+ extras: Optional[Dict[str, Any]] = None,
395
+ document_id: Optional[str] = None,
396
+ ) -> List[DocumentMetadata]:
397
+ """ํŒŒ์ผ์„ ํŒŒ์‹ฑ โ†’ ์ •์ œ โ†’ ์ฒญํ‚นํ•˜์—ฌ DocumentMetadata ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
398
+
399
+ Parameters
400
+ ----------
401
+ file_path : str
402
+ ํŒŒ์‹ฑํ•  ์›๋ณธ ๋ฌธ์„œ ๊ฒฝ๋กœ.
403
+ doc_type : IndexType
404
+ ๋ฌธ์„œ์˜ semantic type.
405
+ document_id : Optional[str]
406
+ ์›๋ณธ ๋ฌธ์„œ ๋‹จ์œ„์˜ ์•ˆ์ • ID. ์ง€์ •๋˜๋ฉด ์ƒ์„ฑ๋˜๋Š” ๋ชจ๋“  chunk๊ฐ€ ๊ฐ™์€ doc_id๋ฅผ ๊ณต์œ ํ•œ๋‹ค.
407
+
408
+ Returns
409
+ -------
410
+ List[DocumentMetadata]
411
+ ์ฒญํฌ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋ฆฌ์ŠคํŠธ. doc_id๋Š” ์›๋ณธ ๋ฌธ์„œ ๋‹จ์œ„๋กœ ๋™์ผํ•˜๋ฉฐ,
412
+ ์ฒญํฌ๋Š” chunk_index๋กœ ๊ตฌ๋ถ„ํ•œ๋‹ค.
413
+ """
414
+ path = Path(file_path)
415
+ ext = path.suffix.lower()
416
+
417
+ if ext not in _PARSERS:
418
+ raise ValueError(
419
+ f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹: {ext} "
420
+ f"(์ง€์›: {', '.join(sorted(self.SUPPORTED_EXTENSIONS))})"
421
+ )
422
+
423
+ logger.info(f"๋ฌธ์„œ ํŒŒ์‹ฑ ์‹œ์ž‘: {file_path} (type={doc_type.value})")
424
+
425
+ units: List[Tuple[Optional[int], str]] = []
426
+ page_parser = _PAGE_PARSERS.get(ext)
427
+ if page_parser is not None:
428
+ for page_number, page_text in page_parser(file_path):
429
+ cleaned_page = _clean_text(page_text)
430
+ if cleaned_page:
431
+ units.append((page_number, cleaned_page))
432
+ else:
433
+ raw_text = _PARSERS[ext](file_path)
434
+ if not raw_text.strip():
435
+ logger.warning(f"๋นˆ ๋ฌธ์„œ: {file_path}")
436
+ return []
437
+
438
+ cleaned = _clean_text(raw_text)
439
+ if not cleaned:
440
+ logger.warning(f"์ •์ œ ํ›„ ๋นˆ ๋ฌธ์„œ: {file_path}")
441
+ return []
442
+ units.append((None, cleaned))
443
+
444
+ if not units:
445
+ logger.warning(f"์ •์ œ ํ›„ ๋นˆ ๋ฌธ์„œ: {file_path}")
446
+ return []
447
+
448
+ chunk_entries: List[Tuple[str, Optional[int]]] = []
449
+ for page_number, cleaned_text in units:
450
+ chunks = _hybrid_chunk(
451
+ cleaned_text,
452
+ doc_type,
453
+ chunk_size=self.chunk_size,
454
+ chunk_overlap=self.chunk_overlap,
455
+ min_chunk_tokens=self.min_chunk_tokens,
456
+ )
457
+ for chunk in chunks:
458
+ chunk_entries.append((chunk, page_number))
459
+
460
+ if not chunk_entries:
461
+ logger.warning(f"์ฒญํ‚น ๊ฒฐ๊ณผ ์—†์Œ: {file_path}")
462
+ return []
463
+
464
+ logger.info(f"์ฒญํ‚น ์™„๋ฃŒ: {len(chunk_entries)}๊ฐœ ์ฒญํฌ ์ƒ์„ฑ ({file_path})")
465
+
466
+ # 4. ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ
467
+ now_iso = datetime.now(timezone.utc).isoformat()
468
+ doc_title = title or path.stem
469
+ score = (
470
+ reliability_score
471
+ if reliability_score is not None
472
+ else _DEFAULT_RELIABILITY.get(doc_type, 0.5)
473
+ )
474
+ # doc_id: ์›๋ณธ ๋ฌธ์„œ ๋‹จ์œ„ ์•ˆ์ • ID (๋ชจ๋“  ์ฒญํฌ๊ฐ€ ๋™์ผ)
475
+ doc_id = (
476
+ document_id or hashlib.sha256(f"{file_path}:{doc_type.value}".encode()).hexdigest()[:12]
477
+ )
478
+
479
+ results: List[DocumentMetadata] = []
480
+ for idx, (chunk, page_number) in enumerate(chunk_entries):
481
+ chunk_extras = dict(extras or {})
482
+ chunk_extras.update(
483
+ {
484
+ "chunk_text": chunk,
485
+ "file_path": str(path),
486
+ "file_extension": ext,
487
+ "chunk_id": f"{doc_id}:{idx}",
488
+ }
489
+ )
490
+ if page_number is not None:
491
+ chunk_extras["page"] = page_number
492
+
493
+ meta = DocumentMetadata(
494
+ doc_id=doc_id,
495
+ doc_type=doc_type.value,
496
+ source=source,
497
+ title=doc_title,
498
+ category=category,
499
+ reliability_score=score,
500
+ created_at=now_iso,
501
+ updated_at=now_iso,
502
+ valid_from=valid_from,
503
+ valid_until=valid_until,
504
+ chunk_index=idx,
505
+ chunk_total=len(chunk_entries),
506
+ extras=chunk_extras,
507
+ )
508
+ results.append(meta)
509
+
510
+ return results
511
+
512
+ def process_batch(
513
+ self,
514
+ file_paths: List[str],
515
+ doc_type: IndexType,
516
+ **kwargs: Any,
517
+ ) -> BatchResult:
518
+ """์—ฌ๋Ÿฌ ํŒŒ์ผ์„ ์ผ๊ด„ ์ฒ˜๋ฆฌํ•œ๋‹ค.
519
+
520
+ Returns
521
+ -------
522
+ BatchResult
523
+ ์„ฑ๊ณตํ•œ ์ฒญํฌ ๋ฆฌ์ŠคํŠธ์™€ ์‹คํŒจํ•œ ํŒŒ์ผ ์ •๋ณด๋ฅผ ๋ชจ๋‘ ํฌํ•จ.
524
+ """
525
+ result = BatchResult()
526
+ for fp in file_paths:
527
+ try:
528
+ chunks = self.process(fp, doc_type, **kwargs)
529
+ result.succeeded.extend(chunks)
530
+ except Exception as e:
531
+ logger.error(f"๋ฌธ์„œ ์ฒ˜๋ฆฌ ์‹คํŒจ: {fp} โ€” {e}")
532
+ result.failed.append((fp, str(e)))
533
+ logger.info(
534
+ f"๋ฐฐ์น˜ ์ฒ˜๋ฆฌ ์™„๋ฃŒ: {len(file_paths)}๊ฐœ ํŒŒ์ผ โ†’ "
535
+ f"{result.total_chunks}๊ฐœ ์ฒญํฌ, {len(result.failed)}๊ฐœ ์‹คํŒจ"
536
+ )
537
+ return result
538
+
539
+ def parse_only(self, file_path: str) -> str:
540
+ """ํŒŒ์‹ฑ + ์ •์ œ๋งŒ ์ˆ˜ํ–‰ํ•˜๊ณ  ํ…์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค (์ฒญํ‚น ์—†์Œ)."""
541
+ ext = Path(file_path).suffix.lower()
542
+ if ext not in _PARSERS:
543
+ raise ValueError(f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹: {ext}")
544
+ raw = _PARSERS[ext](file_path)
545
+ return _clean_text(raw)
src/inference/feature_flags.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Feature Flag ๊ด€๋ฆฌ ๋ชจ๋“ˆ.
2
+
3
+ ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๊ธฐ๋ฐ˜ Feature Flag์™€ X-Feature-Flag ํ—ค๋”๋ฅผ ํ†ตํ•œ ์š”์ฒญ๋ณ„ ์˜ค๋ฒ„๋ผ์ด๋“œ๋ฅผ ์ง€์›ํ•œ๋‹ค.
4
+ """
5
+
6
+ import os
7
+ from dataclasses import asdict, dataclass
8
+ from typing import Optional
9
+
10
+ from loguru import logger
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class FeatureFlags:
15
+ """๋Ÿฐํƒ€์ž„ Feature Flag ์„ค์ •."""
16
+
17
+ use_rag_pipeline: bool = True
18
+ model_version: str = "v2_lora" # v1_lora | v2_lora
19
+
20
+ @classmethod
21
+ def from_env(cls) -> "FeatureFlags":
22
+ """ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ Feature Flag๋ฅผ ๋กœ๋“œํ•œ๋‹ค."""
23
+ flags = cls(
24
+ use_rag_pipeline=os.getenv("USE_RAG_PIPELINE", "true").lower() in ("true", "1", "yes"),
25
+ model_version=os.getenv("MODEL_VERSION", "v2_lora"),
26
+ )
27
+ logger.info(f"Feature Flags ๋กœ๋“œ: {flags}")
28
+ return flags
29
+
30
+ def override_from_header(self, header_value: Optional[str]) -> "FeatureFlags":
31
+ """X-Feature-Flag ํ—ค๋”์—์„œ ๋Ÿฐํƒ€์ž„ ์˜ค๋ฒ„๋ผ์ด๋“œ.
32
+
33
+ ํ˜•์‹: 'USE_RAG_PIPELINE=false,MODEL_VERSION=v1_lora'
34
+ ์›๋ณธ ์ธ์Šคํ„ด์Šค๋Š” ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์œผ๋ฉฐ ์ƒˆ ์ธ์Šคํ„ด์Šค๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
35
+ """
36
+ if not header_value:
37
+ return self
38
+
39
+ overrides: dict = {}
40
+ for pair in header_value.split(","):
41
+ pair = pair.strip()
42
+ if "=" not in pair:
43
+ continue
44
+ key, value = pair.split("=", 1)
45
+ key = key.strip().upper()
46
+ value = value.strip()
47
+
48
+ if key == "USE_RAG_PIPELINE":
49
+ overrides["use_rag_pipeline"] = value.lower() in ("true", "1", "yes")
50
+ elif key == "MODEL_VERSION":
51
+ if value in ("v1_lora", "v2_lora"):
52
+ overrides["model_version"] = value
53
+
54
+ if overrides:
55
+ current = asdict(self)
56
+ current.update(overrides)
57
+ return FeatureFlags(**current)
58
+ return self
src/inference/graph/__init__.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GovOn LangGraph runtime ํŒจํ‚ค์ง€.
2
+
3
+ Issue #415: LangGraph runtime ๊ธฐ๋ฐ˜ ๋ฐ planner/executor adapter ๊ตฌ์„ฑ.
4
+
5
+ ์ฃผ์š” public API:
6
+ - `build_govon_graph`: StateGraph ๋นŒ๋” ํ•จ์ˆ˜
7
+ - `GovOnGraphState`: graph state TypedDict
8
+ - `ApprovalStatus`, `TaskType`, `ToolPlan`: state ๊ด€๋ จ ํƒ€์ž…
9
+ - `PlannerAdapter`, `LLMPlannerAdapter`: planner ์ถ”์ƒํ™” (LLMPlannerAdapter๊ฐ€ ๊ธฐ๋ณธ)
10
+ - `RegexPlannerAdapter`: CI fallback planner (SKIP_MODEL_LOAD=true ํ™˜๊ฒฝ ์ „์šฉ)
11
+ - `ExecutorAdapter`, `RegistryExecutorAdapter`: executor ์ถ”์ƒํ™”
12
+ """
13
+
14
+ from .builder import build_govon_graph
15
+ from .executor_adapter import ExecutorAdapter, RegistryExecutorAdapter
16
+ from .planner_adapter import LLMPlannerAdapter, PlannerAdapter, RegexPlannerAdapter
17
+ from .state import ApprovalStatus, GovOnGraphState, TaskType, ToolPlan
18
+
19
+ __all__ = [
20
+ "build_govon_graph",
21
+ "GovOnGraphState",
22
+ "ApprovalStatus",
23
+ "TaskType",
24
+ "ToolPlan",
25
+ "PlannerAdapter",
26
+ "RegexPlannerAdapter",
27
+ "LLMPlannerAdapter",
28
+ "ExecutorAdapter",
29
+ "RegistryExecutorAdapter",
30
+ ]
src/inference/graph/builder.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GovOn LangGraph StateGraph ๋นŒ๋”.
2
+
3
+ Issue #415: LangGraph runtime ๊ธฐ๋ฐ˜ ๋ฐ planner/executor adapter ๊ตฌ์„ฑ.
4
+
5
+ `build_govon_graph()` ํ•จ์ˆ˜๊ฐ€ 6-node StateGraph๋ฅผ ์กฐ๋ฆฝํ•˜๊ณ 
6
+ ์ปดํŒŒ์ผ๋œ graph๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
7
+
8
+ Graph topology:
9
+ START -> session_load -> planner -> approval_wait
10
+ -> [approved] tool_execute -> synthesis -> persist -> END
11
+ -> [rejected] persist -> END
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Optional
17
+
18
+ from langgraph.graph import END, START, StateGraph
19
+ from langgraph.utils.runnable import RunnableCallable
20
+
21
+ from .executor_adapter import ExecutorAdapter
22
+ from .nodes import (
23
+ approval_wait_node,
24
+ persist_node,
25
+ planner_node,
26
+ session_load_node,
27
+ synthesis_node,
28
+ tool_execute_node,
29
+ )
30
+ from .planner_adapter import PlannerAdapter
31
+ from .state import ApprovalStatus, GovOnGraphState
32
+
33
+ if TYPE_CHECKING:
34
+ from src.inference.session_context import SessionStore
35
+
36
+
37
+ def route_after_approval(state: GovOnGraphState) -> str:
38
+ """approval_wait ์ดํ›„ ๋ถ„๊ธฐ ์กฐ๊ฑด.
39
+
40
+ `approval_status` ๊ฐ’์— ๋”ฐ๋ผ ๋‹ค์Œ ๋…ธ๋“œ๋ฅผ ๊ฒฐ์ •ํ•œ๋‹ค.
41
+
42
+ Parameters
43
+ ----------
44
+ state : GovOnGraphState
45
+ ํ˜„์žฌ graph state.
46
+
47
+ Returns
48
+ -------
49
+ str
50
+ "tool_execute" (์Šน์ธ) ๋˜๋Š” "persist" (๊ฑฐ์ ˆ).
51
+ """
52
+ if state.get("approval_status") == ApprovalStatus.APPROVED.value:
53
+ return "tool_execute"
54
+ return "persist"
55
+
56
+
57
+ def build_govon_graph(
58
+ planner_adapter: PlannerAdapter,
59
+ executor_adapter: ExecutorAdapter,
60
+ session_store: "SessionStore",
61
+ checkpointer: Optional[object] = None,
62
+ ) -> object:
63
+ """GovOn MVP StateGraph๋ฅผ ๊ตฌ์„ฑํ•˜๊ณ  ์ปดํŒŒ์ผํ•œ๋‹ค.
64
+
65
+ 6๊ฐœ ๋…ธ๋“œ๋ฅผ ์กฐ๋ฆฝํ•˜๊ณ  conditional edge๋กœ approval gate๋ฅผ ์—ฐ๊ฒฐํ•œ๋‹ค.
66
+ checkpointer๊ฐ€ None์ด๋ฉด `MemorySaver`๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.
67
+
68
+ Parameters
69
+ ----------
70
+ planner_adapter : PlannerAdapter
71
+ planner ์–ด๋Œ‘ํ„ฐ ์ธ์Šคํ„ด์Šค.
72
+ ์šด์˜ ํ™˜๊ฒฝ์—์„œ๋Š” `LLMPlannerAdapter`๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.
73
+ CI ํ™˜๊ฒฝ์—์„œ๋Š” `RegexPlannerAdapter`๊ฐ€ fallback์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.
74
+ executor_adapter : ExecutorAdapter
75
+ tool executor ์–ด๋Œ‘ํ„ฐ ์ธ์Šคํ„ด์Šค.
76
+ session_store : SessionStore
77
+ GovOn ์„ธ์…˜ ์ €์žฅ์†Œ. session_load์™€ persist ๋…ธ๋“œ์—์„œ ์‚ฌ์šฉํ•œ๋‹ค.
78
+ checkpointer : optional
79
+ LangGraph checkpoint ์ €์žฅ์†Œ.
80
+ None์ด๋ฉด MemorySaver๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค (๋ฉ”๋ชจ๋ฆฌ์—๋งŒ ์ €์žฅ, ์žฌ์‹œ์ž‘ ์‹œ ์†Œ๋ฉธ).
81
+ ํ”„๋กœ๋•์…˜์—์„œ๋Š” `AsyncSqliteSaver`๋ฅผ ์ฃผ์ž…ํ•œ๋‹ค.
82
+
83
+ Returns
84
+ -------
85
+ CompiledGraph
86
+ ์ปดํŒŒ์ผ๋œ LangGraph. `ainvoke()`, `aget_state()` ๋“ฑ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.
87
+ """
88
+ from langgraph.checkpoint.memory import MemorySaver
89
+
90
+ graph = StateGraph(GovOnGraphState)
91
+
92
+ # --- ๋…ธ๋“œ ๋“ฑ๋ก (closure๋กœ adapter์™€ session_store ์ฃผ์ž…) ---
93
+
94
+ async def _session_load(state: GovOnGraphState) -> dict:
95
+ return await session_load_node(state, session_store=session_store)
96
+
97
+ async def _planner(state: GovOnGraphState) -> dict:
98
+ return await planner_node(state, planner_adapter=planner_adapter)
99
+
100
+ async def _tool_execute(state: GovOnGraphState) -> dict:
101
+ return await tool_execute_node(state, executor_adapter=executor_adapter)
102
+
103
+ async def _synthesis(state: GovOnGraphState) -> dict:
104
+ return await synthesis_node(state)
105
+
106
+ async def _persist(state: GovOnGraphState) -> dict:
107
+ return await persist_node(state, session_store=session_store)
108
+
109
+ graph.add_node("session_load", _session_load)
110
+ graph.add_node("planner", _planner)
111
+ # Preserve sync execution for interrupt() on Python 3.10.
112
+ # The default add_node(sync_fn) path auto-generates an async executor wrapper,
113
+ # which breaks LangGraph interrupt context under ainvoke().
114
+ graph.add_node(
115
+ "approval_wait",
116
+ RunnableCallable(approval_wait_node, name="approval_wait"),
117
+ )
118
+ graph.add_node("tool_execute", _tool_execute)
119
+ graph.add_node("synthesis", _synthesis)
120
+ graph.add_node("persist", _persist)
121
+
122
+ # --- ์—ฃ์ง€ ---
123
+ graph.add_edge(START, "session_load")
124
+ graph.add_edge("session_load", "planner")
125
+ graph.add_edge("planner", "approval_wait")
126
+ graph.add_conditional_edges(
127
+ "approval_wait",
128
+ route_after_approval,
129
+ {
130
+ "tool_execute": "tool_execute",
131
+ "persist": "persist",
132
+ },
133
+ )
134
+ graph.add_edge("tool_execute", "synthesis")
135
+ graph.add_edge("synthesis", "persist")
136
+ graph.add_edge("persist", END)
137
+
138
+ # --- ์ปดํŒŒ์ผ ---
139
+ saver = checkpointer if checkpointer is not None else MemorySaver()
140
+ compiled = graph.compile(checkpointer=saver)
141
+
142
+ return compiled
src/inference/graph/capabilities/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangGraph capabilities ํŒจํ‚ค์ง€ โ€” ํ‘œ์ค€ํ™”๋œ tool capability ์ธํ„ฐํŽ˜์ด์Šค."""
2
+
3
+ from .api_lookup import ApiLookupCapability, ApiLookupParams
4
+ from .append_evidence import AppendEvidenceCapability
5
+ from .base import CapabilityBase, CapabilityMetadata, LookupResult
6
+ from .defaults import get_all_defaults, get_max_retries, get_timeout
7
+ from .draft_civil_response import DraftCivilResponseCapability
8
+ from .rag_search import RagSearchCapability, RagSearchParams
9
+ from .registry import (
10
+ MVP_CAPABILITY_IDS,
11
+ build_mvp_registry,
12
+ get_all_metadata,
13
+ get_mvp_capability_ids,
14
+ is_mvp_capability,
15
+ )
16
+
17
+ __all__ = [
18
+ "CapabilityBase",
19
+ "CapabilityMetadata",
20
+ "LookupResult",
21
+ "ApiLookupCapability",
22
+ "ApiLookupParams",
23
+ "RagSearchCapability",
24
+ "RagSearchParams",
25
+ "DraftCivilResponseCapability",
26
+ "AppendEvidenceCapability",
27
+ "MVP_CAPABILITY_IDS",
28
+ "build_mvp_registry",
29
+ "get_all_metadata",
30
+ "get_mvp_capability_ids",
31
+ "is_mvp_capability",
32
+ "get_timeout",
33
+ "get_max_retries",
34
+ "get_all_defaults",
35
+ ]
src/inference/graph/capabilities/api_lookup.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """unified api_lookup capability โ€” MinwonAnalysisAction ๋ž˜ํ•‘."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from typing import Any, Dict, Optional
8
+
9
+ from loguru import logger
10
+
11
+ from .base import CapabilityBase, CapabilityMetadata, EvidenceEnvelope, EvidenceItem, LookupResult
12
+ from .defaults import get_timeout
13
+
14
+ try:
15
+ import httpx
16
+
17
+ _HTTPX_AVAILABLE = True
18
+ except ImportError:
19
+ httpx = None # type: ignore
20
+ _HTTPX_AVAILABLE = False
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # ํŒŒ๋ผ๋ฏธํ„ฐ validator
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ @dataclass
29
+ class ApiLookupParams:
30
+ """api_lookup ํ˜ธ์ถœ ํŒŒ๋ผ๋ฏธํ„ฐ โ€” context์—์„œ ์ถ”์ถœยท์ •๊ทœํ™”ยท๊ฒ€์ฆ."""
31
+
32
+ query: str
33
+ ret_count: int = 5
34
+ min_score: int = 2
35
+
36
+ @classmethod
37
+ def from_context(cls, query: str, context: Dict[str, Any]) -> "ApiLookupParams":
38
+ """context์—์„œ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ถ”์ถœํ•˜๊ณ  alias๋ฅผ ์ •๊ทœํ™”ํ•œ๋‹ค."""
39
+
40
+ def _first_not_none(*values, default):
41
+ for v in values:
42
+ if v is not None:
43
+ return v
44
+ return default
45
+
46
+ ret_count = int(
47
+ _first_not_none(
48
+ context.get("api_lookup_count"),
49
+ context.get("ret_count"),
50
+ context.get("count"),
51
+ default=5,
52
+ )
53
+ )
54
+ min_score = int(
55
+ _first_not_none(
56
+ context.get("api_lookup_min_score"),
57
+ context.get("min_score"),
58
+ context.get("score_threshold"),
59
+ default=2,
60
+ )
61
+ )
62
+ return cls(
63
+ query=query.strip(),
64
+ ret_count=max(1, min(20, ret_count)),
65
+ min_score=max(0, min(10, min_score)),
66
+ )
67
+
68
+ def validate(self) -> Optional[str]:
69
+ """๊ฒ€์ฆ ์‹คํŒจ ์‹œ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€, ํ†ต๊ณผ ์‹œ None."""
70
+ if not self.query:
71
+ return "query๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"
72
+ if len(self.query) > 500:
73
+ return f"query๊ฐ€ ๋„ˆ๋ฌด ๊น๋‹ˆ๋‹ค ({len(self.query)}์ž, ์ตœ๋Œ€ 500์ž)"
74
+ return None
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Capability
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ class ApiLookupCapability(CapabilityBase):
83
+ """๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ ๋ฏผ์›๋ถ„์„์ •๋ณด์กฐํšŒ API๋ฅผ LangGraph capability๋กœ ๋ž˜ํ•‘.
84
+
85
+ Parameters
86
+ ----------
87
+ action : Optional[MinwonAnalysisAction]
88
+ ๋ž˜ํ•‘ํ•  ๊ธฐ์กด Action ์ธ์Šคํ„ด์Šค. None์ด๋ฉด ๋นˆ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค
89
+ (SKIP_MODEL_LOAD ๋“ฑ ๊ฒฝ๋Ÿ‰ ํ™˜๊ฒฝ ์ง€์›).
90
+ """
91
+
92
+ def __init__(self, action: Optional[Any] = None) -> None:
93
+ self._action = action
94
+
95
+ @property
96
+ def metadata(self) -> CapabilityMetadata:
97
+ return CapabilityMetadata(
98
+ name="api_lookup",
99
+ description="๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ ๋ฏผ์›๋ถ„์„์ •๋ณด์กฐํšŒ API๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ์œ ์‚ฌ ๋ฏผ์› ์‚ฌ๋ก€๋ฅผ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค.",
100
+ approval_summary="์™ธ๋ถ€ API(data.go.kr)์—์„œ ์œ ์‚ฌ ๋ฏผ์› ์‚ฌ๋ก€๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.",
101
+ provider="data.go.kr",
102
+ timeout_sec=get_timeout("api_lookup"),
103
+ )
104
+
105
+ async def execute(
106
+ self,
107
+ query: str,
108
+ context: Dict[str, Any],
109
+ session: Any,
110
+ ) -> LookupResult:
111
+ """MinwonAnalysisAction.fetch_similar_cases๋ฅผ ๋ž˜ํ•‘ํ•˜์—ฌ LookupResult๋กœ ๋ฐ˜ํ™˜."""
112
+ provider = self.metadata.provider
113
+
114
+ # ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”์ถœ ๋ฐ ๊ฒ€์ฆ (action ์œ ๋ฌด์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ ํ•ญ์ƒ ์ˆ˜ํ–‰)
115
+ params = ApiLookupParams.from_context(query, context)
116
+ validation_error = params.validate()
117
+ if validation_error:
118
+ return LookupResult(
119
+ success=False,
120
+ query=query,
121
+ provider=provider,
122
+ error=validation_error,
123
+ empty_reason="validation_error",
124
+ evidence=EvidenceEnvelope(
125
+ status="error",
126
+ errors=[validation_error],
127
+ ),
128
+ )
129
+
130
+ # action์ด ์—†์œผ๋ฉด ๋นˆ ๊ฒฐ๊ณผ (๊ฒฝ๋Ÿ‰ ํ™˜๊ฒฝ)
131
+ if self._action is None:
132
+ logger.debug("[api_lookup] action์ด None โ€” ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜")
133
+ return LookupResult(
134
+ success=True,
135
+ query=params.query,
136
+ provider=provider,
137
+ empty_reason="no_match",
138
+ evidence=EvidenceEnvelope(status="empty"),
139
+ )
140
+
141
+ # ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ธ์ž๋กœ ์ „๋‹ฌ (shared state ๋ณ€๊ฒฝ ์—†์ด thread-safe)
142
+ try:
143
+ payload = await asyncio.wait_for(
144
+ self._action.fetch_similar_cases(
145
+ params.query,
146
+ context,
147
+ ret_count=params.ret_count,
148
+ min_score=params.min_score,
149
+ ),
150
+ timeout=self.metadata.timeout_sec,
151
+ )
152
+ except asyncio.TimeoutError:
153
+ timeout_msg = f"API ํ˜ธ์ถœ ํƒ€์ž„์•„์›ƒ ({self.metadata.timeout_sec}์ดˆ ์ดˆ๊ณผ)"
154
+ logger.warning(f"[api_lookup] ํƒ€์ž„์•„์›ƒ ({self.metadata.timeout_sec}s ์ดˆ๊ณผ)")
155
+ return LookupResult(
156
+ success=False,
157
+ query=params.query,
158
+ provider=provider,
159
+ error=timeout_msg,
160
+ empty_reason="provider_error",
161
+ evidence=EvidenceEnvelope(
162
+ status="error",
163
+ errors=[timeout_msg],
164
+ ),
165
+ )
166
+ except Exception as exc:
167
+ if _HTTPX_AVAILABLE and isinstance(exc, httpx.HTTPError):
168
+ logger.warning(f"[api_lookup] httpx ์˜ค๋ฅ˜: {exc}")
169
+ else:
170
+ logger.error(f"[api_lookup] API ํ˜ธ์ถœ ์˜ค๋ฅ˜: {exc}", exc_info=True)
171
+ return LookupResult(
172
+ success=False,
173
+ query=params.query,
174
+ provider=provider,
175
+ error=str(exc),
176
+ empty_reason="provider_error",
177
+ evidence=EvidenceEnvelope(
178
+ status="error",
179
+ errors=[str(exc)],
180
+ ),
181
+ )
182
+
183
+ # ๊ฒฐ๊ณผ ๋ณ€ํ™˜
184
+ results = payload.get("results")
185
+ if results is None:
186
+ error_msg = "๋ฏผ์› ๋ถ„์„ API ํ˜ธ์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."
187
+ return LookupResult(
188
+ success=False,
189
+ query=payload.get("query", params.query),
190
+ provider=provider,
191
+ error=error_msg,
192
+ empty_reason="provider_error",
193
+ evidence=EvidenceEnvelope(
194
+ status="error",
195
+ errors=[error_msg],
196
+ ),
197
+ )
198
+
199
+ # citations๋ฅผ dict ๋ชฉ๋ก์œผ๋กœ ์ •๊ทœํ™”
200
+ raw_citations = payload.get("citations", [])
201
+ citations: list[Dict[str, Any]] = []
202
+ for c in raw_citations:
203
+ if isinstance(c, dict):
204
+ citations.append(c)
205
+ elif hasattr(c, "__dict__"):
206
+ citations.append({k: v for k, v in c.__dict__.items() if not k.startswith("_")})
207
+
208
+ if not results:
209
+ return LookupResult(
210
+ success=True,
211
+ query=payload.get("query", params.query),
212
+ provider=provider,
213
+ empty_reason="no_match",
214
+ evidence=EvidenceEnvelope(status="empty"),
215
+ )
216
+
217
+ # EvidenceItem์œผ๋กœ ์ •๊ทœํ™”
218
+ evidence_items = []
219
+ for item in results:
220
+ if not isinstance(item, dict):
221
+ continue
222
+ title = item.get("title") or item.get("qnaTitle") or item.get("question", "")
223
+ excerpt = item.get("content") or item.get("qnaContent") or item.get("qnaAnswer", "")
224
+ link = item.get("url") or item.get("detailUrl", "")
225
+ evidence_items.append(
226
+ EvidenceItem(
227
+ source_type="api",
228
+ title=str(title),
229
+ excerpt=str(excerpt)[:500],
230
+ link_or_path=str(link),
231
+ score=float(item.get("score", 0)),
232
+ provider_meta={"provider": provider},
233
+ )
234
+ )
235
+ # citations๋„ EvidenceItem์œผ๋กœ ๋ณ€ํ™˜ (์ค‘๋ณต ์ œ๊ฑฐ๋ฅผ ์œ„ํ•ด link_or_path ๊ธฐ๋ฐ˜ dedup)
236
+ seen_links: set[str] = {item.link_or_path for item in evidence_items}
237
+ for c in citations:
238
+ link = c.get("url") or c.get("detailUrl", "")
239
+ if link in seen_links:
240
+ continue
241
+ seen_links.add(str(link))
242
+ title = c.get("title") or c.get("qnaTitle") or c.get("question", "")
243
+ excerpt = c.get("content") or c.get("qnaContent") or c.get("qnaAnswer", "")
244
+ evidence_items.append(
245
+ EvidenceItem(
246
+ source_type="api",
247
+ title=str(title),
248
+ excerpt=str(excerpt)[:500],
249
+ link_or_path=str(link),
250
+ score=float(c.get("score", 0)),
251
+ provider_meta={"provider": provider},
252
+ )
253
+ )
254
+
255
+ envelope = EvidenceEnvelope(
256
+ items=evidence_items,
257
+ status="ok" if evidence_items else "empty",
258
+ )
259
+
260
+ return LookupResult(
261
+ success=True,
262
+ query=payload.get("query", params.query),
263
+ results=results,
264
+ context_text=payload.get("context_text", ""),
265
+ citations=citations,
266
+ provider=provider,
267
+ evidence=envelope,
268
+ )
src/inference/graph/capabilities/append_evidence.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """append_evidence capability โ€” ๊ธฐ์กด closure๋ฅผ CapabilityBase๋กœ ๋ž˜ํ•‘."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict
6
+
7
+ from .base import CapabilityBase, CapabilityMetadata, EvidenceEnvelope, EvidenceItem, LookupResult
8
+ from .defaults import get_timeout
9
+
10
+
11
+ class AppendEvidenceCapability(CapabilityBase):
12
+ """๊ทผ๊ฑฐ/์ถœ์ฒ˜ ๋ณด๊ฐ• capability.
13
+
14
+ ๊ธฐ์กด api_server์˜ _append_evidence_tool closure๋ฅผ ์ฃผ์ž…๋ฐ›์•„
15
+ CapabilityBase ์ธํ„ฐํŽ˜์ด์Šค๋กœ ๋ž˜ํ•‘ํ•œ๋‹ค.
16
+
17
+ Parameters
18
+ ----------
19
+ execute_fn : Callable
20
+ ``async (query, context, session) -> dict`` ์‹œ๊ทธ๋‹ˆ์ฒ˜์˜ ์‹คํ–‰ ํ•จ์ˆ˜.
21
+ """
22
+
23
+ def __init__(self, execute_fn: Callable[..., Any]) -> None:
24
+ self._execute_fn = execute_fn
25
+
26
+ @property
27
+ def metadata(self) -> CapabilityMetadata:
28
+ return CapabilityMetadata(
29
+ name="append_evidence",
30
+ description=(
31
+ "๊ธฐ์กด ๋‹ต๋ณ€์— ๋ฒ•๋ น ๊ทผ๊ฑฐ, ์œ ์‚ฌ ์‚ฌ๋ก€, ์™ธ๋ถ€ ํ†ต๊ณ„ ๋“ฑ " "์ถ”๊ฐ€ ์ถœ์ฒ˜๋ฅผ ๋ณด๊ฐ•ํ•ฉ๋‹ˆ๋‹ค."
32
+ ),
33
+ approval_summary="๊ธฐ์กด ๋‹ต๋ณ€์— ๋ฒ•์  ๊ทผ๊ฑฐ์™€ ์ถœ์ฒ˜๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.",
34
+ provider="local_vectordb+data.go.kr",
35
+ timeout_sec=get_timeout("append_evidence"),
36
+ )
37
+
38
+ async def execute(
39
+ self,
40
+ query: str,
41
+ context: Dict[str, Any],
42
+ session: Any,
43
+ ) -> LookupResult:
44
+ """์ฃผ์ž…๋ฐ›์€ ํ•จ์ˆ˜์— ์œ„์ž„ํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ LookupResult๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค."""
45
+ raw = await self._execute_fn(query=query, context=context, session=session)
46
+
47
+ if isinstance(raw, dict) and raw.get("error"):
48
+ return LookupResult(
49
+ success=False,
50
+ query=query,
51
+ provider=self.metadata.provider,
52
+ error=raw["error"],
53
+ empty_reason="provider_error",
54
+ evidence=EvidenceEnvelope(
55
+ status="error",
56
+ errors=[raw["error"]],
57
+ ),
58
+ )
59
+
60
+ text = raw.get("text", "") if isinstance(raw, dict) else str(raw)
61
+ citations = raw.get("api_citations", []) if isinstance(raw, dict) else []
62
+ rag_results = raw.get("rag_results", []) if isinstance(raw, dict) else []
63
+
64
+ # ์ด์ „ ๋‹จ๊ณ„์˜ evidence๋ฅผ ํ•ฉ์‚ฐํ•˜์—ฌ EvidenceEnvelope ๊ตฌ์„ฑ
65
+ evidence_items: list[EvidenceItem] = []
66
+ errors: list[str] = []
67
+
68
+ # rag_results -> EvidenceItem ๋ณ€ํ™˜
69
+ for item in rag_results:
70
+ if not isinstance(item, dict):
71
+ continue
72
+ metadata = item.get("metadata", {}) or {}
73
+ evidence_items.append(
74
+ EvidenceItem(
75
+ source_type="rag",
76
+ title=item.get("title", ""),
77
+ excerpt=str(item.get("content", ""))[:500],
78
+ link_or_path=metadata.get("file_path", ""),
79
+ page=metadata.get("page"),
80
+ score=float(item.get("score", 0.0)),
81
+ provider_meta={"provider": "local_vectordb"},
82
+ )
83
+ )
84
+
85
+ # api_citations -> EvidenceItem ๋ณ€ํ™˜
86
+ for c in citations:
87
+ if not isinstance(c, dict):
88
+ continue
89
+ title = c.get("title") or c.get("qnaTitle") or c.get("question", "")
90
+ excerpt = c.get("content") or c.get("qnaContent") or c.get("qnaAnswer", "")
91
+ link = c.get("url") or c.get("detailUrl", "")
92
+ evidence_items.append(
93
+ EvidenceItem(
94
+ source_type="api",
95
+ title=str(title),
96
+ excerpt=str(excerpt)[:500],
97
+ link_or_path=str(link),
98
+ score=float(c.get("score", 0)),
99
+ provider_meta={"provider": "data.go.kr"},
100
+ )
101
+ )
102
+
103
+ if isinstance(raw, dict):
104
+ raw_errors = raw.get("errors", [])
105
+ if isinstance(raw_errors, list):
106
+ errors = [str(e) for e in raw_errors]
107
+
108
+ status: str
109
+ if not evidence_items and errors:
110
+ status = "error"
111
+ elif not evidence_items:
112
+ status = "empty"
113
+ elif errors:
114
+ status = "partial"
115
+ else:
116
+ status = "ok"
117
+
118
+ envelope = EvidenceEnvelope(
119
+ items=evidence_items,
120
+ summary_text=text,
121
+ status=status,
122
+ errors=errors,
123
+ )
124
+
125
+ return LookupResult(
126
+ success=True,
127
+ query=query,
128
+ context_text=text,
129
+ citations=citations,
130
+ results=rag_results,
131
+ provider=self.metadata.provider,
132
+ evidence=envelope,
133
+ )
src/inference/graph/capabilities/base.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangGraph capability ๊ณตํ†ต ์ถ”์ƒํ™”."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, List, Optional
8
+
9
+
10
+ @dataclass
11
+ class EvidenceItem:
12
+ """RAG/API ์ถœ์ฒ˜ ๋ฌด๊ด€ํ•˜๊ฒŒ ๋™์ผํ•œ ๊ตฌ์กฐ๋กœ evidence๋ฅผ ํ‘œํ˜„.
13
+
14
+ source_type: "rag" | "api" | "llm_generated"
15
+ """
16
+
17
+ source_type: str # "rag" | "api" | "llm_generated"
18
+ title: str
19
+ excerpt: str # ๋ณธ๋ฌธ ๋ฐœ์ทŒ (์ตœ๋Œ€ 500์ž)
20
+ link_or_path: str = "" # URL(API) ๋˜๋Š” file_path(RAG)
21
+ page: Optional[int] = None
22
+ score: float = 0.0
23
+ provider_meta: Dict[str, Any] = field(default_factory=dict)
24
+
25
+ def to_dict(self) -> Dict[str, Any]:
26
+ return {
27
+ "source_type": self.source_type,
28
+ "title": self.title,
29
+ "excerpt": self.excerpt,
30
+ "link_or_path": self.link_or_path,
31
+ "page": self.page,
32
+ "score": self.score,
33
+ "provider_meta": self.provider_meta,
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class EvidenceEnvelope:
39
+ """mixed evidence ๊ฒฐ๊ณผ ์ปจํ…Œ์ด๋„ˆ."""
40
+
41
+ items: List[EvidenceItem] = field(default_factory=list)
42
+ summary_text: str = ""
43
+ status: str = "ok" # "ok" | "empty" | "partial" | "error"
44
+ errors: List[str] = field(default_factory=list)
45
+
46
+ def to_dict(self) -> Dict[str, Any]:
47
+ return {
48
+ "items": [item.to_dict() for item in self.items],
49
+ "summary_text": self.summary_text,
50
+ "status": self.status,
51
+ "errors": self.errors,
52
+ }
53
+
54
+
55
+ @dataclass
56
+ class CapabilityMetadata:
57
+ """plannerยทexecutorยทsession trace์—์„œ ๊ณตํ†ต์œผ๋กœ ์‚ฌ์šฉํ•˜๋Š” capability ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ."""
58
+
59
+ name: str # tool registry key (์˜ˆ: "api_lookup")
60
+ description: str # LLM planner๊ฐ€ ์ฝ๋Š” ํ•œ๊ตญ์–ด ์„ค๋ช… (1-2๋ฌธ์žฅ)
61
+ approval_summary: str # approval_wait ํ”„๋กฌํ”„ํŠธ์— ํ‘œ์‹œ๋˜๋Š” ์š”์•ฝ
62
+ provider: str # ๋ฐ์ดํ„ฐ ์ œ๊ณต์ž ์‹๋ณ„์ž (์˜ˆ: "data.go.kr")
63
+ timeout_sec: float = 10.0 # ๊ธฐ๋ณธ ํƒ€์ž„์•„์›ƒ
64
+
65
+
66
+ @dataclass
67
+ class LookupResult:
68
+ """api_lookup ๊ณตํ†ต ์‘๋‹ต ์Šคํ‚ค๋งˆ."""
69
+
70
+ success: bool
71
+ query: str
72
+ results: List[Dict[str, Any]] = field(default_factory=list)
73
+ context_text: str = ""
74
+ citations: List[Dict[str, Any]] = field(default_factory=list)
75
+ provider: str = ""
76
+ error: Optional[str] = None
77
+ empty_reason: Optional[str] = None # "quota", "no_match", "provider_error"
78
+ latency_ms: float = 0.0
79
+ evidence: Optional[EvidenceEnvelope] = None # ์ •๊ทœํ™”๋œ evidence (์ƒˆ ํ•„๋“œ)
80
+
81
+ def to_dict(self) -> Dict[str, Any]:
82
+ d: Dict[str, Any] = {
83
+ "success": self.success,
84
+ "query": self.query,
85
+ "count": len(self.results),
86
+ "results": self.results,
87
+ "context_text": self.context_text,
88
+ "citations": self.citations,
89
+ "provider": self.provider,
90
+ "error": self.error,
91
+ "empty_reason": self.empty_reason,
92
+ "latency_ms": round(self.latency_ms, 2),
93
+ }
94
+ if self.evidence is not None:
95
+ d["evidence"] = self.evidence.to_dict()
96
+ return d
97
+
98
+
99
+ class CapabilityBase(ABC):
100
+ """LangGraph tool capability ์ถ”์ƒ ๋ฒ ์ด์Šค.
101
+
102
+ RegistryExecutorAdapter์˜ tool_registry์— ๋“ฑ๋ก ๊ฐ€๋Šฅํ•œ async callable ์ธํ„ฐํŽ˜์ด์Šค.
103
+ """
104
+
105
+ @property
106
+ @abstractmethod
107
+ def metadata(self) -> CapabilityMetadata: ...
108
+
109
+ @abstractmethod
110
+ async def execute(
111
+ self,
112
+ query: str,
113
+ context: Dict[str, Any],
114
+ session: Any, # SessionContext (์ˆœํ™˜ import ๋ฐฉ์ง€)
115
+ ) -> LookupResult: ...
116
+
117
+ async def __call__(
118
+ self,
119
+ query: str,
120
+ context: Dict[str, Any],
121
+ session: Any,
122
+ ) -> Dict[str, Any]:
123
+ """RegistryExecutorAdapter ํ˜ธํ™˜ ์ง„์ž…์ ."""
124
+ import time
125
+
126
+ start = time.monotonic()
127
+ result = await self.execute(query, context, session)
128
+ result.latency_ms = (time.monotonic() - start) * 1000
129
+ return result.to_dict()
src/inference/graph/capabilities/defaults.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Capability timeout/retry ๊ธฐ๋ณธ๊ฐ’ ๋ชจ๋“ˆ.
2
+
3
+ Issue #163: capability๋ณ„ timeout๊ณผ retry ๊ธฐ๋ณธ๊ฐ’์„ ์ค‘์•™ ์ง‘์ค‘ ๊ด€๋ฆฌ.
4
+ ํ™˜๊ฒฝ๋ณ€์ˆ˜ GOVON_TOOL_TIMEOUT_{CAPABILITY_NAME} ์œผ๋กœ ์˜ค๋ฒ„๋ผ์ด๋“œ ๊ฐ€๋Šฅ.
5
+
6
+ ์˜ˆ: GOVON_TOOL_TIMEOUT_RAG_SEARCH=20 -> rag_search timeout์„ 20์ดˆ๋กœ ๋ณ€๊ฒฝ
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass
13
+ from typing import Dict
14
+
15
+ from loguru import logger
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CapabilityDefaults:
20
+ """capability๋ณ„ timeout/retry ๊ธฐ๋ณธ๊ฐ’."""
21
+
22
+ timeout_sec: float
23
+ max_retries: int
24
+
25
+
26
+ # -----------------------------------------------------------------------
27
+ # ๊ธฐ๋ณธ๊ฐ’ ์ •์˜ (์ฝ”๋“œ๋ฒ ์ด์Šค capability metadata์—์„œ ์ถ”์ถœ)
28
+ # -----------------------------------------------------------------------
29
+
30
+ _DEFAULTS: Dict[str, CapabilityDefaults] = {
31
+ "rag_search": CapabilityDefaults(timeout_sec=15.0, max_retries=0),
32
+ "api_lookup": CapabilityDefaults(timeout_sec=10.0, max_retries=1),
33
+ "draft_civil_response": CapabilityDefaults(timeout_sec=30.0, max_retries=0),
34
+ "append_evidence": CapabilityDefaults(timeout_sec=30.0, max_retries=0),
35
+ "issue_detector": CapabilityDefaults(timeout_sec=15.0, max_retries=0),
36
+ "stats_lookup": CapabilityDefaults(timeout_sec=15.0, max_retries=0),
37
+ "keyword_analyzer": CapabilityDefaults(timeout_sec=10.0, max_retries=0),
38
+ "demographics_lookup": CapabilityDefaults(timeout_sec=15.0, max_retries=0),
39
+ }
40
+
41
+
42
+ def get_timeout(capability_name: str) -> float:
43
+ """capability์˜ timeout(์ดˆ)์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
44
+
45
+ ํ™˜๊ฒฝ๋ณ€์ˆ˜ ``GOVON_TOOL_TIMEOUT_{CAPABILITY_NAME_UPPER}`` ๊ฐ€ ์„ค์ •๋˜์–ด ์žˆ์œผ๋ฉด
46
+ ํ•ด๋‹น ๊ฐ’์„ ์‚ฌ์šฉํ•˜๊ณ , ์—†์œผ๋ฉด ๊ธฐ๋ณธ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
47
+
48
+ Parameters
49
+ ----------
50
+ capability_name : str
51
+ capability ์ด๋ฆ„ (์˜ˆ: "rag_search").
52
+
53
+ Returns
54
+ -------
55
+ float
56
+ timeout ์ดˆ. ์•Œ ์ˆ˜ ์—†๋Š” capability๋Š” 10.0์ดˆ.
57
+ """
58
+ env_key = f"GOVON_TOOL_TIMEOUT_{capability_name.upper()}"
59
+ env_val = os.environ.get(env_key)
60
+ if env_val is not None:
61
+ try:
62
+ val = float(env_val)
63
+ if val > 0:
64
+ return val
65
+ logger.warning(
66
+ f"GOVON_TOOL_TIMEOUT_{capability_name.upper()} ๊ฐ’์ด ์–‘์ˆ˜๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค: {env_val}"
67
+ )
68
+ except ValueError:
69
+ logger.warning(f"{env_key} ๊ฐ’์„ ์ˆซ์ž๋กœ ํŒŒ์‹ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {env_val!r}")
70
+
71
+ defaults = _DEFAULTS.get(capability_name)
72
+ return defaults.timeout_sec if defaults else 10.0
73
+
74
+
75
+ def get_max_retries(capability_name: str) -> int:
76
+ """capability์˜ ์ตœ๋Œ€ ์žฌ์‹œ๋„ ํšŸ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
77
+
78
+ Parameters
79
+ ----------
80
+ capability_name : str
81
+ capability ์ด๋ฆ„.
82
+
83
+ Returns
84
+ -------
85
+ int
86
+ ์ตœ๋Œ€ ์žฌ์‹œ๋„ ํšŸ์ˆ˜. ์•Œ ์ˆ˜ ์—†๋Š” capability๋Š” 0.
87
+ """
88
+ defaults = _DEFAULTS.get(capability_name)
89
+ return defaults.max_retries if defaults else 0
90
+
91
+
92
+ def get_all_defaults() -> Dict[str, CapabilityDefaults]:
93
+ """๋“ฑ๋ก๋œ ๋ชจ๋“  capability ๊ธฐ๋ณธ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค."""
94
+ return dict(_DEFAULTS)
src/inference/graph/capabilities/demographics_lookup.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """demographics_lookup capability โ€” ์„ฑ๋ณ„+์—ฐ๋ น+์ธ๊ตฌ๋Œ€๋น„ ์กฐํ•ฉ.
2
+
3
+ Issue #489: ๋ฏผ์› ์ธ๊ตฌํ†ต๊ณ„ ๋ถ„์„ ๋„๊ตฌ.
4
+ 3๊ฐœ API(์„ฑ๋ณ„ํ†ต๊ณ„, ์—ฐ๋ นํ†ต๊ณ„, ์ธ๊ตฌ๋Œ€๋น„๋น„์œจ)๋ฅผ ์กฐํ•ฉํ•˜์—ฌ
5
+ ๋ฏผ์› ์ธ๊ตฌํ†ต๊ณ„ ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ์ œ๊ณตํ•œ๋‹ค.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from loguru import logger
14
+
15
+ from .base import (
16
+ CapabilityBase,
17
+ CapabilityMetadata,
18
+ EvidenceEnvelope,
19
+ EvidenceItem,
20
+ LookupResult,
21
+ )
22
+ from .defaults import get_timeout
23
+
24
+
25
+ class DemographicsLookupCapability(CapabilityBase):
26
+ """๋ฏผ์› ์ธ๊ตฌํ†ต๊ณ„ ๋ถ„์„ capability.
27
+
28
+ ์„ฑ๋ณ„, ์—ฐ๋ น, ์ธ๊ตฌ๋Œ€๋น„ ๋น„์œจ์„ ์กฐํ•ฉํ•˜์—ฌ ์ธ๊ตฌํ†ต๊ณ„ ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ์ œ๊ณตํ•œ๋‹ค.
29
+
30
+ Parameters
31
+ ----------
32
+ action : Optional[MinwonAnalysisAction]
33
+ API ํ˜ธ์ถœ์šฉ Action ์ธ์Šคํ„ด์Šค. None์ด๋ฉด ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜.
34
+ """
35
+
36
+ def __init__(self, action: Optional[Any] = None) -> None:
37
+ self._action = action
38
+
39
+ @property
40
+ def metadata(self) -> CapabilityMetadata:
41
+ return CapabilityMetadata(
42
+ name="demographics_lookup",
43
+ description=(
44
+ "์„ฑ๋ณ„, ์—ฐ๋ น, ์ธ๊ตฌ๋Œ€๋น„ ๋น„์œจ์„ ์กฐํ•ฉํ•˜์—ฌ " "๋ฏผ์› ์ธ๊ตฌํ†ต๊ณ„ ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค."
45
+ ),
46
+ approval_summary="๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ์—์„œ ๋ฏผ์› ์ธ๊ตฌํ†ต๊ณ„๋ฅผ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค.",
47
+ provider="data.go.kr",
48
+ timeout_sec=get_timeout("demographics_lookup"),
49
+ )
50
+
51
+ async def execute(
52
+ self,
53
+ query: str,
54
+ context: Dict[str, Any],
55
+ session: Any,
56
+ ) -> LookupResult:
57
+ """์„ฑ๋ณ„+์—ฐ๋ น+์ธ๊ตฌ๋Œ€๋น„ API๋ฅผ ๋ณ‘๋ ฌ ํ˜ธ์ถœํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ์กฐํ•ฉํ•œ๋‹ค."""
58
+ provider = self.metadata.provider
59
+
60
+ if not query or not query.strip():
61
+ return LookupResult(
62
+ success=False,
63
+ query=query,
64
+ provider=provider,
65
+ error="query๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค",
66
+ empty_reason="validation_error",
67
+ evidence=EvidenceEnvelope(status="error", errors=["query๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"]),
68
+ )
69
+
70
+ if self._action is None:
71
+ logger.debug("[demographics_lookup] action์ด None - ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜")
72
+ return LookupResult(
73
+ success=True,
74
+ query=query,
75
+ provider=provider,
76
+ empty_reason="no_match",
77
+ evidence=EvidenceEnvelope(status="empty"),
78
+ )
79
+
80
+ date_from = context.get("date_from", "")
81
+ date_to = context.get("date_to", "")
82
+ searchword = context.get("searchword", "")
83
+ top_n = int(context.get("top_n", 5))
84
+
85
+ if not searchword:
86
+ return LookupResult(
87
+ success=False,
88
+ query=query,
89
+ provider=provider,
90
+ error="์ธ๊ตฌํ†ต๊ณ„ ๋ถ„์„์—๋Š” searchword๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค",
91
+ empty_reason="validation_error",
92
+ evidence=EvidenceEnvelope(
93
+ status="error",
94
+ errors=["์ธ๊ตฌํ†ต๊ณ„ ๋ถ„์„์—๋Š” searchword๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค"],
95
+ ),
96
+ )
97
+
98
+ try:
99
+ gender, age, population = await asyncio.wait_for(
100
+ self._fetch_all(date_from, date_to, searchword, top_n),
101
+ timeout=self.metadata.timeout_sec,
102
+ )
103
+ except asyncio.TimeoutError:
104
+ msg = f"API ํ˜ธ์ถœ ํƒ€์ž„์•„์›ƒ ({self.metadata.timeout_sec}์ดˆ ์ดˆ๊ณผ)"
105
+ logger.warning(f"[demographics_lookup] {msg}")
106
+ return LookupResult(
107
+ success=False,
108
+ query=query,
109
+ provider=provider,
110
+ error=msg,
111
+ empty_reason="provider_error",
112
+ evidence=EvidenceEnvelope(status="error", errors=[msg]),
113
+ )
114
+ except Exception as exc:
115
+ logger.error(f"[demographics_lookup] API ํ˜ธ์ถœ ์˜ค๋ฅ˜: {exc}", exc_info=True)
116
+ return LookupResult(
117
+ success=False,
118
+ query=query,
119
+ provider=provider,
120
+ error=str(exc),
121
+ empty_reason="provider_error",
122
+ evidence=EvidenceEnvelope(status="error", errors=[str(exc)]),
123
+ )
124
+
125
+ all_results: List[Dict[str, Any]] = []
126
+ evidence_items: List[EvidenceItem] = []
127
+ errors: List[str] = []
128
+
129
+ if gender is not None:
130
+ for item in gender:
131
+ item["_source_api"] = "gender"
132
+ all_results.append(item)
133
+ evidence_items.append(
134
+ EvidenceItem(
135
+ source_type="api",
136
+ title=item.get("label", ""),
137
+ excerpt=f"์„ฑ๋ณ„: {item.get('label', '')}, " f"๊ฑด์ˆ˜={item.get('hits', 0)}",
138
+ provider_meta={"provider": provider, "api": "gender"},
139
+ )
140
+ )
141
+ else:
142
+ errors.append("์„ฑ๋ณ„ํ†ต๊ณ„ API ์‹คํŒจ")
143
+
144
+ if age is not None:
145
+ for item in age:
146
+ item["_source_api"] = "age"
147
+ all_results.append(item)
148
+ evidence_items.append(
149
+ EvidenceItem(
150
+ source_type="api",
151
+ title=f"{item.get('label', '')}๋Œ€",
152
+ excerpt=f"์—ฐ๋ น: {item.get('label', '')}๋Œ€, " f"๊ฑด์ˆ˜={item.get('hits', 0)}",
153
+ provider_meta={"provider": provider, "api": "age"},
154
+ )
155
+ )
156
+ else:
157
+ errors.append("์—ฐ๋ นํ†ต๊ณ„ API ์‹คํŒจ")
158
+
159
+ if population is not None:
160
+ for item in population:
161
+ item["_source_api"] = "population"
162
+ all_results.append(item)
163
+ evidence_items.append(
164
+ EvidenceItem(
165
+ source_type="api",
166
+ title=item.get("label", ""),
167
+ excerpt=f"์ธ๊ตฌ๋Œ€๋น„: {item.get('label', '')}, "
168
+ f"๋น„์œจ={item.get('ratio', '')}",
169
+ provider_meta={"provider": provider, "api": "population"},
170
+ )
171
+ )
172
+ else:
173
+ errors.append("์ธ๊ตฌ๋Œ€๋น„ API ์‹คํŒจ")
174
+
175
+ if not all_results:
176
+ status = "error" if len(errors) == 3 else "empty"
177
+ return LookupResult(
178
+ success=len(errors) < 3,
179
+ query=query,
180
+ provider=provider,
181
+ empty_reason="no_match" if len(errors) < 3 else "provider_error",
182
+ error="; ".join(errors) if errors else None,
183
+ evidence=EvidenceEnvelope(items=[], status=status, errors=errors),
184
+ )
185
+
186
+ context_text = self._build_context_text(gender, age, population)
187
+ status = "ok" if not errors else "partial"
188
+
189
+ return LookupResult(
190
+ success=True,
191
+ query=query,
192
+ results=all_results,
193
+ context_text=context_text,
194
+ provider=provider,
195
+ evidence=EvidenceEnvelope(
196
+ items=evidence_items,
197
+ summary_text=context_text,
198
+ status=status,
199
+ errors=errors,
200
+ ),
201
+ )
202
+
203
+ async def _fetch_all(
204
+ self,
205
+ date_from: str,
206
+ date_to: str,
207
+ searchword: str,
208
+ top_n: int,
209
+ ) -> tuple:
210
+ """3๊ฐœ API๋ฅผ ๋ณ‘๋ ฌ ํ˜ธ์ถœํ•œ๋‹ค."""
211
+ tasks = [
212
+ self._safe_call(
213
+ self._action.get_gender_stats,
214
+ date_from=date_from,
215
+ date_to=date_to,
216
+ searchword=searchword,
217
+ ),
218
+ self._safe_call(
219
+ self._action.get_age_stats,
220
+ date_from=date_from,
221
+ date_to=date_to,
222
+ searchword=searchword,
223
+ ),
224
+ self._safe_call(
225
+ self._action.get_population_ratio,
226
+ date_from=date_from,
227
+ date_to=date_to,
228
+ top_n=top_n,
229
+ ),
230
+ ]
231
+ return tuple(await asyncio.gather(*tasks))
232
+
233
+ @staticmethod
234
+ async def _safe_call(fn, **kwargs) -> Optional[List[Dict[str, Any]]]:
235
+ """๊ฐœ๋ณ„ API ํ˜ธ์ถœ์„ ์•ˆ์ „ํ•˜๊ฒŒ ๋ž˜ํ•‘ํ•œ๋‹ค."""
236
+ try:
237
+ return await fn(**kwargs)
238
+ except Exception as exc:
239
+ logger.warning(f"[demographics_lookup] ๊ฐœ๋ณ„ API ์‹คํŒจ: {exc}")
240
+ return None
241
+
242
+ @staticmethod
243
+ def _build_context_text(
244
+ gender: Optional[List],
245
+ age: Optional[List],
246
+ population: Optional[List],
247
+ ) -> str:
248
+ """์กฐํ•ฉ ๊ฒฐ๊ณผ์—์„œ ์ž์—ฐ์–ด ์š”์•ฝ์„ ์ƒ์„ฑํ•œ๋‹ค."""
249
+ parts: List[str] = []
250
+
251
+ if gender:
252
+ total = sum(int(g.get("hits", 0)) for g in gender)
253
+ if total > 0:
254
+ items = []
255
+ for g in gender:
256
+ label = g.get("label", "")
257
+ hits = int(g.get("hits", 0))
258
+ pct = (hits / total * 100) if total else 0
259
+ items.append(f"{label} {pct:.1f}%")
260
+ parts.append(", ".join(items))
261
+
262
+ if age:
263
+ # ๊ฐ€์žฅ ๋†’์€ ๊ฑด์ˆ˜์˜ ์—ฐ๋ น๋Œ€
264
+ sorted_age = sorted(age, key=lambda x: int(x.get("hits", 0)), reverse=True)
265
+ if sorted_age:
266
+ top = sorted_age[0]
267
+ total = sum(int(a.get("hits", 0)) for a in age)
268
+ hits = int(top.get("hits", 0))
269
+ pct = (hits / total * 100) if total else 0
270
+ parts.append(f"{top.get('label', '')}๋Œ€ ์ตœ๋‹ค({pct:.1f}%)")
271
+
272
+ if population:
273
+ if len(population) > 0:
274
+ top = population[0]
275
+ ratio = top.get("ratio", "")
276
+ label = top.get("label", "")
277
+ try:
278
+ ratio_pct = float(ratio) * 100 if ratio else 0
279
+ parts.append(f"{label} ์ธ๊ตฌ๋Œ€๋น„ {ratio_pct:.2f}%")
280
+ except (ValueError, TypeError):
281
+ parts.append(f"{label} ์ธ๊ตฌ๋Œ€๋น„ {ratio}")
282
+
283
+ return ", ".join(parts) if parts else ""
src/inference/graph/capabilities/draft_civil_response.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """draft_civil_response capability โ€” ๊ธฐ์กด closure๋ฅผ CapabilityBase๋กœ ๋ž˜ํ•‘."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict
6
+
7
+ from .base import CapabilityBase, CapabilityMetadata, EvidenceEnvelope, EvidenceItem, LookupResult
8
+ from .defaults import get_timeout
9
+
10
+
11
+ class DraftCivilResponseCapability(CapabilityBase):
12
+ """๋ฏผ์› ๋‹ต๋ณ€ ์ดˆ์•ˆ ์ƒ์„ฑ capability.
13
+
14
+ ๊ธฐ์กด api_server์˜ _draft_civil_response_tool closure๋ฅผ ์ฃผ์ž…๋ฐ›์•„
15
+ CapabilityBase ์ธํ„ฐํŽ˜์ด์Šค๋กœ ๋ž˜ํ•‘ํ•œ๋‹ค.
16
+
17
+ Parameters
18
+ ----------
19
+ execute_fn : Callable
20
+ ``async (query, context, session) -> dict`` ์‹œ๊ทธ๋‹ˆ์ฒ˜์˜ ์‹คํ–‰ ํ•จ์ˆ˜.
21
+ """
22
+
23
+ def __init__(self, execute_fn: Callable[..., Any]) -> None:
24
+ self._execute_fn = execute_fn
25
+
26
+ @property
27
+ def metadata(self) -> CapabilityMetadata:
28
+ return CapabilityMetadata(
29
+ name="draft_civil_response",
30
+ description=(
31
+ "๊ฒ€์ƒ‰๋œ ๋ฒ•๋ น/์‚ฌ๋ก€์™€ ์™ธ๋ถ€ ๋ฏผ์›๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ์ข…ํ•ฉํ•˜์—ฌ " "๋ฏผ์› ๋‹ต๋ณ€ ์ดˆ์•ˆ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค."
32
+ ),
33
+ approval_summary="AI ๋ชจ๋ธ์ด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ ์ข…ํ•ฉํ•˜์—ฌ ๋ฏผ์› ๋‹ต๋ณ€ ์ดˆ์•ˆ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.",
34
+ provider="local_llm",
35
+ timeout_sec=get_timeout("draft_civil_response"),
36
+ )
37
+
38
+ async def execute(
39
+ self,
40
+ query: str,
41
+ context: Dict[str, Any],
42
+ session: Any,
43
+ ) -> LookupResult:
44
+ """์ฃผ์ž…๋ฐ›์€ ํ•จ์ˆ˜์— ์œ„์ž„ํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ LookupResult๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค."""
45
+ raw = await self._execute_fn(query=query, context=context, session=session)
46
+
47
+ if isinstance(raw, dict) and raw.get("error"):
48
+ return LookupResult(
49
+ success=False,
50
+ query=query,
51
+ provider=self.metadata.provider,
52
+ error=raw["error"],
53
+ empty_reason="provider_error",
54
+ evidence=EvidenceEnvelope(
55
+ status="error",
56
+ errors=[raw["error"]],
57
+ ),
58
+ )
59
+
60
+ text = raw.get("text", "") if isinstance(raw, dict) else str(raw)
61
+
62
+ # draft์—์„œ ์ฐธ์กฐ๋œ ์‚ฌ๋ก€๋ฅผ EvidenceItem์œผ๋กœ ๋ณ€ํ™˜
63
+ evidence_items: list[EvidenceItem] = []
64
+ if isinstance(raw, dict):
65
+ # raw์— ํฌํ•จ๋œ citations/references๋ฅผ EvidenceItem์œผ๋กœ ๋ณ€ํ™˜
66
+ for ref in raw.get("citations", []):
67
+ if not isinstance(ref, dict):
68
+ continue
69
+ title = ref.get("title") or ref.get("qnaTitle") or ref.get("question", "")
70
+ excerpt = ref.get("content") or ref.get("qnaContent") or ref.get("qnaAnswer", "")
71
+ link = ref.get("url") or ref.get("detailUrl", "")
72
+ evidence_items.append(
73
+ EvidenceItem(
74
+ source_type="llm_generated",
75
+ title=str(title),
76
+ excerpt=str(excerpt)[:500],
77
+ link_or_path=str(link),
78
+ provider_meta={"provider": self.metadata.provider},
79
+ )
80
+ )
81
+
82
+ envelope = EvidenceEnvelope(
83
+ items=evidence_items,
84
+ summary_text=text,
85
+ status="ok",
86
+ )
87
+
88
+ return LookupResult(
89
+ success=True,
90
+ query=query,
91
+ context_text=text,
92
+ provider=self.metadata.provider,
93
+ # draft ๊ฒฐ๊ณผ๋Š” results ๋Œ€์‹  context_text์— ๋‹ด๊ธด๋‹ค
94
+ results=[raw] if isinstance(raw, dict) else [],
95
+ evidence=envelope,
96
+ )
src/inference/graph/capabilities/issue_detector.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """issue_detector capability โ€” ๊ธ‰์ฆํ‚ค์›Œ๋“œ+์˜ค๋Š˜์ด์Šˆ+์ตœ๋‹คํ‚ค์›Œ๋“œ ์กฐํ•ฉ.
2
+
3
+ Issue #486: ๋ฏผ์› ์ด์Šˆ ํƒ์ง€ ๋„๊ตฌ.
4
+ 3๊ฐœ API(๊ธ‰์ฆํ‚ค์›Œ๋“œ, ์˜ค๋Š˜์ด์Šˆ, ์ตœ๋‹คํ‚ค์›Œ๋“œ)๋ฅผ ์กฐํ•ฉํ•˜์—ฌ
5
+ ํ˜„์žฌ ์ฃผ์š” ์ด์Šˆ๋ฅผ ํƒ์ง€ํ•˜๊ณ  ์ž์—ฐ์–ด ์š”์•ฝ์„ ์ƒ์„ฑํ•œ๋‹ค.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from loguru import logger
14
+
15
+ from .base import (
16
+ CapabilityBase,
17
+ CapabilityMetadata,
18
+ EvidenceEnvelope,
19
+ EvidenceItem,
20
+ LookupResult,
21
+ )
22
+ from .defaults import get_timeout
23
+
24
+
25
+ class IssueDetectorCapability(CapabilityBase):
26
+ """๋ฏผ์› ์ด์Šˆ ํƒ์ง€ capability.
27
+
28
+ ๊ธ‰์ฆํ‚ค์›Œ๋“œ, ์˜ค๋Š˜ ์ด์Šˆ ํ† ํ”ฝ, ์ตœ๋‹ค ํ‚ค์›Œ๋“œ๋ฅผ ์กฐํ•ฉํ•˜์—ฌ
29
+ ํ˜„์žฌ ์ฃผ์š” ๋ฏผ์› ์ด์Šˆ๋ฅผ ํŒŒ์•…ํ•œ๋‹ค.
30
+
31
+ Parameters
32
+ ----------
33
+ action : Optional[MinwonAnalysisAction]
34
+ API ํ˜ธ์ถœ์šฉ Action ์ธ์Šคํ„ด์Šค. None์ด๋ฉด ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜.
35
+ """
36
+
37
+ def __init__(self, action: Optional[Any] = None) -> None:
38
+ self._action = action
39
+
40
+ @property
41
+ def metadata(self) -> CapabilityMetadata:
42
+ return CapabilityMetadata(
43
+ name="issue_detector",
44
+ description=(
45
+ "๊ธ‰์ฆํ‚ค์›Œ๋“œ, ์˜ค๋Š˜์ด์Šˆ, ์ตœ๋‹คํ‚ค์›Œ๋“œ๋ฅผ ์กฐํ•ฉํ•˜์—ฌ " "ํ˜„์žฌ ์ฃผ์š” ๋ฏผ์› ์ด์Šˆ๋ฅผ ํƒ์ง€ํ•ฉ๋‹ˆ๋‹ค."
46
+ ),
47
+ approval_summary="๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ์—์„œ ๋ฏผ์› ์ด์Šˆ ํ˜„ํ™ฉ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.",
48
+ provider="data.go.kr",
49
+ timeout_sec=get_timeout("issue_detector"),
50
+ )
51
+
52
+ async def execute(
53
+ self,
54
+ query: str,
55
+ context: Dict[str, Any],
56
+ session: Any,
57
+ ) -> LookupResult:
58
+ """3๊ฐœ API๋ฅผ ๋ณ‘๋ ฌ ํ˜ธ์ถœํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ์กฐํ•ฉํ•œ๋‹ค."""
59
+ provider = self.metadata.provider
60
+
61
+ if not query or not query.strip():
62
+ return LookupResult(
63
+ success=False,
64
+ query=query,
65
+ provider=provider,
66
+ error="query๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค",
67
+ empty_reason="validation_error",
68
+ evidence=EvidenceEnvelope(status="error", errors=["query๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"]),
69
+ )
70
+
71
+ if self._action is None:
72
+ logger.debug("[issue_detector] action์ด None - ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜")
73
+ return LookupResult(
74
+ success=True,
75
+ query=query,
76
+ provider=provider,
77
+ empty_reason="no_match",
78
+ evidence=EvidenceEnvelope(status="empty"),
79
+ )
80
+
81
+ # ๋‚ ์งœ ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”์ถœ ๋ฐ ๊ฒ€์ฆ
82
+ analysis_time = context.get("analysis_time", "")
83
+ search_date = context.get("search_date", "")
84
+ max_result = int(context.get("max_result", 5))
85
+
86
+ if not analysis_time and not search_date:
87
+ err = "analysis_time ๋˜๋Š” search_date ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค"
88
+ return LookupResult(
89
+ success=False,
90
+ query=query,
91
+ provider=provider,
92
+ error=err,
93
+ empty_reason="validation_error",
94
+ evidence=EvidenceEnvelope(status="error", errors=[err]),
95
+ )
96
+
97
+ try:
98
+ rising, topics, top_kw = await asyncio.wait_for(
99
+ self._fetch_all(analysis_time, search_date, max_result),
100
+ timeout=self.metadata.timeout_sec,
101
+ )
102
+ except asyncio.TimeoutError:
103
+ msg = f"API ํ˜ธ์ถœ ํƒ€์ž„์•„์›ƒ ({self.metadata.timeout_sec}์ดˆ ์ดˆ๊ณผ)"
104
+ logger.warning(f"[issue_detector] {msg}")
105
+ return LookupResult(
106
+ success=False,
107
+ query=query,
108
+ provider=provider,
109
+ error=msg,
110
+ empty_reason="provider_error",
111
+ evidence=EvidenceEnvelope(status="error", errors=[msg]),
112
+ )
113
+ except Exception as exc:
114
+ logger.error(f"[issue_detector] API ํ˜ธ์ถœ ์˜ค๋ฅ˜: {exc}", exc_info=True)
115
+ return LookupResult(
116
+ success=False,
117
+ query=query,
118
+ provider=provider,
119
+ error=str(exc),
120
+ empty_reason="provider_error",
121
+ evidence=EvidenceEnvelope(status="error", errors=[str(exc)]),
122
+ )
123
+
124
+ # ๊ฒฐ๊ณผ ์กฐํ•ฉ
125
+ all_results: List[Dict[str, Any]] = []
126
+ evidence_items: List[EvidenceItem] = []
127
+ errors: List[str] = []
128
+
129
+ if rising is not None:
130
+ for item in rising:
131
+ item["_source_api"] = "rising_keyword"
132
+ all_results.append(item)
133
+ evidence_items.append(
134
+ EvidenceItem(
135
+ source_type="api",
136
+ title=item.get("keyword", ""),
137
+ excerpt=f"๊ธ‰์ฆํ‚ค์›Œ๋“œ: {item.get('keyword', '')}, "
138
+ f"๋นˆ๋„={item.get('df', 0)}, ์ „์ผ๋Œ€๋น„={item.get('prevRatio', '')}%",
139
+ provider_meta={"provider": provider, "api": "rising_keyword"},
140
+ )
141
+ )
142
+ else:
143
+ errors.append("๊ธ‰์ฆํ‚ค์›Œ๋“œ API ์‹คํŒจ")
144
+
145
+ if topics is not None:
146
+ for item in topics:
147
+ item["_source_api"] = "today_topic"
148
+ all_results.append(item)
149
+ evidence_items.append(
150
+ EvidenceItem(
151
+ source_type="api",
152
+ title=item.get("topic", ""),
153
+ excerpt=f"์˜ค๋Š˜์ด์Šˆ: {item.get('topic', '')}, "
154
+ f"๊ฑด์ˆ˜={item.get('count', 0)}",
155
+ provider_meta={"provider": provider, "api": "today_topic"},
156
+ )
157
+ )
158
+ else:
159
+ errors.append("์˜ค๋Š˜์ด์Šˆ API ์‹คํŒจ")
160
+
161
+ if top_kw is not None:
162
+ for item in top_kw:
163
+ item["_source_api"] = "top_keyword"
164
+ all_results.append(item)
165
+ evidence_items.append(
166
+ EvidenceItem(
167
+ source_type="api",
168
+ title=item.get("term", ""),
169
+ excerpt=f"์ตœ๋‹คํ‚ค์›Œ๋“œ: {item.get('term', '')}, " f"๋นˆ๋„={item.get('df', 0)}",
170
+ provider_meta={"provider": provider, "api": "top_keyword"},
171
+ )
172
+ )
173
+ else:
174
+ errors.append("์ตœ๋‹คํ‚ค์›Œ๋“œ API ์‹คํŒจ")
175
+
176
+ if not all_results:
177
+ status = "error" if len(errors) == 3 else "empty"
178
+ return LookupResult(
179
+ success=len(errors) < 3,
180
+ query=query,
181
+ provider=provider,
182
+ empty_reason="no_match" if len(errors) < 3 else "provider_error",
183
+ error="; ".join(errors) if errors else None,
184
+ evidence=EvidenceEnvelope(items=[], status=status, errors=errors),
185
+ )
186
+
187
+ context_text = self._build_context_text(rising, topics, top_kw)
188
+ status = "ok" if not errors else "partial"
189
+
190
+ return LookupResult(
191
+ success=True,
192
+ query=query,
193
+ results=all_results,
194
+ context_text=context_text,
195
+ provider=provider,
196
+ evidence=EvidenceEnvelope(
197
+ items=evidence_items,
198
+ summary_text=context_text,
199
+ status=status,
200
+ errors=errors,
201
+ ),
202
+ )
203
+
204
+ async def _fetch_all(
205
+ self,
206
+ analysis_time: str,
207
+ search_date: str,
208
+ max_result: int,
209
+ ) -> tuple:
210
+ """3๊ฐœ API๋ฅผ ๋ณ‘๋ ฌ ํ˜ธ์ถœํ•œ๋‹ค."""
211
+ tasks = [
212
+ (
213
+ self._safe_call(
214
+ self._action.get_rising_keywords,
215
+ analysis_time=analysis_time,
216
+ max_result=max_result,
217
+ )
218
+ if analysis_time
219
+ else self._noop()
220
+ ),
221
+ (
222
+ self._safe_call(
223
+ self._action.get_today_topics,
224
+ search_date=search_date,
225
+ top_n=max_result,
226
+ )
227
+ if search_date
228
+ else self._noop()
229
+ ),
230
+ (
231
+ self._safe_call(
232
+ self._action.get_top_keywords_by_period,
233
+ analysis_time=analysis_time or search_date,
234
+ max_result=max_result,
235
+ )
236
+ if (analysis_time or search_date)
237
+ else self._noop()
238
+ ),
239
+ ]
240
+ return await asyncio.gather(*tasks)
241
+
242
+ @staticmethod
243
+ async def _noop() -> None:
244
+ """๋นˆ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” no-op ์ฝ”๋ฃจํ‹ด."""
245
+ return None
246
+
247
+ @staticmethod
248
+ async def _safe_call(fn, **kwargs) -> Optional[List[Dict[str, Any]]]:
249
+ """๊ฐœ๋ณ„ API ํ˜ธ์ถœ์„ ์•ˆ์ „ํ•˜๊ฒŒ ๋ž˜ํ•‘ํ•œ๋‹ค."""
250
+ try:
251
+ return await fn(**kwargs)
252
+ except Exception as exc:
253
+ logger.warning(f"[issue_detector] ๊ฐœ๋ณ„ API ์‹คํŒจ: {exc}")
254
+ return None
255
+
256
+ @staticmethod
257
+ def _build_context_text(
258
+ rising: Optional[List],
259
+ topics: Optional[List],
260
+ top_kw: Optional[List],
261
+ ) -> str:
262
+ """์กฐํ•ฉ ๊ฒฐ๊ณผ์—์„œ ์ž์—ฐ์–ด ์š”์•ฝ์„ ์ƒ์„ฑํ•œ๋‹ค."""
263
+ parts: List[str] = []
264
+
265
+ if rising:
266
+ items = []
267
+ for r in rising[:3]:
268
+ kw = r.get("keyword", "")
269
+ ratio = r.get("prevRatio", "")
270
+ items.append(f"{kw}(+{ratio}%)" if ratio else kw)
271
+ if items:
272
+ parts.append(f"๊ธ‰์ฆ ์ด์Šˆ: {', '.join(items)}")
273
+
274
+ if topics:
275
+ items = []
276
+ for t in topics[:3]:
277
+ topic = t.get("topic", "")
278
+ count = t.get("count", 0)
279
+ items.append(f"{topic}({count:,}๊ฑด)")
280
+ if items:
281
+ parts.append(f"์˜ค๋Š˜ ํ•ต์‹ฌ: {', '.join(items)}")
282
+
283
+ if top_kw:
284
+ items = []
285
+ for k in top_kw[:3]:
286
+ term = k.get("term", "")
287
+ df = k.get("df", 0)
288
+ items.append(f"{term}({df}๊ฑด)")
289
+ if items:
290
+ parts.append(f"์ตœ๋‹ค: {', '.join(items)}")
291
+
292
+ return "; ".join(parts) if parts else ""
src/inference/graph/capabilities/keyword_analyzer.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """keyword_analyzer capability โ€” ํ•ต์‹ฌํ‚ค์›Œ๋“œ+์—ฐ๊ด€์–ด ์กฐํ•ฉ.
2
+
3
+ Issue #488: ๋ฏผ์› ํ‚ค์›Œ๋“œ ๋ถ„์„ ๋„๊ตฌ.
4
+ 2๊ฐœ API(ํ•ต์‹ฌํ‚ค์›Œ๋“œ, ์—ฐ๊ด€์–ด)๋ฅผ ์กฐํ•ฉํ•˜์—ฌ
5
+ ํ‚ค์›Œ๋“œ ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ์ œ๊ณตํ•œ๋‹ค.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from loguru import logger
14
+
15
+ from .base import (
16
+ CapabilityBase,
17
+ CapabilityMetadata,
18
+ EvidenceEnvelope,
19
+ EvidenceItem,
20
+ LookupResult,
21
+ )
22
+ from .defaults import get_timeout
23
+
24
+
25
+ class KeywordAnalyzerCapability(CapabilityBase):
26
+ """๋ฏผ์› ํ‚ค์›Œ๋“œ ๋ถ„์„ capability.
27
+
28
+ ํ•ต์‹ฌํ‚ค์›Œ๋“œ์™€ ์—ฐ๊ด€์–ด๋ฅผ ์กฐํ•ฉํ•˜์—ฌ ํ‚ค์›Œ๋“œ ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ์ œ๊ณตํ•œ๋‹ค.
29
+
30
+ Parameters
31
+ ----------
32
+ action : Optional[MinwonAnalysisAction]
33
+ API ํ˜ธ์ถœ์šฉ Action ์ธ์Šคํ„ด์Šค. None์ด๋ฉด ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜.
34
+ """
35
+
36
+ def __init__(self, action: Optional[Any] = None) -> None:
37
+ self._action = action
38
+
39
+ @property
40
+ def metadata(self) -> CapabilityMetadata:
41
+ return CapabilityMetadata(
42
+ name="keyword_analyzer",
43
+ description=("ํ•ต์‹ฌํ‚ค์›Œ๋“œ์™€ ์—ฐ๊ด€์–ด๋ฅผ ์กฐํ•ฉํ•˜์—ฌ " "๋ฏผ์› ํ‚ค์›Œ๋“œ ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค."),
44
+ approval_summary="๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ์—์„œ ๋ฏผ์› ํ‚ค์›Œ๋“œ๋ฅผ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค.",
45
+ provider="data.go.kr",
46
+ timeout_sec=get_timeout("keyword_analyzer"),
47
+ )
48
+
49
+ async def execute(
50
+ self,
51
+ query: str,
52
+ context: Dict[str, Any],
53
+ session: Any,
54
+ ) -> LookupResult:
55
+ """ํ•ต์‹ฌํ‚ค์›Œ๋“œ + ์—ฐ๊ด€์–ด API๋ฅผ ๋ณ‘๋ ฌ ํ˜ธ์ถœํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ์กฐํ•ฉํ•œ๋‹ค."""
56
+ provider = self.metadata.provider
57
+
58
+ if not query or not query.strip():
59
+ return LookupResult(
60
+ success=False,
61
+ query=query,
62
+ provider=provider,
63
+ error="query๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค",
64
+ empty_reason="validation_error",
65
+ evidence=EvidenceEnvelope(status="error", errors=["query๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"]),
66
+ )
67
+
68
+ if self._action is None:
69
+ logger.debug("[keyword_analyzer] action์ด None - ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜")
70
+ return LookupResult(
71
+ success=True,
72
+ query=query,
73
+ provider=provider,
74
+ empty_reason="no_match",
75
+ evidence=EvidenceEnvelope(status="empty"),
76
+ )
77
+
78
+ date_from = context.get("date_from", "")
79
+ date_to = context.get("date_to", "")
80
+ searchword = context.get("searchword", "")
81
+ result_count = int(context.get("result_count", 5))
82
+
83
+ try:
84
+ core_kw, related = await asyncio.wait_for(
85
+ self._fetch_all(date_from, date_to, searchword, result_count),
86
+ timeout=self.metadata.timeout_sec,
87
+ )
88
+ except asyncio.TimeoutError:
89
+ msg = f"API ํ˜ธ์ถœ ํƒ€์ž„์•„์›ƒ ({self.metadata.timeout_sec}์ดˆ ์ดˆ๊ณผ)"
90
+ logger.warning(f"[keyword_analyzer] {msg}")
91
+ return LookupResult(
92
+ success=False,
93
+ query=query,
94
+ provider=provider,
95
+ error=msg,
96
+ empty_reason="provider_error",
97
+ evidence=EvidenceEnvelope(status="error", errors=[msg]),
98
+ )
99
+ except Exception as exc:
100
+ logger.error(f"[keyword_analyzer] API ํ˜ธ์ถœ ์˜ค๋ฅ˜: {exc}", exc_info=True)
101
+ return LookupResult(
102
+ success=False,
103
+ query=query,
104
+ provider=provider,
105
+ error=str(exc),
106
+ empty_reason="provider_error",
107
+ evidence=EvidenceEnvelope(status="error", errors=[str(exc)]),
108
+ )
109
+
110
+ all_results: List[Dict[str, Any]] = []
111
+ evidence_items: List[EvidenceItem] = []
112
+ errors: List[str] = []
113
+
114
+ if core_kw is not None:
115
+ for item in core_kw:
116
+ item["_source_api"] = "core_keyword"
117
+ all_results.append(item)
118
+ evidence_items.append(
119
+ EvidenceItem(
120
+ source_type="api",
121
+ title=item.get("label", ""),
122
+ excerpt=f"ํ•ต์‹ฌํ‚ค์›Œ๋“œ: {item.get('label', '')}, "
123
+ f"์ ์ˆ˜={item.get('value', 0)}",
124
+ provider_meta={"provider": provider, "api": "core_keyword"},
125
+ )
126
+ )
127
+ else:
128
+ errors.append("ํ•ต์‹ฌํ‚ค์›Œ๋“œ API ์‹คํŒจ")
129
+
130
+ if related is not None:
131
+ for item in related:
132
+ item["_source_api"] = "related_word"
133
+ all_results.append(item)
134
+ evidence_items.append(
135
+ EvidenceItem(
136
+ source_type="api",
137
+ title=item.get("label", ""),
138
+ excerpt=f"์—ฐ๊ด€์–ด: {item.get('label', '')}, " f"์ ์ˆ˜={item.get('value', 0)}",
139
+ provider_meta={"provider": provider, "api": "related_word"},
140
+ )
141
+ )
142
+ else:
143
+ if searchword:
144
+ errors.append("์—ฐ๊ด€์–ด API ์‹คํŒจ")
145
+
146
+ if not all_results:
147
+ status = "error" if errors else "empty"
148
+ return LookupResult(
149
+ success=not errors,
150
+ query=query,
151
+ provider=provider,
152
+ empty_reason="no_match" if not errors else "provider_error",
153
+ error="; ".join(errors) if errors else None,
154
+ evidence=EvidenceEnvelope(items=[], status=status, errors=errors),
155
+ )
156
+
157
+ context_text = self._build_context_text(core_kw, related)
158
+ status = "ok" if not errors else "partial"
159
+
160
+ return LookupResult(
161
+ success=True,
162
+ query=query,
163
+ results=all_results,
164
+ context_text=context_text,
165
+ provider=provider,
166
+ evidence=EvidenceEnvelope(
167
+ items=evidence_items,
168
+ summary_text=context_text,
169
+ status=status,
170
+ errors=errors,
171
+ ),
172
+ )
173
+
174
+ async def _fetch_all(
175
+ self,
176
+ date_from: str,
177
+ date_to: str,
178
+ searchword: str,
179
+ result_count: int,
180
+ ) -> tuple:
181
+ """ํ•ต์‹ฌํ‚ค์›Œ๋“œ + ์—ฐ๊ด€์–ด๋ฅผ ๋ณ‘๋ ฌ ํ˜ธ์ถœํ•œ๋‹ค."""
182
+ tasks = []
183
+
184
+ # ํ•ต์‹ฌํ‚ค์›Œ๋“œ๋Š” date_from/date_to๊ฐ€ ์žˆ์œผ๋ฉด ํ•ญ์ƒ ํ˜ธ์ถœ
185
+ if date_from and date_to:
186
+ tasks.append(
187
+ self._safe_call(
188
+ self._action.get_core_keywords,
189
+ date_from=date_from,
190
+ date_to=date_to,
191
+ result_count=result_count,
192
+ )
193
+ )
194
+ else:
195
+ tasks.append(self._noop())
196
+
197
+ # ์—ฐ๊ด€์–ด๋Š” searchword๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ ํ˜ธ์ถœ
198
+ if date_from and date_to and searchword:
199
+ tasks.append(
200
+ self._safe_call(
201
+ self._action.get_related_words,
202
+ date_from=date_from,
203
+ date_to=date_to,
204
+ searchword=searchword,
205
+ result_count=result_count,
206
+ )
207
+ )
208
+ else:
209
+ tasks.append(self._noop())
210
+
211
+ return tuple(await asyncio.gather(*tasks))
212
+
213
+ @staticmethod
214
+ async def _safe_call(fn, **kwargs) -> Optional[List[Dict[str, Any]]]:
215
+ """๊ฐœ๋ณ„ API ํ˜ธ์ถœ์„ ์•ˆ์ „ํ•˜๊ฒŒ ๋ž˜ํ•‘ํ•œ๋‹ค."""
216
+ try:
217
+ return await fn(**kwargs)
218
+ except Exception as exc:
219
+ logger.warning(f"[keyword_analyzer] ๊ฐœ๋ณ„ API ์‹คํŒจ: {exc}")
220
+ return None
221
+
222
+ @staticmethod
223
+ async def _noop() -> None:
224
+ """๋นˆ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” no-op ์ฝ”๋ฃจํ‹ด."""
225
+ return None
226
+
227
+ @staticmethod
228
+ def _build_context_text(
229
+ core_kw: Optional[List],
230
+ related: Optional[List],
231
+ ) -> str:
232
+ """์กฐํ•ฉ ๊ฒฐ๊ณผ์—์„œ ์ž์—ฐ์–ด ์š”์•ฝ์„ ์ƒ์„ฑํ•œ๋‹ค."""
233
+ parts: List[str] = []
234
+
235
+ if core_kw:
236
+ items = []
237
+ for k in core_kw[:5]:
238
+ label = k.get("label", "")
239
+ value = k.get("value", 0)
240
+ try:
241
+ value_f = float(value)
242
+ items.append(f"{label}({value_f:,.0f}๊ฑด)")
243
+ except (ValueError, TypeError):
244
+ items.append(f"{label}({value})")
245
+ if items:
246
+ parts.append(f"ํ•ต์‹ฌ ํ‚ค์›Œ๋“œ: {', '.join(items)}")
247
+
248
+ if related:
249
+ items = []
250
+ for r in related[:5]:
251
+ label = r.get("label", "")
252
+ value = r.get("value", 0)
253
+ try:
254
+ value_f = float(value)
255
+ items.append(f"{label}({value_f:,.1f}์ )")
256
+ except (ValueError, TypeError):
257
+ items.append(f"{label}({value})")
258
+ if items:
259
+ parts.append(f"์—ฐ๊ด€์–ด: {', '.join(items)}")
260
+
261
+ return ", ".join(parts) if parts else ""
src/inference/graph/capabilities/rag_search.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """rag_search capability โ€” ๋กœ์ปฌ ๋ฌธ์„œ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ๊ฒ€์ƒ‰์„ LangGraph capability๋กœ ํ‘œ์ค€ํ™”.
2
+
3
+ Issue #395: local RAG ๊ฒ€์ƒ‰์„ LangGraph tool capability๋กœ ํ‘œ์ค€ํ™”.
4
+
5
+ ApiLookupCapability ํŒจํ„ด์„ ๋”ฐ๋ผ ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฒ€์ฆ, ํƒ€์ž„์•„์›ƒ, ๊ฒฐ๊ณผ ์ •๊ทœํ™”,
6
+ fallback ์ •์ฑ…(empty/low-confidence)์„ ๊ตฌํ˜„ํ•œ๋‹ค.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Callable, Dict, List, Optional
14
+
15
+ from loguru import logger
16
+
17
+ from src.inference.index_manager import IndexType
18
+
19
+ from .base import CapabilityBase, CapabilityMetadata, EvidenceEnvelope, EvidenceItem, LookupResult
20
+ from .defaults import get_timeout
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # ์ƒ์ˆ˜
24
+ # ---------------------------------------------------------------------------
25
+
26
+ LOW_CONFIDENCE_THRESHOLD: float = 0.3
27
+ """์ €์‹ ๋ขฐ๋„ ๊ธฐ๋ณธ ์ž„๊ณ„๊ฐ’. ๋ชจ๋“  ๊ฒฐ๊ณผ score๊ฐ€ ์ด ๊ฐ’ ๋ฏธ๋งŒ์ด๋ฉด low_confidence๋กœ ๋ถ„๋ฅ˜."""
28
+
29
+ _VALID_SOURCE_TYPES: frozenset[str] = frozenset(t.value for t in IndexType)
30
+ _DEFAULT_SOURCE_TYPES: list[str] = [t.value for t in IndexType]
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # ํŒŒ๋ผ๋ฏธํ„ฐ validator
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ @dataclass
39
+ class RagSearchParams:
40
+ """rag_search ํ˜ธ์ถœ ํŒŒ๋ผ๋ฏธํ„ฐ โ€” context์—์„œ ์ถ”์ถœยท์ •๊ทœํ™”ยท๊ฒ€์ฆ."""
41
+
42
+ query: str
43
+ top_k: int = 5
44
+ source_types: List[str] = field(default_factory=lambda: list(_DEFAULT_SOURCE_TYPES))
45
+ min_confidence: float = LOW_CONFIDENCE_THRESHOLD
46
+
47
+ @classmethod
48
+ def from_context(
49
+ cls,
50
+ query: str,
51
+ context: Dict[str, Any],
52
+ default_min_confidence: float = LOW_CONFIDENCE_THRESHOLD,
53
+ ) -> "RagSearchParams":
54
+ """context์—์„œ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ถ”์ถœํ•˜๊ณ  alias๋ฅผ ์ •๊ทœํ™”ํ•œ๋‹ค."""
55
+ top_k = int(context.get("rag_top_k") or context.get("top_k") or context.get("count") or 5)
56
+
57
+ raw_filters = context.get("filters") or context.get("source_types")
58
+ if isinstance(raw_filters, list):
59
+ source_types = [str(f).lower() for f in raw_filters]
60
+ else:
61
+ source_types = list(_DEFAULT_SOURCE_TYPES)
62
+
63
+ raw = context.get("rag_min_confidence")
64
+ if raw is None:
65
+ raw = context.get("min_confidence")
66
+ if raw is None:
67
+ raw = context.get("score_threshold")
68
+ if raw is None:
69
+ raw = default_min_confidence
70
+ min_confidence = float(raw)
71
+
72
+ return cls(
73
+ query=query.strip(),
74
+ top_k=max(1, min(50, top_k)),
75
+ source_types=source_types,
76
+ min_confidence=max(0.0, min(1.0, min_confidence)),
77
+ )
78
+
79
+ def validate(self) -> Optional[str]:
80
+ """๊ฒ€์ฆ ์‹คํŒจ ์‹œ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€, ํ†ต๊ณผ ์‹œ None."""
81
+ if not self.query:
82
+ return "query๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"
83
+ if len(self.query) > 2000:
84
+ return f"query๊ฐ€ ๋„ˆ๋ฌด ๊น๋‹ˆ๋‹ค ({len(self.query)}์ž, ์ตœ๋Œ€ 2000์ž)"
85
+ invalid = [t for t in self.source_types if t not in _VALID_SOURCE_TYPES]
86
+ if invalid:
87
+ return f"์œ ํšจํ•˜์ง€ ์•Š์€ source_type: {invalid}"
88
+ return None
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # ๊ฒฐ๊ณผ ์ •๊ทœํ™” ํ—ฌํผ
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def _normalize_result(raw: Dict[str, Any]) -> Dict[str, Any]:
97
+ """raw SearchResult dict์— ๊ณตํ†ต ์Šคํ‚ค๋งˆ ํ•„๋“œ๋ฅผ ๋ณด๊ฐ•ํ•œ๋‹ค.
98
+
99
+ ์ถ”๊ฐ€ ํ•„๋“œ: excerpt, file_path, page, score, source_type, doc_id, title.
100
+ ๊ธฐ์กด ํ•„๋“œ๋Š” ๊ทธ๋Œ€๋กœ ์œ ์ง€.
101
+ """
102
+ content = raw.get("content", "")
103
+ metadata = raw.get("metadata", {})
104
+ result = dict(raw)
105
+ result["excerpt"] = content[:500] if content else ""
106
+ result["file_path"] = metadata.get("file_path", "")
107
+ result["page"] = metadata.get("page", raw.get("chunk_index", 0))
108
+ result["score"] = raw.get("score", 0.0)
109
+ result["source_type"] = raw.get("source_type", "")
110
+ result["doc_id"] = raw.get("doc_id", "")
111
+ result["title"] = raw.get("title", "")
112
+ return result
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Capability
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ class RagSearchCapability(CapabilityBase):
121
+ """๋กœ์ปฌ ๋ฌธ์„œ(๋ฒ•๋ น/๋งค๋‰ด์–ผ/์‚ฌ๋ก€/๊ณต์ง€) ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ๊ฒ€์ƒ‰ capability.
122
+
123
+ ๊ธฐ์กด api_server์˜ _rag_search_tool closure๋ฅผ ์ฃผ์ž…๋ฐ›์•„
124
+ CapabilityBase ์ธํ„ฐํŽ˜์ด์Šค๋กœ ๋ž˜ํ•‘ํ•˜๊ณ , ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฒ€์ฆยทํƒ€์ž„์•„์›ƒยท
125
+ ๊ฒฐ๊ณผ ์ •๊ทœํ™”ยทfallback ์ •์ฑ…์„ ์ ์šฉํ•œ๋‹ค.
126
+
127
+ Parameters
128
+ ----------
129
+ execute_fn : Callable
130
+ ``async (query, context, session) -> dict`` ์‹œ๊ทธ๋‹ˆ์ฒ˜์˜ ์‹คํ–‰ ํ•จ์ˆ˜.
131
+ low_confidence_threshold : float
132
+ ์ €์‹ ๋ขฐ๋„ ์ž„๊ณ„๊ฐ’. ๋ชจ๋“  ๊ฒฐ๊ณผ๊ฐ€ ์ด ๊ฐ’ ๋ฏธ๋งŒ์ด๋ฉด ``low_confidence``๋กœ ๋ถ„๋ฅ˜.
133
+ """
134
+
135
+ def __init__(
136
+ self,
137
+ execute_fn: Callable[..., Any],
138
+ low_confidence_threshold: float = LOW_CONFIDENCE_THRESHOLD,
139
+ ) -> None:
140
+ self._execute_fn = execute_fn
141
+ self._low_confidence_threshold = low_confidence_threshold
142
+
143
+ @property
144
+ def metadata(self) -> CapabilityMetadata:
145
+ return CapabilityMetadata(
146
+ name="rag_search",
147
+ description=(
148
+ "๋‚ด๋ถ€ ๋ฒ•๋ น, ๋งค๋‰ด์–ผ, ๋ฏผ์› ์‚ฌ๋ก€, ๊ณต์ง€์‚ฌํ•ญ ๋“ฑ ๋กœ์ปฌ ๋ฌธ์„œ๋ฅผ "
149
+ "ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ๊ฒ€์ƒ‰(BM25 + ๋ฒกํ„ฐ)์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค."
150
+ ),
151
+ approval_summary="๋กœ์ปฌ ๋ฌธ์„œ DB์—์„œ ๊ด€๋ จ ๋ฒ•๋ น/์‚ฌ๋ก€๋ฅผ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค.",
152
+ provider="local_vectordb",
153
+ timeout_sec=get_timeout("rag_search"),
154
+ )
155
+
156
+ async def execute(
157
+ self,
158
+ query: str,
159
+ context: Dict[str, Any],
160
+ session: Any,
161
+ ) -> LookupResult:
162
+ """๊ฒ€์ƒ‰ ์‹คํ–‰ โ€” ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฒ€์ฆ, ํƒ€์ž„์•„์›ƒ, ์ •๊ทœํ™”, fallback ์ ์šฉ."""
163
+ provider = self.metadata.provider
164
+
165
+ params = RagSearchParams.from_context(
166
+ query, context, default_min_confidence=self._low_confidence_threshold
167
+ )
168
+ validation_error = params.validate()
169
+ if validation_error:
170
+ return LookupResult(
171
+ success=False,
172
+ query=query,
173
+ provider=provider,
174
+ error=validation_error,
175
+ empty_reason="validation_error",
176
+ )
177
+
178
+ try:
179
+ raw = await asyncio.wait_for(
180
+ self._execute_fn(query=params.query, context=context, session=session),
181
+ timeout=self.metadata.timeout_sec,
182
+ )
183
+ except asyncio.TimeoutError:
184
+ logger.warning(f"[rag_search] ํƒ€์ž„์•„์›ƒ ({self.metadata.timeout_sec}s ์ดˆ๊ณผ)")
185
+ return LookupResult(
186
+ success=False,
187
+ query=params.query,
188
+ provider=provider,
189
+ error=f"๊ฒ€์ƒ‰ ํƒ€์ž„์•„์›ƒ ({self.metadata.timeout_sec}์ดˆ ์ดˆ๊ณผ)",
190
+ empty_reason="provider_error",
191
+ )
192
+ except Exception as exc:
193
+ logger.error(f"[rag_search] ๊ฒ€์ƒ‰ ์˜ค๋ฅ˜: {exc}", exc_info=True)
194
+ return LookupResult(
195
+ success=False,
196
+ query=params.query,
197
+ provider=provider,
198
+ error=str(exc),
199
+ empty_reason="provider_error",
200
+ )
201
+
202
+ if not isinstance(raw, dict):
203
+ raw = {}
204
+
205
+ if raw.get("error"):
206
+ return LookupResult(
207
+ success=False,
208
+ query=raw.get("query", params.query),
209
+ provider=provider,
210
+ error=raw["error"],
211
+ empty_reason="provider_error",
212
+ evidence=EvidenceEnvelope(
213
+ status="error",
214
+ errors=[raw["error"]],
215
+ ),
216
+ )
217
+
218
+ raw_query = raw.get("query", params.query)
219
+ raw_context_text = raw.get("context_text", "")
220
+ raw_results = raw.get("results", [])
221
+ normalized = [_normalize_result(r) for r in raw_results]
222
+
223
+ # EvidenceItem์œผ๋กœ ์ •๊ทœํ™”
224
+ evidence_items = []
225
+ for r in normalized:
226
+ evidence_items.append(
227
+ EvidenceItem(
228
+ source_type="rag",
229
+ title=r.get("title", ""),
230
+ excerpt=r.get("excerpt", "")[:500],
231
+ link_or_path=r.get("file_path", ""),
232
+ page=r.get("page"),
233
+ score=float(r.get("score", 0.0)),
234
+ provider_meta={"provider": provider},
235
+ )
236
+ )
237
+
238
+ if not normalized:
239
+ return LookupResult(
240
+ success=True,
241
+ query=raw_query,
242
+ provider=provider,
243
+ empty_reason="no_match",
244
+ context_text=raw_context_text,
245
+ evidence=EvidenceEnvelope(items=[], status="empty"),
246
+ )
247
+
248
+ confident = [r for r in normalized if r["score"] >= params.min_confidence]
249
+ if not confident:
250
+ logger.info(f"[rag_search] ๋ชจ๋“  ๊ฒฐ๊ณผ๊ฐ€ ์ €์‹ ๋ขฐ๋„ (threshold={params.min_confidence})")
251
+ return LookupResult(
252
+ success=True,
253
+ query=raw_query,
254
+ results=normalized,
255
+ context_text=raw_context_text,
256
+ provider=provider,
257
+ empty_reason="low_confidence",
258
+ evidence=EvidenceEnvelope(
259
+ items=evidence_items,
260
+ status="partial",
261
+ ),
262
+ )
263
+
264
+ confident_evidence = [ei for ei in evidence_items if ei.score >= params.min_confidence]
265
+ citations = [
266
+ {
267
+ "source_type": r["source_type"],
268
+ "doc_id": r["doc_id"],
269
+ "title": r["title"],
270
+ "score": r["score"],
271
+ "excerpt": r["excerpt"][:200],
272
+ }
273
+ for r in confident
274
+ ]
275
+
276
+ return LookupResult(
277
+ success=True,
278
+ query=raw_query,
279
+ results=confident,
280
+ context_text=raw_context_text,
281
+ citations=citations,
282
+ provider=provider,
283
+ evidence=EvidenceEnvelope(
284
+ items=confident_evidence,
285
+ status="ok",
286
+ ),
287
+ )
src/inference/graph/capabilities/registry.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """tool metadata registry โ€” MVP capability์˜ ๋‹จ์ผ ์†Œ์Šค.
2
+
3
+ Issue #416: tool metadata registry ๋ฐ LangGraph executor binding ์ •๋ฆฌ.
4
+
5
+ ์ด ๋ชจ๋“ˆ์€ ๋‹ค์Œ์„ ๋ณด์žฅํ•œ๋‹ค:
6
+ - planner๊ฐ€ ์ฝ๋Š” metadata์™€ executor binding์ด ๊ฐ™์€ ์†Œ์Šค์—์„œ ๋‚˜์˜จ๋‹ค
7
+ - approval prompt์™€ session log๊ฐ€ ๋™์ผํ•œ capability identifier๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค
8
+ - ๋น„MVP capability๊ฐ€ registry ์ˆ˜์ค€์—์„œ ์ฐจ๋‹จ๋œ๋‹ค
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Callable, Dict, List
14
+
15
+ from src.inference.tool_router import ToolType
16
+
17
+ from .api_lookup import ApiLookupCapability
18
+ from .append_evidence import AppendEvidenceCapability
19
+ from .base import CapabilityBase, CapabilityMetadata
20
+ from .demographics_lookup import DemographicsLookupCapability
21
+ from .draft_civil_response import DraftCivilResponseCapability
22
+ from .issue_detector import IssueDetectorCapability
23
+ from .keyword_analyzer import KeywordAnalyzerCapability
24
+ from .rag_search import RagSearchCapability
25
+ from .stats_lookup import StatsLookupCapability
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # MVP capability stable identifiers (session log, approval prompt์—์„œ ์‚ฌ์šฉ)
29
+ # ToolType enum์—์„œ ํŒŒ์ƒํ•˜์—ฌ ๋‹จ์ผ ์†Œ์Šค๋ฅผ ์œ ์ง€ํ•œ๋‹ค.
30
+ # ---------------------------------------------------------------------------
31
+
32
+ MVP_CAPABILITY_IDS: frozenset[str] = frozenset(t.value for t in ToolType)
33
+
34
+
35
+ def get_mvp_capability_ids() -> frozenset[str]:
36
+ """MVP capability stable identifier ์ง‘ํ•ฉ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
37
+
38
+ plan_validator, planner_adapter ๋“ฑ์—์„œ ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ๋กœ ์‚ฌ์šฉํ•œ๋‹ค.
39
+ """
40
+ return MVP_CAPABILITY_IDS
41
+
42
+
43
+ def build_mvp_registry(
44
+ *,
45
+ rag_search_fn: Callable[..., Any],
46
+ api_lookup_action: Any = None,
47
+ draft_civil_response_fn: Callable[..., Any],
48
+ append_evidence_fn: Callable[..., Any],
49
+ rag_low_confidence_threshold: float = 0.3,
50
+ ) -> Dict[str, CapabilityBase]:
51
+ """MVP 4๊ฐœ capability๋ฅผ CapabilityBase ์ธ์Šคํ„ด์Šค๋กœ ๊ตฌ์„ฑํ•œ registry๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
52
+
53
+ ๋ชจ๋“  capability๊ฐ€ CapabilityBase๋ฅผ ๊ตฌํ˜„ํ•˜๋ฏ€๋กœ,
54
+ RegistryExecutorAdapter.get_tool_metadata()๊ฐ€ ์ผ๊ด€๋œ metadata๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
55
+
56
+ Parameters
57
+ ----------
58
+ rag_search_fn : Callable
59
+ ``async (query, context, session) -> dict`` ํ˜•ํƒœ์˜ RAG ๊ฒ€์ƒ‰ ํ•จ์ˆ˜.
60
+ api_lookup_action : Any, optional
61
+ ``MinwonAnalysisAction`` ์ธ์Šคํ„ด์Šค. None์ด๋ฉด ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜.
62
+ draft_civil_response_fn : Callable
63
+ ``async (query, context, session) -> dict`` ํ˜•ํƒœ์˜ ๋ฏผ์› ๋‹ต๋ณ€ ์ƒ์„ฑ ํ•จ์ˆ˜.
64
+ append_evidence_fn : Callable
65
+ ``async (query, context, session) -> dict`` ํ˜•ํƒœ์˜ ๊ทผ๊ฑฐ ๋ณด๊ฐ• ํ•จ์ˆ˜.
66
+ rag_low_confidence_threshold : float
67
+ RAG ๊ฒ€์ƒ‰ ์ €์‹ ๋ขฐ๋„ ์ž„๊ณ„๊ฐ’. ๊ธฐ๋ณธ๊ฐ’ 0.3.
68
+
69
+ Returns
70
+ -------
71
+ Dict[str, CapabilityBase]
72
+ capability name -> CapabilityBase ์ธ์Šคํ„ด์Šค ๋งคํ•‘.
73
+ """
74
+ return {
75
+ "rag_search": RagSearchCapability(
76
+ execute_fn=rag_search_fn,
77
+ low_confidence_threshold=rag_low_confidence_threshold,
78
+ ),
79
+ "api_lookup": ApiLookupCapability(action=api_lookup_action),
80
+ "draft_civil_response": DraftCivilResponseCapability(
81
+ execute_fn=draft_civil_response_fn,
82
+ ),
83
+ "append_evidence": AppendEvidenceCapability(execute_fn=append_evidence_fn),
84
+ "issue_detector": IssueDetectorCapability(action=api_lookup_action),
85
+ "stats_lookup": StatsLookupCapability(action=api_lookup_action),
86
+ "keyword_analyzer": KeywordAnalyzerCapability(action=api_lookup_action),
87
+ "demographics_lookup": DemographicsLookupCapability(action=api_lookup_action),
88
+ }
89
+
90
+
91
+ def get_all_metadata(
92
+ registry: Dict[str, CapabilityBase],
93
+ ) -> List[Dict[str, Any]]:
94
+ """registry์— ๋“ฑ๋ก๋œ ๋ชจ๋“  capability์˜ metadata๋ฅผ dict ๋ชฉ๋ก์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
95
+
96
+ planner๊ฐ€ tool ๋ชฉ๋ก์„ ๊ตฌ์„ฑํ•  ๋•Œ ์‚ฌ์šฉํ•œ๋‹ค.
97
+
98
+ Parameters
99
+ ----------
100
+ registry : Dict[str, CapabilityBase]
101
+ build_mvp_registry()๊ฐ€ ๋ฐ˜ํ™˜ํ•œ registry.
102
+
103
+ Returns
104
+ -------
105
+ List[Dict[str, Any]]
106
+ ๊ฐ capability์˜ metadata dict ๋ชฉ๋ก.
107
+ """
108
+ result: List[Dict[str, Any]] = []
109
+ for name, cap in registry.items():
110
+ meta = cap.metadata
111
+ result.append(
112
+ {
113
+ "name": meta.name,
114
+ "description": meta.description,
115
+ "approval_summary": meta.approval_summary,
116
+ "provider": meta.provider,
117
+ "timeout_sec": meta.timeout_sec,
118
+ }
119
+ )
120
+ return result
121
+
122
+
123
+ def is_mvp_capability(name: str) -> bool:
124
+ """์ฃผ์–ด์ง„ ์ด๋ฆ„์ด MVP capability์ธ์ง€ ํ™•์ธํ•œ๋‹ค."""
125
+ return name in MVP_CAPABILITY_IDS
src/inference/graph/capabilities/stats_lookup.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """stats_lookup capability โ€” ๋งž์ถคํ˜•ํ†ต๊ณ„+ํŠธ๋ Œ๋“œ+๊ฑด์ˆ˜+๊ธฐ๊ด€์ˆœ์œ„+์ง€์—ญ์ˆœ์œ„ ์กฐํ•ฉ.
2
+
3
+ Issue #487: ๋ฏผ์› ํ†ต๊ณ„ ์กฐํšŒ ๋„๊ตฌ.
4
+ 5๊ฐœ API๋ฅผ ์กฐํ•ฉํ•˜์—ฌ ๋ฏผ์› ํ†ต๊ณ„ ํ˜„ํ™ฉ์„ ์ œ๊ณตํ•œ๋‹ค.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from loguru import logger
13
+
14
+ from .base import (
15
+ CapabilityBase,
16
+ CapabilityMetadata,
17
+ EvidenceEnvelope,
18
+ EvidenceItem,
19
+ LookupResult,
20
+ )
21
+ from .defaults import get_timeout
22
+
23
+
24
+ class StatsLookupCapability(CapabilityBase):
25
+ """๋ฏผ์› ํ†ต๊ณ„ ์กฐํšŒ capability.
26
+
27
+ ํ‚ค์›Œ๋“œ๊ฐ€ ์žˆ์œผ๋ฉด ๊ฑด์ˆ˜+ํŠธ๋ Œ๋“œ, ์—†์œผ๋ฉด ํ†ต๊ณ„+๊ธฐ๊ด€์ˆœ์œ„+์ง€์—ญ์ˆœ์œ„๋ฅผ ์กฐํ•ฉํ•œ๋‹ค.
28
+
29
+ Parameters
30
+ ----------
31
+ action : Optional[MinwonAnalysisAction]
32
+ API ํ˜ธ์ถœ์šฉ Action ์ธ์Šคํ„ด์Šค. None์ด๋ฉด ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜.
33
+ """
34
+
35
+ def __init__(self, action: Optional[Any] = None) -> None:
36
+ self._action = action
37
+
38
+ @property
39
+ def metadata(self) -> CapabilityMetadata:
40
+ return CapabilityMetadata(
41
+ name="stats_lookup",
42
+ description=(
43
+ "๋ฏผ์› ํ†ต๊ณ„, ํŠธ๋ Œ๋“œ, ๊ฑด์ˆ˜, ๊ธฐ๊ด€/์ง€์—ญ ์ˆœ์œ„๋ฅผ ์กฐํ•ฉํ•˜์—ฌ " "๋ฏผ์› ํ˜„ํ™ฉ ํ†ต๊ณ„๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค."
44
+ ),
45
+ approval_summary="๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ์—์„œ ๋ฏผ์› ํ†ต๊ณ„ ํ˜„ํ™ฉ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.",
46
+ provider="data.go.kr",
47
+ timeout_sec=get_timeout("stats_lookup"),
48
+ )
49
+
50
+ async def execute(
51
+ self,
52
+ query: str,
53
+ context: Dict[str, Any],
54
+ session: Any,
55
+ ) -> LookupResult:
56
+ """์กฐ๊ฑด์— ๋”ฐ๋ผ API๋ฅผ ์กฐํ•ฉ ํ˜ธ์ถœํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค."""
57
+ provider = self.metadata.provider
58
+
59
+ if not query or not query.strip():
60
+ return LookupResult(
61
+ success=False,
62
+ query=query,
63
+ provider=provider,
64
+ error="query๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค",
65
+ empty_reason="validation_error",
66
+ evidence=EvidenceEnvelope(status="error", errors=["query๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"]),
67
+ )
68
+
69
+ if self._action is None:
70
+ logger.debug("[stats_lookup] action์ด None - ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜")
71
+ return LookupResult(
72
+ success=True,
73
+ query=query,
74
+ provider=provider,
75
+ empty_reason="no_match",
76
+ evidence=EvidenceEnvelope(status="empty"),
77
+ )
78
+
79
+ date_from = context.get("date_from", "")
80
+ date_to = context.get("date_to", "")
81
+ searchword = context.get("searchword", "")
82
+ period = context.get("period", "DAILY")
83
+ top_n = int(context.get("top_n", 5))
84
+
85
+ try:
86
+ results_map = await asyncio.wait_for(
87
+ self._fetch_all(date_from, date_to, searchword, period, top_n),
88
+ timeout=self.metadata.timeout_sec,
89
+ )
90
+ except asyncio.TimeoutError:
91
+ msg = f"API ํ˜ธ์ถœ ํƒ€์ž„์•„์›ƒ ({self.metadata.timeout_sec}์ดˆ ์ดˆ๊ณผ)"
92
+ logger.warning(f"[stats_lookup] {msg}")
93
+ return LookupResult(
94
+ success=False,
95
+ query=query,
96
+ provider=provider,
97
+ error=msg,
98
+ empty_reason="provider_error",
99
+ evidence=EvidenceEnvelope(status="error", errors=[msg]),
100
+ )
101
+ except Exception as exc:
102
+ logger.error(f"[stats_lookup] API ํ˜ธ์ถœ ์˜ค๋ฅ˜: {exc}", exc_info=True)
103
+ return LookupResult(
104
+ success=False,
105
+ query=query,
106
+ provider=provider,
107
+ error=str(exc),
108
+ empty_reason="provider_error",
109
+ evidence=EvidenceEnvelope(status="error", errors=[str(exc)]),
110
+ )
111
+
112
+ all_results: List[Dict[str, Any]] = []
113
+ evidence_items: List[EvidenceItem] = []
114
+ errors: List[str] = []
115
+
116
+ for api_name, items in results_map.items():
117
+ if items is None:
118
+ errors.append(f"{api_name} API ์‹คํŒจ")
119
+ continue
120
+ for item in items:
121
+ item["_source_api"] = api_name
122
+ all_results.append(item)
123
+ evidence_items.append(
124
+ EvidenceItem(
125
+ source_type="api",
126
+ title=item.get("label", item.get("term", "")),
127
+ excerpt=self._format_item(api_name, item),
128
+ provider_meta={"provider": provider, "api": api_name},
129
+ )
130
+ )
131
+
132
+ if not all_results:
133
+ status = "error" if len(errors) == len(results_map) else "empty"
134
+ return LookupResult(
135
+ success=len(errors) < len(results_map),
136
+ query=query,
137
+ provider=provider,
138
+ empty_reason="no_match" if len(errors) < len(results_map) else "provider_error",
139
+ error="; ".join(errors) if errors else None,
140
+ evidence=EvidenceEnvelope(items=[], status=status, errors=errors),
141
+ )
142
+
143
+ context_text = self._build_context_text(results_map, date_from, date_to)
144
+ status = "ok" if not errors else "partial"
145
+
146
+ return LookupResult(
147
+ success=True,
148
+ query=query,
149
+ results=all_results,
150
+ context_text=context_text,
151
+ provider=provider,
152
+ evidence=EvidenceEnvelope(
153
+ items=evidence_items,
154
+ summary_text=context_text,
155
+ status=status,
156
+ errors=errors,
157
+ ),
158
+ )
159
+
160
+ async def _fetch_all(
161
+ self,
162
+ date_from: str,
163
+ date_to: str,
164
+ searchword: str,
165
+ period: str,
166
+ top_n: int,
167
+ ) -> Dict[str, Optional[List]]:
168
+ """์กฐ๊ฑด์— ๋”ฐ๋ผ ์ ์ ˆํ•œ API๋ฅผ ๋ณ‘๋ ฌ ํ˜ธ์ถœํ•œ๋‹ค."""
169
+ tasks: Dict[str, Any] = {}
170
+
171
+ if searchword:
172
+ # ํ‚ค์›Œ๋“œ ๊ธฐ๋ฐ˜: ๊ฑด์ˆ˜ + ํŠธ๋ Œ๋“œ
173
+ tasks["doc_count"] = self._safe_call(
174
+ self._action.get_doc_count,
175
+ date_from=date_from,
176
+ date_to=date_to,
177
+ searchword=searchword,
178
+ )
179
+ if date_from and date_to:
180
+ tasks["trend"] = self._safe_call(
181
+ self._action.get_trend,
182
+ date_from=date_from + "00",
183
+ date_to=date_to + "23",
184
+ period=period,
185
+ )
186
+ else:
187
+ # ์ผ๋ฐ˜ ํ†ต๊ณ„: ํ†ต๊ณ„ + ๊ธฐ๊ด€์ˆœ์œ„ + ์ง€์—ญ์ˆœ์œ„
188
+ if date_from and date_to:
189
+ tasks["statistics"] = self._safe_call(
190
+ self._action.get_statistics,
191
+ date_from=date_from,
192
+ date_to=date_to,
193
+ period=period,
194
+ )
195
+ tasks["org_ranking"] = self._safe_call(
196
+ self._action.get_org_ranking,
197
+ date_from=date_from,
198
+ date_to=date_to,
199
+ top_n=top_n,
200
+ )
201
+ tasks["region_ranking"] = self._safe_call(
202
+ self._action.get_region_ranking,
203
+ date_from=date_from,
204
+ date_to=date_to,
205
+ top_n=top_n,
206
+ )
207
+
208
+ if not tasks:
209
+ return {}
210
+
211
+ keys = list(tasks.keys())
212
+ values = await asyncio.gather(*tasks.values())
213
+ return dict(zip(keys, values))
214
+
215
+ @staticmethod
216
+ async def _safe_call(fn, **kwargs) -> Optional[List[Dict[str, Any]]]:
217
+ """๊ฐœ๋ณ„ API ํ˜ธ์ถœ์„ ์•ˆ์ „ํ•˜๊ฒŒ ๋ž˜ํ•‘ํ•œ๋‹ค."""
218
+ try:
219
+ return await fn(**kwargs)
220
+ except Exception as exc:
221
+ logger.warning(f"[stats_lookup] ๊ฐœ๋ณ„ API ์‹คํŒจ: {exc}")
222
+ return None
223
+
224
+ @staticmethod
225
+ def _format_item(api_name: str, item: Dict[str, Any]) -> str:
226
+ """๊ฐœ๋ณ„ ์•„์ดํ…œ์˜ ์š”์•ฝ ๋ฌธ์ž์—ด์„ ์ƒ์„ฑํ•œ๋‹ค."""
227
+ label = item.get("label", "")
228
+ hits = item.get("hits", "")
229
+ if api_name == "doc_count":
230
+ pttn = item.get("pttn", 0)
231
+ dfpt = item.get("dfpt", 0)
232
+ saeol = item.get("saeol", 0)
233
+ return f"๊ตญ๋ฏผ์‹ ๋ฌธ๊ณ ={pttn}, ๋ฏผ์›24={dfpt}, ์ƒˆ์˜ฌ={saeol}"
234
+ if api_name == "trend":
235
+ ratio = item.get("prebRatio", "")
236
+ return f"{label}: {hits}๊ฑด, ์ „์ผ๋Œ€๋น„ {ratio}%"
237
+ return f"{label}: {hits}๊ฑด"
238
+
239
+ @staticmethod
240
+ def _build_context_text(
241
+ results_map: Dict[str, Optional[List]],
242
+ date_from: str,
243
+ date_to: str,
244
+ ) -> str:
245
+ """์กฐํ•ฉ ๊ฒฐ๊ณผ์—์„œ ์ž์—ฐ์–ด ์š”์•ฝ์„ ์ƒ์„ฑํ•œ๋‹ค."""
246
+ parts: List[str] = []
247
+ period_str = ""
248
+ if date_from and date_to:
249
+ period_str = (
250
+ f"{date_from[:4]}/{date_from[4:6]}/{date_from[6:8]}~{date_to[4:6]}/{date_to[6:8]}"
251
+ )
252
+
253
+ doc_count = results_map.get("doc_count")
254
+ if doc_count and len(doc_count) > 0:
255
+ item = doc_count[0]
256
+ try:
257
+ pttn = int(item.get("pttn") or 0)
258
+ dfpt = int(item.get("dfpt") or 0)
259
+ saeol = int(item.get("saeol") or 0)
260
+ except (ValueError, TypeError):
261
+ pttn, dfpt, saeol = 0, 0, 0
262
+ total = pttn + dfpt + saeol
263
+ parts.append(f"{period_str} ์ด {total:,}๊ฑด" if period_str else f"์ด {total:,}๊ฑด")
264
+
265
+ stats = results_map.get("statistics")
266
+ if stats:
267
+ total = sum(int(s.get("hits", 0)) for s in stats)
268
+ parts.append(f"{period_str} ์ด {total:,}๊ฑด" if period_str else f"์ด {total:,}๊ฑด")
269
+
270
+ trend = results_map.get("trend")
271
+ if trend and len(trend) > 0:
272
+ last = trend[-1]
273
+ ratio = last.get("prebRatio", "")
274
+ if ratio:
275
+ parts.append(f"์ „์ผ๋Œ€๋น„ {'+' if not ratio.startswith('-') else ''}{ratio}%")
276
+
277
+ region = results_map.get("region_ranking")
278
+ if region and len(region) > 0:
279
+ top = region[0]
280
+ parts.append(f"{top.get('label', '')} ์ตœ๋‹ค({int(top.get('hits', 0)):,}๊ฑด)")
281
+
282
+ org = results_map.get("org_ranking")
283
+ if org and len(org) > 0:
284
+ top = org[0]
285
+ parts.append(f"๊ธฐ๊ด€ ์ตœ๋‹ค: {top.get('label', '')}({int(top.get('hits', 0)):,}๊ฑด)")
286
+
287
+ return ", ".join(parts) if parts else ""
src/inference/graph/executor_adapter.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Executor adapter: tool registry์—์„œ tool์„ ์กฐํšŒํ•˜๊ณ  ์‹คํ–‰.
2
+
3
+ Issue #415: LangGraph runtime ๊ธฐ๋ฐ˜ ๋ฐ planner/executor adapter ๊ตฌ์„ฑ.
4
+ Issue #416: tool metadata registry ๋ฐ LangGraph executor binding ์ •๋ฆฌ.
5
+
6
+ ๋‘ ๊ฐ€์ง€ ๊ตฌํ˜„์ฒด๋ฅผ ์ œ๊ณตํ•œ๋‹ค:
7
+ - `ExecutorAdapter` (ABC): ์ถ”์ƒ ์ธํ„ฐํŽ˜์ด์Šค
8
+ - `RegistryExecutorAdapter`: CapabilityBase ๊ธฐ๋ฐ˜ registry๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ตฌํ˜„์ฒด
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import time
15
+ from abc import ABC, abstractmethod
16
+ from typing import Any, Callable, Dict, List, Optional
17
+
18
+ from loguru import logger
19
+
20
+
21
+ class ExecutorAdapter(ABC):
22
+ """Tool executor ์ถ”์ƒ ์ธํ„ฐํŽ˜์ด์Šค.
23
+
24
+ LangGraph graph์˜ `tool_execute` ๋…ธ๋“œ์—์„œ ํ˜ธ์ถœ๋œ๋‹ค.
25
+ """
26
+
27
+ @abstractmethod
28
+ async def execute(
29
+ self,
30
+ tool_name: str,
31
+ query: str,
32
+ context: Dict[str, Any],
33
+ ) -> Dict[str, Any]:
34
+ """tool์„ ์‹คํ–‰ํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
35
+
36
+ Parameters
37
+ ----------
38
+ tool_name : str
39
+ ์‹คํ–‰ํ•  tool ์ด๋ฆ„.
40
+ query : str
41
+ ์‚ฌ์šฉ์ž ์š”์ฒญ ํ…์ŠคํŠธ.
42
+ context : Dict[str, Any]
43
+ ๋ˆ„์  ์ปจํ…์ŠคํŠธ (์ด์ „ tool ๊ฒฐ๊ณผ ํฌํ•จ).
44
+
45
+ Returns
46
+ -------
47
+ Dict[str, Any]
48
+ tool ์‹คํ–‰ ๊ฒฐ๊ณผ. ์ตœ์†Œ {"success": bool, ...} ํ˜•ํƒœ.
49
+ ์‹คํŒจ ์‹œ {"success": False, "error": str}.
50
+ """
51
+ ...
52
+
53
+ @abstractmethod
54
+ def list_tools(self) -> list[str]:
55
+ """๋“ฑ๋ก๋œ tool ์ด๋ฆ„ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค."""
56
+ ...
57
+
58
+
59
+ class RegistryExecutorAdapter(ExecutorAdapter):
60
+ """๊ธฐ์กด tool_registry๋ฅผ ์žฌ์‚ฌ์šฉํ•˜๋Š” executor.
61
+
62
+ `tool_registry`๋Š” `Dict[str, Callable]` ํ˜•ํƒœ๋กœ ์ฃผ์ž…๋ฐ›๋Š”๋‹ค.
63
+ ๊ฐ callable์€ `async (query, context, session) -> dict` ์‹œ๊ทธ๋‹ˆ์ฒ˜์—ฌ์•ผ ํ•œ๋‹ค.
64
+ ๊ธฐ์กด `AgentLoop._execute_tool()` ๋กœ์ง์„ ๊ณ„์Šนํ•œ๋‹ค.
65
+
66
+ Parameters
67
+ ----------
68
+ tool_registry : Dict[str, Callable]
69
+ tool ์ด๋ฆ„ -> async callable ๋งคํ•‘.
70
+ session_store : SessionStore
71
+ GovOn ์„ธ์…˜ ์ €์žฅ์†Œ. executor๊ฐ€ tool ํ˜ธ์ถœ ์‹œ ์„ธ์…˜์„ ์ฃผ์ž…ํ•œ๋‹ค.
72
+ default_timeout : float
73
+ tool ์‹คํ–‰ ์ œํ•œ ์‹œ๊ฐ„ (์ดˆ). ๊ธฐ๋ณธ๊ฐ’ 30.0.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ tool_registry: Dict[str, Callable],
79
+ session_store: Any, # SessionStore (์ˆœํ™˜ import ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด Any ์‚ฌ์šฉ)
80
+ default_timeout: float = 30.0,
81
+ ) -> None:
82
+ self._tools = tool_registry
83
+ self._session_store = session_store
84
+ self._default_timeout = default_timeout
85
+
86
+ async def execute(
87
+ self,
88
+ tool_name: str,
89
+ query: str,
90
+ context: Dict[str, Any],
91
+ ) -> Dict[str, Any]:
92
+ """tool์„ ์กฐํšŒํ•˜๊ณ  ํƒ€์ž„์•„์›ƒ ํฌํ•จํ•˜์—ฌ ์‹คํ–‰ํ•œ๋‹ค.
93
+
94
+ registry์— ๋“ฑ๋ก๋˜์ง€ ์•Š์€ tool์€ ๋น„MVP capability๋กœ ์ฐจ๋‹จํ•œ๋‹ค.
95
+ """
96
+ from src.inference.graph.capabilities.registry import is_mvp_capability
97
+
98
+ # ๋น„MVP capability ์ฐจ๋‹จ
99
+ if not is_mvp_capability(tool_name):
100
+ logger.warning(f"[RegistryExecutorAdapter] ๋น„MVP capability ์ฐจ๋‹จ: {tool_name}")
101
+ return {"success": False, "error": f"๋น„MVP capability: {tool_name}"}
102
+
103
+ tool_fn = self._tools.get(tool_name)
104
+ if tool_fn is None:
105
+ return {"success": False, "error": f"๋“ฑ๋ก๋˜์ง€ ์•Š์€ tool: {tool_name}"}
106
+
107
+ session = self._session_store.get_or_create(context.get("session_id"))
108
+ start = time.monotonic()
109
+ try:
110
+ result = await asyncio.wait_for(
111
+ tool_fn(query=query, context=context, session=session),
112
+ timeout=self._default_timeout,
113
+ )
114
+ latency = (time.monotonic() - start) * 1000
115
+ if isinstance(result, dict):
116
+ if "latency_ms" not in result:
117
+ result["latency_ms"] = latency
118
+ result.setdefault("success", True)
119
+ return result
120
+ return {"success": True, "result": result, "latency_ms": latency}
121
+ except asyncio.TimeoutError:
122
+ latency = (time.monotonic() - start) * 1000
123
+ return {
124
+ "success": False,
125
+ "error": f"tool {tool_name} ํƒ€์ž„์•„์›ƒ ({self._default_timeout}์ดˆ)",
126
+ "latency_ms": latency,
127
+ }
128
+ except Exception as exc:
129
+ latency = (time.monotonic() - start) * 1000
130
+ logger.error(f"[RegistryExecutorAdapter] tool {tool_name} ์˜ค๋ฅ˜: {exc}", exc_info=True)
131
+ return {"success": False, "error": str(exc), "latency_ms": latency}
132
+
133
+ def list_tools(self) -> list[str]:
134
+ """๋“ฑ๋ก๋œ tool ์ด๋ฆ„ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค."""
135
+ return list(self._tools.keys())
136
+
137
+ def get_tool_metadata(self, tool_name: str) -> Optional[dict]:
138
+ """capability์˜ planner metadata๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
139
+
140
+ CapabilityBase ์ธ์Šคํ„ด์Šค๊ฐ€ ๋“ฑ๋ก๋œ ๊ฒฝ์šฐ metadata ํ”„๋กœํผํ‹ฐ์—์„œ ์ •๋ณด๋ฅผ ์ถ”์ถœํ•˜๊ณ ,
141
+ ์ผ๋ฐ˜ callable์ธ ๊ฒฝ์šฐ ์ด๋ฆ„๋งŒ ํฌํ•จ๋œ ๊ธฐ๋ณธ dict๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
142
+ ๋“ฑ๋ก๋˜์ง€ ์•Š์€ tool์ด๋ฉด None์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
143
+
144
+ Parameters
145
+ ----------
146
+ tool_name : str
147
+ ์กฐํšŒํ•  tool ์ด๋ฆ„.
148
+
149
+ Returns
150
+ -------
151
+ Optional[dict]
152
+ tool metadata dict ๋˜๋Š” None.
153
+ """
154
+ tool = self._tools.get(tool_name)
155
+ if tool is None:
156
+ return None
157
+ # CapabilityBase ์ธํ„ฐํŽ˜์ด์Šค ์ง€์›
158
+ if hasattr(tool, "metadata"):
159
+ meta = tool.metadata
160
+ return {
161
+ "name": meta.name,
162
+ "description": meta.description,
163
+ "approval_summary": meta.approval_summary,
164
+ "provider": getattr(meta, "provider", ""),
165
+ }
166
+ return {
167
+ "name": tool_name,
168
+ "description": "",
169
+ "approval_summary": "",
170
+ "provider": "",
171
+ }
172
+
173
+ def get_tool_descriptions_for_planner(self) -> List[dict]:
174
+ """planner๊ฐ€ ์ฝ์„ tool ๋ชฉ๋ก์„ ๋‹จ์ผ ๋ฉ”์„œ๋“œ๋กœ ๋…ธ์ถœํ•œ๋‹ค.
175
+
176
+ ๋“ฑ๋ก๋œ ๋ชจ๋“  tool์˜ metadata๋ฅผ dict ๋ชฉ๋ก์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
177
+ CapabilityBase ์ธ์Šคํ„ด์Šค๋Š” ํ’๋ถ€ํ•œ metadata๋ฅผ, ์ผ๋ฐ˜ callable์€
178
+ ์ด๋ฆ„๋งŒ ํฌํ•จ๋œ ๊ธฐ๋ณธ dict๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
179
+
180
+ Returns
181
+ -------
182
+ List[dict]
183
+ ๊ฐ tool์˜ metadata dict ๋ชฉ๋ก.
184
+ """
185
+ descriptions: List[dict] = []
186
+ for name in self._tools:
187
+ meta = self.get_tool_metadata(name)
188
+ if meta is not None:
189
+ descriptions.append(meta)
190
+ return descriptions
src/inference/graph/nodes.py ADDED
@@ -0,0 +1,752 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GovOn LangGraph ๋…ธ๋“œ ํ•จ์ˆ˜ ๋ชจ์Œ.
2
+
3
+ Issue #415: LangGraph runtime ๊ธฐ๋ฐ˜ ๋ฐ planner/executor adapter ๊ตฌ์„ฑ.
4
+
5
+ 6๊ฐœ ๋…ธ๋“œ๋ฅผ ์ •์˜ํ•œ๋‹ค:
6
+ session_load -> planner -> approval_wait -> tool_execute -> synthesis -> persist
7
+
8
+ ๊ฐ ๋…ธ๋“œ๋Š” `GovOnGraphState`๋ฅผ ์ž…๋ ฅ์œผ๋กœ ๋ฐ›๊ณ  ์ƒํƒœ ์—…๋ฐ์ดํŠธ dict๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
9
+ I/O๊ฐ€ ํ•„์š”ํ•œ ๋…ธ๋“œ๋Š” async ํ•จ์ˆ˜์ด๋ฉฐ, `approval_wait` ๋…ธ๋“œ๋Š” `interrupt()`๋ฅผ
10
+ ์‚ฌ์šฉํ•˜๋Š” human-in-the-loop ์Šน์ธ ๊ฒŒ์ดํŠธ์ด๋ฏ€๋กœ sync ํ•จ์ˆ˜๋กœ ์œ ์ง€ํ•œ๋‹ค.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import time
17
+ from typing import TYPE_CHECKING, Any, Dict, List
18
+
19
+ from langchain_core.messages import AIMessage
20
+ from langgraph.types import interrupt
21
+ from loguru import logger
22
+
23
+ from src.inference.query_builder import (
24
+ build_query_variants,
25
+ build_runtime_query_context,
26
+ resolve_tool_query,
27
+ )
28
+
29
+ from .plan_validator import PlanValidationError, ToolPlanValidator
30
+ from .state import ApprovalStatus, GovOnGraphState
31
+
32
+ if TYPE_CHECKING:
33
+ from src.inference.session_context import SessionStore
34
+
35
+ from .executor_adapter import ExecutorAdapter
36
+ from .planner_adapter import PlannerAdapter
37
+
38
+
39
+ async def session_load_node(
40
+ state: GovOnGraphState,
41
+ *,
42
+ session_store: "SessionStore",
43
+ ) -> dict:
44
+ """์„ธ์…˜ ๋กœ๋“œ ๋…ธ๋“œ.
45
+
46
+ SessionStore์—์„œ ๊ธฐ์กด ์„ธ์…˜์„ ๋ถˆ๋Ÿฌ์˜ค๊ฑฐ๋‚˜ ์ƒˆ ์„ธ์…˜์„ ์ƒ์„ฑํ•œ๋‹ค.
47
+ ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ์™€ tool ์‚ฌ์šฉ ๊ธฐ๋ก์„ accumulated_context์— ์ฃผ์ž…ํ•œ๋‹ค.
48
+
49
+ Parameters
50
+ ----------
51
+ state : GovOnGraphState
52
+ ํ˜„์žฌ graph state. `session_id`์™€ `messages`๋ฅผ ์ฝ๋Š”๋‹ค.
53
+ session_store : SessionStore
54
+ graph config์—์„œ closure๋กœ ์ฃผ์ž…๋˜๋Š” ์„ธ์…˜ ์ €์žฅ์†Œ.
55
+
56
+ Returns
57
+ -------
58
+ dict
59
+ `session_id`์™€ `accumulated_context`๋ฅผ ๊ฐฑ์‹ ํ•œ๋‹ค.
60
+ """
61
+ _start = time.monotonic()
62
+
63
+ session_id: str | None = state.get("session_id")
64
+ session = session_store.get_or_create(session_id)
65
+
66
+ messages = state.get("messages", [])
67
+ query = messages[-1].content if messages else ""
68
+ accumulated_context = build_runtime_query_context(session, query)
69
+
70
+ _latency_ms = round((time.monotonic() - _start) * 1000, 2)
71
+ logger.debug(
72
+ f"[session_load] session_id={session.session_id} "
73
+ f"query_len={len(query)} latency_ms={_latency_ms}"
74
+ )
75
+
76
+ return {
77
+ "session_id": session.session_id,
78
+ "accumulated_context": accumulated_context,
79
+ "node_latencies": {"session_load": _latency_ms},
80
+ }
81
+
82
+
83
+ async def planner_node(
84
+ state: GovOnGraphState,
85
+ *,
86
+ planner_adapter: "PlannerAdapter",
87
+ ) -> dict:
88
+ """Planner ๋…ธ๋“œ.
89
+
90
+ PlannerAdapter๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ๊ตฌ์กฐํ™”๋œ ์‹คํ–‰ ๊ณ„ํš์„ ์ƒ์„ฑํ•œ๋‹ค.
91
+ LLMPlannerAdapter๊ฐ€ ๊ธฐ๋ณธ์œผ๋กœ ์‚ฌ์šฉ๋œ๋‹ค. SKIP_MODEL_LOAD=true ํ™˜๊ฒฝ์—์„œ๋Š”
92
+ RegexPlannerAdapter๊ฐ€ CI fallback์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.
93
+
94
+ Parameters
95
+ ----------
96
+ state : GovOnGraphState
97
+ ํ˜„์žฌ graph state. `messages`์™€ `accumulated_context`๋ฅผ ์ฝ๋Š”๋‹ค.
98
+ planner_adapter : PlannerAdapter
99
+ graph config์—์„œ closure๋กœ ์ฃผ์ž…๋˜๋Š” planner ์–ด๋Œ‘ํ„ฐ.
100
+
101
+ Returns
102
+ -------
103
+ dict
104
+ `task_type`, `goal`, `reason`, `planned_tools`๋ฅผ ๊ฐฑ์‹ ํ•œ๋‹ค.
105
+ """
106
+ _start = time.monotonic()
107
+
108
+ messages = state.get("messages", [])
109
+ context = state.get("accumulated_context", {})
110
+
111
+ plan = await planner_adapter.plan(messages=messages, context=context)
112
+
113
+ validator = ToolPlanValidator()
114
+ try:
115
+ validator.validate(plan)
116
+ except PlanValidationError as e:
117
+ _latency_ms = round((time.monotonic() - _start) * 1000, 2)
118
+ logger.warning(f"[planner] validation ์‹คํŒจ: {e} latency_ms={_latency_ms}")
119
+ return {
120
+ **validator.make_fallback_plan(e),
121
+ "task_type": "",
122
+ "node_latencies": {"planner": _latency_ms},
123
+ }
124
+
125
+ logger.info(
126
+ f"[planner] task_type={plan.task_type.value} "
127
+ f"tools={plan.tools} reason={plan.reason} adapter_mode={plan.adapter_mode}"
128
+ )
129
+
130
+ query_variants = build_query_variants(
131
+ context.get("query", ""),
132
+ tool_names=plan.tools,
133
+ context=context,
134
+ )
135
+
136
+ _latency_ms = round((time.monotonic() - _start) * 1000, 2)
137
+ logger.debug(f"[planner] latency_ms={_latency_ms}")
138
+
139
+ return {
140
+ "task_type": plan.task_type.value,
141
+ "goal": plan.goal,
142
+ "reason": plan.reason,
143
+ "planned_tools": plan.tools,
144
+ "tool_summaries": plan.tool_summaries,
145
+ "adapter_mode": plan.adapter_mode,
146
+ "accumulated_context": {
147
+ **context,
148
+ "query_variants": query_variants,
149
+ },
150
+ "node_latencies": {"planner": _latency_ms},
151
+ }
152
+
153
+
154
+ def approval_wait_node(state: GovOnGraphState) -> dict:
155
+ """Human-in-the-loop ์Šน์ธ ๊ฒŒ์ดํŠธ.
156
+
157
+ `interrupt()`๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ graph ์‹คํ–‰์„ ์ผ์‹œ ์ •์ง€ํ•œ๋‹ค.
158
+ FastAPI `/v2/agent/approve` ์—”๋“œํฌ์ธํŠธ๊ฐ€ ์‚ฌ์šฉ์ž ์‘๋‹ต์„ ๋ฐ›์•„ graph๋ฅผ resumeํ•œ๋‹ค.
159
+
160
+ `interrupt()`๋Š” LangGraph๊ฐ€ ์ง€์›ํ•˜๋Š” human-in-the-loop ๋ฉ”์ปค๋‹ˆ์ฆ˜์ด๋‹ค.
161
+ graph ์‹คํ–‰์ด ๋ฉˆ์ถ”๊ณ , `Command(resume=...)` ํ˜ธ์ถœ๋กœ ์žฌ๊ฐœ๋  ๋•Œ
162
+ `interrupt()`์˜ ๋ฐ˜ํ™˜๊ฐ’์œผ๋กœ ์‚ฌ์šฉ์ž ์ž…๋ ฅ์ด ์ „๋‹ฌ๋œ๋‹ค.
163
+
164
+ Parameters
165
+ ----------
166
+ state : GovOnGraphState
167
+ ํ˜„์žฌ graph state. `goal`, `reason`, `planned_tools`๋ฅผ ์ฝ๋Š”๋‹ค.
168
+
169
+ Returns
170
+ -------
171
+ dict
172
+ `approval_status`๋ฅผ ๊ฐฑ์‹ ํ•œ๋‹ค.
173
+ """
174
+ tool_summaries: List[str] = state.get("tool_summaries") or []
175
+ planned_tools: List[str] = state.get("planned_tools", [])
176
+
177
+ # CLI ํ‘œ์‹œ์šฉ ๋„๊ตฌ ์„ค๋ช…: tool_summaries๊ฐ€ ์žˆ์œผ๋ฉด ์‚ฌ์šฉ, ์—†์œผ๋ฉด tool ์ด๋ฆ„ ๊ทธ๋Œ€๋กœ
178
+ display_tools = tool_summaries if tool_summaries else planned_tools
179
+
180
+ approval_request = {
181
+ "type": "approval_request",
182
+ "goal": state.get("goal", ""),
183
+ "reason": state.get("reason", ""),
184
+ "planned_tools": planned_tools,
185
+ "tool_summaries": display_tools,
186
+ "prompt": (
187
+ f"๋‹ค์Œ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค:\n\n"
188
+ f" {state.get('goal', '')}\n\n"
189
+ f" ์ด์œ : {state.get('reason', '')}\n"
190
+ f" ์‚ฌ์šฉํ•  ๋„๊ตฌ:\n"
191
+ + "".join(f" - {s}\n" for s in display_tools)
192
+ + "\n์Šน์ธํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? (์Šน์ธ/๊ฑฐ์ ˆ)"
193
+ ),
194
+ }
195
+
196
+ logger.info(f"[approval_wait] interrupt ํ˜ธ์ถœ: tools={planned_tools}")
197
+
198
+ # interrupt()๋Š” graph ์‹คํ–‰์„ ๋ฉˆ์ถ”๊ณ , resume ์‹œ ๋ฐ˜ํ™˜๊ฐ’์ด ๋œ๋‹ค.
199
+ # ์˜ˆ: {"approved": True} ๋˜๋Š” {"approved": False}
200
+ user_response = interrupt(approval_request)
201
+
202
+ if isinstance(user_response, dict) and user_response.get("approved"):
203
+ logger.info("[approval_wait] ์Šน์ธ๋จ")
204
+ return {"approval_status": ApprovalStatus.APPROVED.value}
205
+
206
+ # cancel ์‹ ํ˜ธ๊ฐ€ ์žˆ์œผ๋ฉด interrupt_reason์„ "user_cancel"๋กœ ์„ค์ •
207
+ interrupt_reason = None
208
+ if isinstance(user_response, dict) and user_response.get("cancel"):
209
+ logger.info("[approval_wait] ์‚ฌ์šฉ์ž ์ทจ์†Œ (cancel)")
210
+ interrupt_reason = "user_cancel"
211
+ else:
212
+ logger.info("[approval_wait] ๊ฑฐ์ ˆ๋จ")
213
+
214
+ return {
215
+ "approval_status": ApprovalStatus.REJECTED.value,
216
+ "interrupt_reason": interrupt_reason,
217
+ }
218
+
219
+
220
+ async def tool_execute_node(
221
+ state: GovOnGraphState,
222
+ *,
223
+ executor_adapter: "ExecutorAdapter",
224
+ ) -> dict:
225
+ """Tool executor ๋…ธ๋“œ.
226
+
227
+ ExecutorAdapter๋ฅผ ํ†ตํ•ด `planned_tools`๋ฅผ ๋‘ ๋‹จ๊ณ„๋กœ ์‹คํ–‰ํ•˜๊ณ 
228
+ ๊ฒฐ๊ณผ๋ฅผ `accumulated_context`์— ๋ˆ„์ ํ•œ๋‹ค.
229
+
230
+ ์‹คํ–‰ ์ „๋žต:
231
+ - Phase 1 (๋ณ‘๋ ฌ): `rag_search`, `api_lookup` ๋“ฑ INDEPENDENT_TOOLS๋Š”
232
+ `asyncio.gather()`๋กœ ๋™์‹œ์— ์‹คํ–‰ํ•œ๋‹ค.
233
+ - Phase 2 (์ˆœ์ฐจ): ๋‚˜๋จธ์ง€ ์˜์กด ๋„๊ตฌ(draft_civil_response ๋“ฑ)๋Š” Phase 1
234
+ ๊ฒฐ๊ณผ๊ฐ€ ๋ˆ„์ ๋œ accumulated_context๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ˆœ์„œ๋Œ€๋กœ ์‹คํ–‰ํ•œ๋‹ค.
235
+
236
+ Parameters
237
+ ----------
238
+ state : GovOnGraphState
239
+ ํ˜„์žฌ graph state. `planned_tools`, `accumulated_context`๋ฅผ ์ฝ๋Š”๋‹ค.
240
+ executor_adapter : ExecutorAdapter
241
+ graph config์—์„œ closure๋กœ ์ฃผ์ž…๋˜๋Š” executor ์–ด๋Œ‘ํ„ฐ.
242
+
243
+ Returns
244
+ -------
245
+ dict
246
+ `tool_results`, `accumulated_context`, `node_latencies`๋ฅผ ๊ฐฑ์‹ ํ•œ๋‹ค.
247
+ `node_latencies`์—๋Š” `"tool_execute"` ํ‚ค๋กœ ๋…ธ๋“œ ์ „์ฒด ๋ ˆ์ดํ„ด์‹œ,
248
+ `"tool:<tool_name>"` ํ‚ค๋กœ ๊ฐœ๋ณ„ ๋„๊ตฌ ๋ ˆ์ดํ„ด์‹œ๊ฐ€ ํฌํ•จ๋œ๋‹ค.
249
+ """
250
+ _start = time.monotonic()
251
+
252
+ # approval guard: ์Šน์ธ ์—†์ด tool ์‹คํ–‰ ์ฐจ๋‹จ
253
+ approval_status = state.get("approval_status", "")
254
+ if approval_status != ApprovalStatus.APPROVED.value:
255
+ _latency_ms = round((time.monotonic() - _start) * 1000, 2)
256
+ logger.warning(
257
+ f"[tool_execute] ์Šน์ธ๋˜์ง€ ์•Š์€ ์ƒํƒœ์—์„œ ์‹คํ–‰ ์‹œ๋„ ์ฐจ๋‹จ: approval_status={approval_status!r}"
258
+ )
259
+ return {
260
+ "tool_results": {},
261
+ "accumulated_context": dict(state.get("accumulated_context", {})),
262
+ "error": f"tool ์‹คํ–‰ ์ฐจ๋‹จ: ์Šน์ธ ํ•„์š” (ํ˜„์žฌ ์ƒํƒœ: {approval_status!r})",
263
+ "node_latencies": {"tool_execute": _latency_ms},
264
+ }
265
+
266
+ planned_tools: list[str] = state.get("planned_tools", [])
267
+ accumulated: Dict[str, Any] = dict(state.get("accumulated_context", {}))
268
+
269
+ # planned_tools๊ฐ€ ๋น„์–ด์žˆ๋Š” ๊ฒฝ์šฐ (validation ์‹คํŒจ fallback ๋“ฑ)
270
+ if not planned_tools:
271
+ logger.warning("[tool_execute] planned_tools๊ฐ€ ๋น„์–ด์žˆ์–ด ์‹คํ–‰ ๊ฑด๋„ˆ๋œ€")
272
+ return {
273
+ "tool_results": {},
274
+ "accumulated_context": accumulated,
275
+ "node_latencies": {"tool_execute": round((time.monotonic() - _start) * 1000, 2)},
276
+ }
277
+
278
+ tool_results: Dict[str, Any] = {}
279
+ tool_latencies: Dict[str, float] = {}
280
+
281
+ # --- ๋…๋ฆฝ ๋„๊ตฌ์™€ ์˜์กด ๋„๊ตฌ๋ฅผ ๋ถ„๋ฆฌํ•˜์—ฌ ๋ณ‘๋ ฌ/์ˆœ์ฐจ ์‹คํ–‰ ---
282
+ # ์ƒˆ๋กœ์šด ๋…๋ฆฝ capability๋ฅผ ์ถ”๊ฐ€ํ•  ๋•Œ๋Š” ์ด ์ง‘ํ•ฉ์—๋„ ๋“ฑ๋กํ•ด์•ผ ํ•œ๋‹ค.
283
+ # ๋…๋ฆฝ ๋„๊ตฌ๋ž€ ๋‹ค๋ฅธ ๋„๊ตฌ์˜ ์‹คํ–‰ ๊ฒฐ๊ณผ(accumulated_context)์— ์˜์กดํ•˜์ง€ ์•Š์•„
284
+ # ๋ณ‘๋ ฌ ์‹คํ–‰์ด ์•ˆ์ „ํ•œ capability๋ฅผ ์˜๋ฏธํ•œ๋‹ค.
285
+ INDEPENDENT_TOOLS = {"rag_search", "api_lookup"}
286
+
287
+ independent = [t for t in planned_tools if t in INDEPENDENT_TOOLS]
288
+ dependent = [t for t in planned_tools if t not in INDEPENDENT_TOOLS]
289
+
290
+ # Phase 1: ๋…๋ฆฝ ๋„๊ตฌ ๋ณ‘๋ ฌ ์‹คํ–‰
291
+ # ์ฃผ์˜: _run_tool ํด๋กœ์ € ๋‚ด์—์„œ accumulated๋Š” ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ์ทจ๊ธ‰ํ•ด์•ผ ํ•œ๋‹ค.
292
+ # ๋ณ‘๋ ฌ ์‹คํ–‰ ์ค‘ accumulated๋ฅผ ๋ณ€๊ฒฝํ•˜๋ฉด race condition์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋‹ค.
293
+ # accumulated ๊ฐฑ์‹ ์€ gather() ์™„๋ฃŒ ํ›„ ๋ฉ”์ธ ๋ฃจํ”„์—์„œ ์ˆœ์„œ๋Œ€๋กœ ์ˆ˜ํ–‰ํ•œ๋‹ค.
294
+ if independent:
295
+
296
+ async def _run_tool(name: str) -> tuple[str, Dict[str, Any], float]:
297
+ t0 = time.monotonic()
298
+ execution_query = resolve_tool_query(name, accumulated)
299
+ logger.info(f"[tool_execute] ๋ณ‘๋ ฌ ์‹คํ–‰: {name}")
300
+ result = await executor_adapter.execute(
301
+ tool_name=name,
302
+ query=execution_query,
303
+ context=dict(accumulated),
304
+ )
305
+ latency = round((time.monotonic() - t0) * 1000, 2)
306
+ return name, result, latency
307
+
308
+ results = await asyncio.gather(
309
+ *[_run_tool(name) for name in independent],
310
+ return_exceptions=True,
311
+ )
312
+ for i, item in enumerate(results):
313
+ if isinstance(item, Exception):
314
+ failed_tool = independent[i]
315
+ logger.opt(exception=item).error(
316
+ f"[tool_execute] ๋ณ‘๋ ฌ ์‹คํ–‰ ์‹คํŒจ: tool={failed_tool}"
317
+ )
318
+ continue
319
+ name, result, latency = item
320
+ tool_results[name] = result
321
+ tool_latencies[name] = latency
322
+ if result.get("success", True):
323
+ accumulated[name] = result
324
+
325
+ # Phase 2: ์˜์กด ๋„๊ตฌ ์ˆœ์ฐจ ์‹คํ–‰ (accumulated context ํ•„์š”)
326
+ for name in dependent:
327
+ t0 = time.monotonic()
328
+ execution_query = resolve_tool_query(name, accumulated)
329
+ logger.info(f"[tool_execute] ์ˆœ์ฐจ ์‹คํ–‰: {name}")
330
+ try:
331
+ result = await executor_adapter.execute(
332
+ tool_name=name,
333
+ query=execution_query,
334
+ context=accumulated,
335
+ )
336
+ except Exception as exc:
337
+ latency = round((time.monotonic() - t0) * 1000, 2)
338
+ logger.opt(exception=exc).error(f"[tool_execute] ์ˆœ์ฐจ ์‹คํ–‰ ์‹คํŒจ: tool={name}")
339
+ tool_results[name] = {
340
+ "success": False,
341
+ "error": f"{type(exc).__name__}: {exc}",
342
+ }
343
+ tool_latencies[name] = latency
344
+ continue
345
+ latency = round((time.monotonic() - t0) * 1000, 2)
346
+ tool_results[name] = result
347
+ tool_latencies[name] = latency
348
+ if result.get("success", True):
349
+ accumulated[name] = result
350
+
351
+ _latency_ms = round((time.monotonic() - _start) * 1000, 2)
352
+ logger.info(
353
+ f"[tool_execute] ์™„๋ฃŒ: {list(tool_results.keys())} "
354
+ f"latency_ms={_latency_ms} per_tool={tool_latencies}"
355
+ )
356
+
357
+ # node_latencies์— ๋…ธ๋“œ ์ „์ฒด ๋ ˆ์ดํ„ด์‹œ์™€ ๊ฐœ๋ณ„ tool ๋ ˆ์ดํ„ด์‹œ๋ฅผ ํ•จ๊ป˜ ๊ธฐ๋กํ•œ๋‹ค.
358
+ # tool ๋ ˆ์ดํ„ด์‹œ๋Š” "tool:<tool_name>" ์ ‘๋‘์‚ฌ๋กœ ๊ตฌ๋ถ„ํ•œ๋‹ค.
359
+ merged_latencies: Dict[str, float] = {"tool_execute": _latency_ms}
360
+ for tool_name, tool_lat in tool_latencies.items():
361
+ merged_latencies[f"tool:{tool_name}"] = tool_lat
362
+
363
+ return {
364
+ "tool_results": tool_results,
365
+ "accumulated_context": accumulated,
366
+ "node_latencies": merged_latencies,
367
+ }
368
+
369
+
370
+ async def synthesis_node(state: GovOnGraphState) -> dict:
371
+ """๊ฒฐ๊ณผ ์ข…ํ•ฉ ๋…ธ๋“œ.
372
+
373
+ tool_results์™€ accumulated_context๋ฅผ ์ข…ํ•ฉํ•˜์—ฌ ์ตœ์ข… ์‘๋‹ต ํ…์ŠคํŠธ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
374
+ ๊ธฐ์กด AgentLoop._extract_final_text() ๋กœ์ง์„ ๊ณ„์Šนํ•œ๋‹ค.
375
+
376
+ Parameters
377
+ ----------
378
+ state : GovOnGraphState
379
+ ํ˜„์žฌ graph state. `tool_results`, `accumulated_context`, `task_type`์„ ์ฝ๋Š”๋‹ค.
380
+
381
+ Returns
382
+ -------
383
+ dict
384
+ `final_text`, `evidence_items`, `messages`(AIMessage ์ถ”๊ฐ€)๋ฅผ ๊ฐฑ์‹ ํ•œ๋‹ค.
385
+ """
386
+ _start = time.monotonic()
387
+
388
+ accumulated = state.get("accumulated_context", {})
389
+ task_type = state.get("task_type", "")
390
+
391
+ final_text = _extract_final_text(accumulated, task_type)
392
+ evidence_items = _collect_evidence_items(accumulated)
393
+
394
+ _latency_ms = round((time.monotonic() - _start) * 1000, 2)
395
+ logger.info(
396
+ f"[synthesis] final_text_len={len(final_text)} evidence_items={len(evidence_items)} latency_ms={_latency_ms}"
397
+ )
398
+
399
+ return {
400
+ "final_text": final_text,
401
+ "evidence_items": evidence_items,
402
+ "messages": [AIMessage(content=final_text)],
403
+ "node_latencies": {"synthesis": _latency_ms},
404
+ }
405
+
406
+
407
+ async def persist_node(
408
+ state: GovOnGraphState,
409
+ *,
410
+ session_store: "SessionStore",
411
+ ) -> dict:
412
+ """์˜์†ํ™” ๋…ธ๋“œ.
413
+
414
+ SessionStore์— ๋Œ€ํ™” ํ„ด๊ณผ tool ์‹คํ–‰ ๊ธฐ๋ก์„ ์ €์žฅํ•œ๋‹ค.
415
+ ๊ธฐ์กด SessionContext.add_turn / add_tool_run ๋กœ์ง์„ ๊ณ„์Šนํ•œ๋‹ค.
416
+
417
+ Parameters
418
+ ----------
419
+ state : GovOnGraphState
420
+ ํ˜„์žฌ graph state. ์ „์ฒด state๋ฅผ ์ฝ์–ด ์ €์žฅํ•œ๋‹ค.
421
+ session_store : SessionStore
422
+ graph config์—์„œ closure๋กœ ์ฃผ์ž…๋˜๋Š” ์„ธ์…˜ ์ €์žฅ์†Œ.
423
+
424
+ Returns
425
+ -------
426
+ dict
427
+ side effect๋กœ DB์— ์ €์žฅํ•˜๊ณ , `node_latencies` ์—…๋ฐ์ดํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
428
+ """
429
+ _start = time.monotonic()
430
+
431
+ session_id: str | None = state.get("session_id")
432
+ session = session_store.get_or_create(session_id)
433
+
434
+ # ์‚ฌ์šฉ์ž ์ž…๋ ฅ ์ €์žฅ (messages[0]์ด ์ตœ์ดˆ ์‚ฌ์šฉ์ž ๋ฉ”์‹œ์ง€)
435
+ messages = state.get("messages", [])
436
+ if messages:
437
+ user_msg = messages[0]
438
+ session.add_turn("user", user_msg.content)
439
+
440
+ # --- graph_run ๊ธฐ๋ก (plan + approval + executed capabilities) ---
441
+ request_id: str = state.get("request_id", "")
442
+ approval_status: str = state.get("approval_status", "")
443
+ planned_tools: List[str] = state.get("planned_tools", [])
444
+ tool_results: Dict[str, Any] = state.get("tool_results", {})
445
+
446
+ # ์Šน์ธ๋œ ๊ฒฝ์šฐ์—๋งŒ ์‹คํ–‰๋œ ๋„๊ตฌ ๋ชฉ๋ก์„ ๊ธฐ๋ก, ๊ฑฐ์ ˆ ์‹œ ๋นˆ ๋ฆฌ์ŠคํŠธ
447
+ executed_capabilities: List[str] = (
448
+ [name for name in planned_tools if name in tool_results]
449
+ if approval_status == ApprovalStatus.APPROVED.value
450
+ else []
451
+ )
452
+
453
+ plan_summary = (
454
+ f"[{state.get('task_type', '')}] {state.get('goal', '')} "
455
+ f"| ์ด์œ : {state.get('reason', '')} | tools: {planned_tools}"
456
+ )
457
+
458
+ total_latency_ms = sum(r.get("latency_ms", 0.0) for r in tool_results.values())
459
+
460
+ # interrupt_reason์ด ์žˆ์œผ๋ฉด "interrupted", ๊ฑฐ์ ˆ์ด๋ฉด "rejected", ๊ทธ ์™ธ "completed"
461
+ interrupt_reason: str | None = state.get("interrupt_reason")
462
+ if interrupt_reason:
463
+ graph_status = "interrupted"
464
+ elif approval_status == ApprovalStatus.REJECTED.value:
465
+ graph_status = "rejected"
466
+ else:
467
+ graph_status = "completed"
468
+
469
+ session.add_graph_run(
470
+ request_id=request_id,
471
+ plan_summary=plan_summary,
472
+ approval_status=approval_status,
473
+ executed_capabilities=executed_capabilities,
474
+ status=graph_status,
475
+ total_latency_ms=total_latency_ms,
476
+ )
477
+
478
+ # tool ์‹คํ–‰ ๊ธฐ๋ก ์ €์žฅ (graph_run_request_id๋กœ ์—ฐ๊ฒฐ)
479
+ for name, result in tool_results.items():
480
+ session.add_tool_run(
481
+ tool=name,
482
+ success=result.get("success", True),
483
+ graph_run_request_id=request_id,
484
+ latency_ms=result.get("latency_ms", 0.0),
485
+ error=result.get("error"),
486
+ )
487
+
488
+ # ์–ด์‹œ์Šคํ„ดํŠธ ์‘๋‹ต ์ €์žฅ
489
+ final_text = state.get("final_text", "")
490
+ if final_text:
491
+ session.add_turn("assistant", final_text)
492
+
493
+ _latency_ms = round((time.monotonic() - _start) * 1000, 2)
494
+ logger.debug(
495
+ f"[persist] session_id={session.session_id} "
496
+ f"graph_run={request_id} saved latency_ms={_latency_ms}"
497
+ )
498
+
499
+ return {"node_latencies": {"persist": _latency_ms}}
500
+
501
+
502
+ def _safe_score(item: dict) -> float:
503
+ """evidence item์˜ score๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ float์œผ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค.
504
+
505
+ ์™ธ๋ถ€ API ๊ฒฐ๊ณผ์˜ score๊ฐ€ ๋ฌธ์ž์—ด์ด๊ฑฐ๋‚˜ None์ผ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ
506
+ ๋ณ€ํ™˜ ์‹คํŒจ ์‹œ 0.0์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
507
+ """
508
+ try:
509
+ return float(item.get("score", 0.0))
510
+ except (ValueError, TypeError):
511
+ return 0.0
512
+
513
+
514
+ # accumulated ์ปจํ…์ŠคํŠธ ํƒ์ƒ‰ ์‹œ ์Šคํ‚ตํ•  ๋ฉ”ํƒ€ ํ‚ค ๋ชฉ๋ก (๋ชจ๋“ˆ ๋ ˆ๋ฒจ ์ƒ์ˆ˜)
515
+ _CONTEXT_META_KEYS: frozenset[str] = frozenset(
516
+ {
517
+ "session_context",
518
+ "query",
519
+ "query_variants",
520
+ "previous_user_query",
521
+ "previous_assistant_response",
522
+ "recent_tool_summary",
523
+ }
524
+ )
525
+
526
+
527
+ def _collect_evidence_items(accumulated: Dict[str, Any]) -> list[dict]:
528
+ """accumulated ์ปจํ…์ŠคํŠธ์—์„œ ๋ชจ๋“  EvidenceItem dict๋ฅผ ์ˆ˜์ง‘ํ•œ๋‹ค.
529
+
530
+ ๊ฐ tool ๊ฒฐ๊ณผ์˜ evidence.items ํ•„๋“œ๋ฅผ ํƒ์ƒ‰ํ•˜์—ฌ ํ•˜๋‚˜์˜ ๋ฆฌ์ŠคํŠธ๋กœ ํ•ฉ์‚ฐํ•œ๋‹ค.
531
+ ์ตœ๋Œ€ 10๊ฐœ๊นŒ์ง€ ๋ฐ˜ํ™˜ํ•˜๋ฉฐ, score ๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ ์ •๋ ฌํ•œ๋‹ค.
532
+
533
+ Parameters
534
+ ----------
535
+ accumulated : Dict[str, Any]
536
+ tool ๊ฒฐ๊ณผ๊ฐ€ ๋ˆ„์ ๋œ ์ปจํ…์ŠคํŠธ dict.
537
+
538
+ Returns
539
+ -------
540
+ list[dict]
541
+ EvidenceItem.to_dict() ํ˜•ํƒœ์˜ dict ๋ฆฌ์ŠคํŠธ.
542
+ """
543
+ items: list[dict] = []
544
+ for key, payload in accumulated.items():
545
+ if key in _CONTEXT_META_KEYS:
546
+ continue
547
+ if not isinstance(payload, dict):
548
+ continue
549
+ ev = payload.get("evidence")
550
+ if isinstance(ev, dict) and ev.get("items"):
551
+ for item in ev["items"]:
552
+ if isinstance(item, dict):
553
+ items.append(item)
554
+ # score ๋‚ด๋ฆผ์ฐจ์ˆœ, ์ตœ๋Œ€ 10๊ฐœ โ€” ์™ธ๋ถ€ ๊ฐ’์ด๋ฏ€๋กœ _safe_score๋กœ ๋ฐฉ์–ด์  ๋ณ€ํ™˜
555
+ items.sort(key=_safe_score, reverse=True)
556
+ return items[:10]
557
+
558
+
559
+ def _safe_score(item: dict) -> float:
560
+ """evidence item์˜ score๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ float์œผ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค.
561
+
562
+ ์™ธ๋ถ€ API ๊ฒฐ๊ณผ์˜ score๊ฐ€ ๋ฌธ์ž์—ด์ด๊ฑฐ๋‚˜ None์ผ ์ˆ˜ ์žˆ์œผ๏ฟฝ๏ฟฝ๋กœ
563
+ ๋ณ€ํ™˜ ์‹คํŒจ ์‹œ 0.0์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
564
+ """
565
+ try:
566
+ return float(item.get("score", 0.0))
567
+ except (ValueError, TypeError):
568
+ return 0.0
569
+
570
+
571
+ # accumulated ์ปจํ…์ŠคํŠธ ํƒ์ƒ‰ ์‹œ ์Šคํ‚ตํ•  ๋ฉ”ํƒ€ ํ‚ค ๋ชฉ๋ก (๋ชจ๋“ˆ ๋ ˆ๋ฒจ ์ƒ์ˆ˜)
572
+ _CONTEXT_META_KEYS: frozenset[str] = frozenset(
573
+ {
574
+ "session_context",
575
+ "query",
576
+ "query_variants",
577
+ "previous_user_query",
578
+ "previous_assistant_response",
579
+ "recent_tool_summary",
580
+ }
581
+ )
582
+
583
+
584
+ def _collect_evidence_items(accumulated: Dict[str, Any]) -> list[dict]:
585
+ """accumulated ์ปจํ…์ŠคํŠธ์—์„œ ๋ชจ๋“  EvidenceItem dict๋ฅผ ์ˆ˜์ง‘ํ•œ๋‹ค.
586
+
587
+ ๊ฐ tool ๊ฒฐ๊ณผ์˜ evidence.items ํ•„๋“œ๋ฅผ ํƒ์ƒ‰ํ•˜์—ฌ ํ•˜๋‚˜์˜ ๋ฆฌ์ŠคํŠธ๋กœ ํ•ฉ์‚ฐํ•œ๋‹ค.
588
+ ์ตœ๋Œ€ 10๊ฐœ๊นŒ์ง€ ๋ฐ˜ํ™˜ํ•˜๋ฉฐ, score ๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ ์ •๋ ฌํ•œ๋‹ค.
589
+
590
+ Parameters
591
+ ----------
592
+ accumulated : Dict[str, Any]
593
+ tool ๊ฒฐ๊ณผ๊ฐ€ ๋ˆ„์ ๋œ ์ปจํ…์ŠคํŠธ dict.
594
+
595
+ Returns
596
+ -------
597
+ list[dict]
598
+ EvidenceItem.to_dict() ํ˜•ํƒœ์˜ dict ๋ฆฌ์ŠคํŠธ.
599
+ """
600
+ items: list[dict] = []
601
+ for key, payload in accumulated.items():
602
+ if key in _CONTEXT_META_KEYS:
603
+ continue
604
+ if not isinstance(payload, dict):
605
+ continue
606
+ ev = payload.get("evidence")
607
+ if isinstance(ev, dict) and ev.get("items"):
608
+ for item in ev["items"]:
609
+ if isinstance(item, dict):
610
+ items.append(item)
611
+ # score ๋‚ด๋ฆผ์ฐจ์ˆœ, ์ตœ๋Œ€ 10๊ฐœ โ€” ์™ธ๋ถ€ ๊ฐ’์ด๋ฏ€๋กœ _safe_score๋กœ ๋ฐฉ์–ด์  ๋ณ€ํ™˜
612
+ items.sort(key=_safe_score, reverse=True)
613
+ return items[:10]
614
+
615
+
616
+ def _extract_final_text(accumulated: Dict[str, Any], task_type: str) -> str:
617
+ """tool ๊ฒฐ๊ณผ๋ฅผ ์ข…ํ•ฉํ•˜์—ฌ ์ตœ์ข… ํ…์ŠคํŠธ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
618
+
619
+ ๊ธฐ์กด AgentLoop._extract_final_text()๋ฅผ ๊ณ„์Šนํ•˜๋˜,
620
+ task_type์„ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ถ„๊ธฐํ•œ๋‹ค.
621
+
622
+ append_evidence ํƒ€์ž…์ผ ๋•Œ๋Š” ๊ธฐ์กด ๋‹ต๋ณ€(previous_assistant_response)์„
623
+ evidence ์„น์…˜ ์•ž์— prependํ•˜์—ฌ ๋‹ต๋ณ€์„ ๋ณด๊ฐ•ํ•œ๋‹ค.
624
+
625
+ Parameters
626
+ ----------
627
+ accumulated : Dict[str, Any]
628
+ tool ๊ฒฐ๊ณผ๊ฐ€ ๋ˆ„์ ๋œ ์ปจํ…์ŠคํŠธ dict.
629
+ task_type : str
630
+ TaskType.value (์˜ˆ: "draft_response").
631
+
632
+ Returns
633
+ -------
634
+ str
635
+ ์ตœ์ข… ์‘๋‹ต ํ…์ŠคํŠธ.
636
+ """
637
+ # append_evidence: ๊ธฐ์กด ๋‹ต๋ณ€ ์œ„์— ๊ทผ๊ฑฐ ์„น์…˜์„ ์ถ”๊ฐ€ํ•œ๋‹ค
638
+ if task_type == "append_evidence":
639
+ previous_draft = str(accumulated.get("previous_assistant_response", "")).strip()
640
+ evidence_section = _build_evidence_section(accumulated)
641
+ if previous_draft and evidence_section:
642
+ return f"{previous_draft}\n\n{evidence_section}"
643
+ if evidence_section:
644
+ return evidence_section
645
+ if previous_draft:
646
+ return previous_draft
647
+
648
+ # 1. append_evidence ๋˜๋Š” draft_civil_response์˜ ์ง์ ‘ ํ…์ŠคํŠธ๊ฐ€ ์žˆ์œผ๋ฉด ์‚ฌ์šฉ
649
+ for key in ("append_evidence", "draft_civil_response"):
650
+ payload = accumulated.get(key, {})
651
+ if isinstance(payload, dict) and payload.get("text"):
652
+ return str(payload["text"])
653
+
654
+ # 2. ๋ชจ๋“  accumulated ๊ฒฐ๊ณผ์—์„œ ํ…์ŠคํŠธ ํƒ์ƒ‰
655
+ for key, payload in accumulated.items():
656
+ if key in ("session_context", "query"):
657
+ continue
658
+ if isinstance(payload, dict) and payload.get("text"):
659
+ return str(payload["text"])
660
+
661
+ # 3. ๊ฐœ๋ณ„ ๊ฒฐ๊ณผ ์กฐํ•ฉ โ€” evidence ํ•„๋“œ๊ฐ€ ์žˆ์œผ๋ฉด ์šฐ์„  ์‚ฌ์šฉ
662
+ parts: list[str] = []
663
+
664
+ # evidence ํ•„๋“œ ๊ธฐ๋ฐ˜ ์ถœ์ฒ˜ ๋ชฉ๋ก ์ƒ์„ฑ (source-specific branching ์—†์ด)
665
+ all_evidence_items: list[dict] = []
666
+ for key, payload in accumulated.items():
667
+ if key in ("session_context", "query"):
668
+ continue
669
+ if isinstance(payload, dict):
670
+ ev = payload.get("evidence")
671
+ if isinstance(ev, dict) and ev.get("items"):
672
+ all_evidence_items.extend(ev["items"])
673
+
674
+ if all_evidence_items:
675
+ lines = ["[์ฐธ์กฐ ๊ทผ๊ฑฐ]"]
676
+ for item in all_evidence_items[:5]:
677
+ source_type = item.get("source_type", "")
678
+ title = item.get("title", "")
679
+ excerpt = item.get("excerpt", "")[:120]
680
+ label = (
681
+ "[๋กœ์ปฌ]" if source_type == "rag" else "[์™ธ๋ถ€]" if source_type == "api" else "[์ƒ์„ฑ]"
682
+ )
683
+ if title:
684
+ lines.append(f"- {label} {title}: {excerpt}")
685
+ elif excerpt:
686
+ lines.append(f"- {label} {excerpt}")
687
+ if len(lines) > 1:
688
+ parts.append("\n".join(lines))
689
+
690
+ # evidence๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ legacy fallback
691
+ if not parts:
692
+ rag_data = accumulated.get("rag_search", {})
693
+ if isinstance(rag_data, dict) and rag_data.get("results"):
694
+ lines = ["[๋กœ์ปฌ ๋ฌธ์„œ ๊ทผ๊ฑฐ]"]
695
+ for item in rag_data["results"][:3]:
696
+ title = item.get("title", "")
697
+ content = str(item.get("content", ""))[:120]
698
+ lines.append(f"- {title}: {content}")
699
+ parts.append("\n".join(lines))
700
+
701
+ api_data = accumulated.get("api_lookup", {})
702
+ if isinstance(api_data, dict) and api_data.get("context_text"):
703
+ parts.append(api_data["context_text"])
704
+
705
+ return "\n\n".join(parts) if parts else "์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
706
+
707
+
708
+ def _build_evidence_section(accumulated: Dict[str, Any]) -> str:
709
+ """accumulated์—์„œ ๊ทผ๊ฑฐ ์„น์…˜ ํ…์ŠคํŠธ๋ฅผ ๊ตฌ์„ฑํ•œ๋‹ค.
710
+
711
+ append_evidence capability์˜ ์ง์ ‘ ํ…์ŠคํŠธ๊ฐ€ ์žˆ์œผ๋ฉด ์šฐ์„  ์‚ฌ์šฉํ•˜๊ณ ,
712
+ ์—†์œผ๋ฉด evidence items์—์„œ ๊ตฌ์กฐํ™”๋œ ํ…์ŠคํŠธ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
713
+
714
+ ๊ณ„์•ฝ(contract):
715
+ - ์ด ํ•จ์ˆ˜๋Š” **๊ทผ๊ฑฐ ์„น์…˜๋งŒ** ๋ฐ˜ํ™˜ํ•œ๋‹ค. ๊ธฐ์กด ๋‹ต๋ณ€(previous_draft)์€ ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค.
716
+ - ํ˜ธ์ถœ์ž(_extract_final_text)๊ฐ€ previous_draft์™€ ๋ณ‘ํ•ฉํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
717
+ - AppendEvidenceCapability.execute()์˜ text ํ•„๋“œ๋„ ๊ทผ๊ฑฐ ์„น์…˜๋งŒ ๋‹ด์•„์•ผ ํ•œ๋‹ค.
718
+ (๊ธฐ์กด ๋‹ต๋ณ€์„ ํฌํ•จํ•œ ์™„์ „ ์‘๋‹ต์„ text์— ๋„ฃ์œผ๋ฉด _extract_final_text์—์„œ ์ค‘๋ณต๋œ๋‹ค.)
719
+
720
+ Parameters
721
+ ----------
722
+ accumulated : Dict[str, Any]
723
+ tool ๊ฒฐ๊ณผ๊ฐ€ ๋ˆ„์ ๋œ ์ปจํ…์ŠคํŠธ dict.
724
+
725
+ Returns
726
+ -------
727
+ str
728
+ ๊ทผ๊ฑฐ ์„น์…˜ ํ…์ŠคํŠธ. ๊ทผ๊ฑฐ๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ๋ฌธ์ž์—ด.
729
+ """
730
+ # append_evidence capability์˜ ์ง์ ‘ ์ƒ์„ฑ ํ…์ŠคํŠธ ์šฐ์„  ์‚ฌ์šฉ
731
+ # ์ด ํ…์ŠคํŠธ๋Š” ๊ทผ๊ฑฐ ์„น์…˜๋งŒ ๋‹ด์•„์•ผ ํ•œ๋‹ค (๊ธฐ์กด ๋‹ต๋ณ€ ํฌํ•จ ๊ธˆ์ง€).
732
+ ae_payload = accumulated.get("append_evidence", {})
733
+ if isinstance(ae_payload, dict) and ae_payload.get("text"):
734
+ return str(ae_payload["text"])
735
+
736
+ # evidence items์—์„œ ๊ตฌ์กฐํ™” ํ…์ŠคํŠธ ์ƒ์„ฑ
737
+ items = _collect_evidence_items(accumulated)
738
+ if not items:
739
+ return ""
740
+
741
+ lines = ["[์ฐธ์กฐ ๊ทผ๊ฑฐ]"]
742
+ for item in items[:5]:
743
+ source_type = item.get("source_type", "")
744
+ title = item.get("title", "")
745
+ excerpt = item.get("excerpt", "")[:120]
746
+ label = "[๋กœ์ปฌ]" if source_type == "rag" else "[์™ธ๋ถ€]" if source_type == "api" else "[์ƒ์„ฑ]"
747
+ if title:
748
+ lines.append(f"- {label} {title}: {excerpt}")
749
+ elif excerpt:
750
+ lines.append(f"- {label} {excerpt}")
751
+
752
+ return "\n".join(lines) if len(lines) > 1 else ""