diff --git a/src/lang2sql/adapters/storage/sqlite_store.py b/src/lang2sql/adapters/storage/sqlite_store.py index ef0c674..559c28b 100644 --- a/src/lang2sql/adapters/storage/sqlite_store.py +++ b/src/lang2sql/adapters/storage/sqlite_store.py @@ -130,15 +130,27 @@ def kv_delete(self, scope: str, key: str) -> None: ) self._conn.commit() + @staticmethod + def _escape_like(s: str) -> str: + return s.replace("!", "!!").replace("%", "!%").replace("_", "!_") + def kv_delete_prefix(self, scope: str, prefix: str) -> int: """Delete all keys under scope that start with prefix. Returns count deleted.""" cur = self._conn.execute( - "DELETE FROM kv WHERE scope = ? AND key LIKE ?", - (scope, prefix + "%"), + "DELETE FROM kv WHERE scope = ? AND key LIKE ? ESCAPE '!'", + (scope, self._escape_like(prefix) + "%"), ) self._conn.commit() return cur.rowcount + def kv_list_prefix(self, scope: str, prefix: str) -> list[tuple[str, str]]: + """Return (key, value) pairs for all keys under scope that start with prefix.""" + rows = self._conn.execute( + "SELECT key, value FROM kv WHERE scope = ? AND key LIKE ? ESCAPE '!' ORDER BY key", + (scope, self._escape_like(prefix) + "%"), + ).fetchall() + return [(r["key"], r["value"]) for r in rows] + # -- Session (de)serialization ------------------------------------------ diff --git a/src/lang2sql/frontends/discord/bot.py b/src/lang2sql/frontends/discord/bot.py index edace0f..2caeebf 100644 --- a/src/lang2sql/frontends/discord/bot.py +++ b/src/lang2sql/frontends/discord/bot.py @@ -148,6 +148,33 @@ async def enrich(interaction: discord.Interaction, table: str = "", clear: bool handlers.enrich(to_identity(_interaction_context(interaction)), table=table, clear=clear), ) + @tree.command(name="term_custom", description="비즈니스 용어 등록·조회·삭제 (조직/팀/개인 범위)") + async def term_custom( + interaction: discord.Interaction, + remove: str = "", + list_all: bool = False, + ) -> None: + if list_all: + await self._run( + interaction, + handlers.term_custom(to_identity(_interaction_context(interaction)), list_all=True), + ) + elif remove: + await self._run( + interaction, + handlers.term_custom(to_identity(_interaction_context(interaction)), term=remove, remove=True), + ) + else: + from .term_wizard import start_term_add_flow + await start_term_add_flow(interaction, handlers, _interaction_context) + + @tree.command(name="org_setup", description="조직 등록 + DB 스캔으로 팀별 용어 자동 추출") + async def org_setup(interaction: discord.Interaction, org: str, clear: bool = False) -> None: + await self._run( + interaction, + handlers.org_setup(to_identity(_interaction_context(interaction)), org=org, clear=clear), + ) + @tree.command(name="semantic_show", description="Show definitions in effect here") async def semantic_show(interaction: discord.Interaction) -> None: await self._run(interaction, handlers.semantic_show(to_identity(_interaction_context(interaction)))) diff --git a/src/lang2sql/frontends/discord/commands.py b/src/lang2sql/frontends/discord/commands.py index 3c1edb4..810c6aa 100644 --- a/src/lang2sql/frontends/discord/commands.py +++ b/src/lang2sql/frontends/discord/commands.py @@ -188,6 +188,40 @@ async def enrich(self, identity: Identity, table: str = "", clear: bool = False) ) return OutboundMessage(text=result.content) + async def org_setup(self, identity: Identity, org: str, clear: bool = False) -> OutboundMessage: + """조직 등록 + DB 스캔으로 팀별 용어 자동 추출.""" + ctx = await self._concierge.build_context(identity) + result = await ctx.tools.dispatch( + "org_setup", {"org": org, "clear": clear}, ctx, "cmd:org_setup" + ) + return OutboundMessage(text=result.content) + + async def term_custom( + self, + identity: Identity, + term: str = "", + definition: str = "", + layer: str = "member", + synonyms: str = "", + inferred: bool = False, + scan: bool = False, + remove: bool = False, + list_all: bool = False, + ) -> OutboundMessage: + """채널(팀)/전사/개인 계층 비즈니스 용어 사전 관리.""" + ctx = await self._concierge.build_context(identity) + result = await ctx.tools.dispatch( + "term_custom", + { + "term": term, "definition": definition, "layer": layer, + "synonyms": synonyms, "inferred": inferred, "scan": scan, + "remove": remove, "list": list_all, + }, + ctx, + "cmd:term_custom", + ) + return OutboundMessage(text=result.content) + async def connect(self, identity: Identity, dsn: str) -> OutboundMessage: """V1 stub: stash a DB DSN keyed by guild/DM in the concierge kv store. diff --git a/src/lang2sql/frontends/discord/term_wizard.py b/src/lang2sql/frontends/discord/term_wizard.py new file mode 100644 index 0000000..ed228e8 --- /dev/null +++ b/src/lang2sql/frontends/discord/term_wizard.py @@ -0,0 +1,121 @@ +"""term_wizard.py — /term_custom 등록 폼 (2단계 UI). + +Step 1: Select — 전사(guild) / 채널·팀(channel) / 개인(member) 선택 +Step 2: Modal — 용어명·정의·동의어 입력 + +채널이 팀 경계 역할을 하므로 entity 직접 입력 불필요. +setup_wizard.py 패턴 동일: Select 선택 → Modal 응답. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import discord +from discord import ui + +from .session_router import to_identity + +if TYPE_CHECKING: + from .commands import CommandHandlers + + +_LAYER_OPTIONS = [ + discord.SelectOption( + label="전사 (Guild) — 회사 공통 정의", + value="guild", + description="모든 채널에서 기본값으로 사용", + ), + discord.SelectOption( + label="채널 (팀) — 이 채널 전용 정의", + value="channel", + description="다른 채널과 충돌 없이 이 채널에서만 유효", + ), + discord.SelectOption( + label="개인 — 나만 사용하는 정의", + value="member", + description="전사·채널 정의를 조용히 덮어씀", + ), +] + + +class _TermModal(ui.Modal, title="비즈니스 용어 등록"): + term = ui.TextInput( + label="용어명", + placeholder="예: 활성고객", + required=True, + max_length=100, + ) + definition = ui.TextInput( + label="정의", + placeholder="예: 최근 30일 내 로그인한 users", + required=True, + style=discord.TextStyle.paragraph, + max_length=500, + ) + synonyms = ui.TextInput( + label="동의어 (쉼표 구분, 선택)", + placeholder="예: active user, 활성화고객", + required=False, + max_length=200, + ) + + def __init__(self, layer: str, handlers: "CommandHandlers", ctx_factory) -> None: + super().__init__() + self._layer = layer + self._handlers = handlers + self._ctx_factory = ctx_factory + + async def on_submit(self, interaction: discord.Interaction) -> None: + await interaction.response.defer(ephemeral=True, thinking=True) + try: + identity = to_identity(self._ctx_factory(interaction)) + result = await self._handlers.term_custom( + identity, + term=self.term.value.strip(), + definition=self.definition.value.strip(), + layer=self._layer, + synonyms=self.synonyms.value.strip(), + ) + await interaction.followup.send(result.text, ephemeral=True) + except Exception as exc: + await interaction.followup.send(f"❌ 오류: {exc}", ephemeral=True) + + +class _LayerSelect(ui.Select): + def __init__(self, handlers: "CommandHandlers", ctx_factory) -> None: + super().__init__( + placeholder="적용 범위를 선택하세요…", + options=_LAYER_OPTIONS, + min_values=1, + max_values=1, + ) + self._handlers = handlers + self._ctx_factory = ctx_factory + + async def callback(self, interaction: discord.Interaction) -> None: + await interaction.response.send_modal( + _TermModal(self.values[0], self._handlers, self._ctx_factory) + ) + + +class _LayerSelectView(ui.View): + def __init__(self, handlers: "CommandHandlers", ctx_factory) -> None: + super().__init__(timeout=120.0) + self.add_item(_LayerSelect(handlers, ctx_factory)) + + +async def start_term_add_flow( + interaction: discord.Interaction, + handlers: "CommandHandlers", + ctx_factory, +) -> None: + """bot.py의 /term_custom 커맨드에서 호출 — 범위 선택 → 용어 등록 모달.""" + await interaction.response.send_message( + "용어를 등록할 **범위**를 선택하세요.\n" + "- **전사**: 모든 채널에서 기본값\n" + "- **채널(팀)**: 이 채널에서만 유효 (다른 채널과 충돌 없음)\n" + "- **개인**: 나만 사용하는 정의 (전사·채널 정의를 덮어씀)", + view=_LayerSelectView(handlers, ctx_factory), + ephemeral=True, + ) diff --git a/src/lang2sql/harness/system_prompt.py b/src/lang2sql/harness/system_prompt.py index 3899a6f..11eb0c0 100644 --- a/src/lang2sql/harness/system_prompt.py +++ b/src/lang2sql/harness/system_prompt.py @@ -72,4 +72,11 @@ async def build_system_prompt(ctx: HarnessContext) -> str: except (ValueError, TypeError): pass + from ..tools.semantic_federation import build_prompt_section + user_id = ctx.identity.user_id or "unknown" + channel_id = ctx.identity.thread_id or ctx.identity.channel_id or "" + semfed_section = build_prompt_section(ctx.store, scope, channel_id, user_id) + if semfed_section: + parts.append(semfed_section) + return "\n\n".join(parts) diff --git a/src/lang2sql/tools/__init__.py b/src/lang2sql/tools/__init__.py index cbd2e8f..aca08d7 100644 --- a/src/lang2sql/tools/__init__.py +++ b/src/lang2sql/tools/__init__.py @@ -17,12 +17,15 @@ from .enrich_schema import EnrichSchema from .explore_schema import ExploreSchema from .ingest_doc import IngestDoc +from .org_setup import OrgSetupTool from .remember import Remember from .run_sql import RunSQL +from .semantic_federation import SemanticFederationTool __all__ = [ "build_default_tools", - "RunSQL", "ExploreSchema", "EnrichSchema", "DefineMetric", "Remember", "AskUser", "IngestDoc", + "RunSQL", "ExploreSchema", "EnrichSchema", "DefineMetric", "SemanticFederationTool", + "OrgSetupTool", "Remember", "AskUser", "IngestDoc", ] @@ -39,6 +42,8 @@ def build_default_tools( ExploreSchema(), EnrichSchema(), DefineMetric(), + SemanticFederationTool(), + OrgSetupTool(), AskUser(), Remember(memory), IngestDoc(ingestion, source, extractor), diff --git a/src/lang2sql/tools/org_setup.py b/src/lang2sql/tools/org_setup.py new file mode 100644 index 0000000..ba9f18f --- /dev/null +++ b/src/lang2sql/tools/org_setup.py @@ -0,0 +1,200 @@ +"""OrgSetupTool — 팀/조직 등록 + 비즈니스 용어 자동 추출. + +온보딩 2단계 (/setup으로 DB 연결 후 실행): +1. 접근 가능한 테이블 스캔 (팀의 DB 권한 = 팀이 보는 테이블) +2. LLM이 테이블 구조 + 샘플 데이터 분석 → 팀 도메인 + 핵심 용어 추론 +3. 결과를 SemanticFederationTool과 동일한 KV 네임스페이스에 저장 + → build_prompt_section()이 자동으로 읽어 시스템 프롬프트에 주입 + +KV 저장: + org:{org_lower} → {"name", "domain", "registered_at"} + cterm:{term_lower}:team:{org_lower} → FedEntry JSON (SemanticFederationTool 호환) +""" + +from __future__ import annotations + +import json +import re +import time +from typing import TYPE_CHECKING, Any + +from ..core.ports.tool import ToolPort +from ..core.types import Message, Role, ToolResult, ToolSpec +from .semantic_federation import FedEntry + +if TYPE_CHECKING: + from ..harness.context import HarnessContext + +_SAMPLE_LIMIT = 10 +_ORG_PREFIX = "org" +_SEMFED_PREFIX = "cterm" # SemanticFederationTool과 동일한 KV 네임스페이스 + + +def _build_prompt(org_name: str, schema_block: str) -> str: + return ( + f'이 DB에 접근 권한이 있는 팀/조직은 **"{org_name}"** 입니다.\n' + "아래는 이 팀이 접근 가능한 테이블 스키마와 실제 데이터 샘플입니다.\n\n" + f"{schema_block}\n\n" + "위 데이터를 분석해서 다음을 추론해줘:\n" + "1. 이 팀이 담당하는 업무 도메인 (한 줄)\n" + "2. 이 팀이 자주 사용할 비즈니스 핵심 용어 (최대 10개)\n" + " - 각 용어의 DB 기반 정의 (어느 테이블/컬럼에 해당하는지 포함)\n" + " - 다른 팀에서 다르게 부를 수 있는 동의어/별칭\n\n" + "아래 JSON 형식으로만 응답:\n" + "{\n" + ' "domain": "이 팀의 업무 도메인 한 줄 설명",\n' + ' "terms": [\n' + ' {"term": "용어명", "definition": "DB 기반 정의", "synonyms": ["동의어1", "동의어2"]}\n' + " ]\n" + "}" + ) + + +def _extract_result(text: str) -> tuple[str, list[dict]]: + m = re.search(r"\{.*\}", text, re.DOTALL) + if not m: + return "", [] + try: + data = json.loads(m.group(0)) + domain = data.get("domain", "") if isinstance(data, dict) else "" + terms = data.get("terms", []) if isinstance(data, dict) else [] + if not isinstance(terms, list): + terms = [] + return domain, terms + except (ValueError, TypeError): + return "", [] + + +class OrgSetupTool(ToolPort): + @property + def spec(self) -> ToolSpec: + return ToolSpec( + name="org_setup", + description=( + "조직/팀 등록 및 접근 가능한 DB 테이블을 스캔해 팀별 비즈니스 용어를 자동 추출한다. " + "DB 연결(/setup) 후 각 팀이 한 번 실행." + ), + parameters={ + "type": "object", + "properties": { + "org": { + "type": "string", + "description": "팀/조직 이름 (예: 재무팀, 마케팅팀)", + }, + "clear": { + "type": "boolean", + "description": "true이면 해당 org의 용어 캐시 초기화", + }, + }, + "required": ["org"], + }, + ) + + async def run(self, args: dict[str, Any], ctx: "HarnessContext") -> ToolResult: + if ctx.store is None: + return ToolResult(call_id="", content="❌ KV store 미설정", is_error=True) + + org_name = str(args.get("org", "")).strip() + if not org_name: + return ToolResult(call_id="", content="❌ org 파라미터가 필요합니다.", is_error=True) + + scope = ctx.identity.guild_id or f"dm:{ctx.identity.user_id}" + org_lower = org_name.lower() + + if args.get("clear"): + # inferred=True인 guild 레이어 항목만 삭제 (사용자 직접 등록 용어는 보존) + entries = ctx.store.kv_list_prefix(scope, f"{_SEMFED_PREFIX}:") + deleted = 0 + for key, val in entries: + if not key.endswith(":guild"): + continue + try: + data = json.loads(val) + except (ValueError, TypeError): + continue + if data.get("inferred"): + ctx.store.kv_delete(scope, key) + deleted += 1 + ctx.store.kv_delete(scope, f"{_ORG_PREFIX}:{org_lower}") + return ToolResult(call_id="", content=f"🗑️ **{org_name}** 자동 추출 용어 {deleted}개 초기화 완료 (수동 등록 용어 보존)") + + if ctx.explorer is None: + return ToolResult(call_id="", content="❌ DB가 연결되지 않았습니다 (/setup 먼저).", is_error=True) + + # 접근 가능한 테이블 스캔 (팀의 DB 권한 = 팀이 보는 테이블) + all_tables = await ctx.explorer.list_tables() + if not all_tables: + return ToolResult(call_id="", content="❌ 접근 가능한 테이블이 없습니다.", is_error=True) + + schema_lines: list[str] = [] + for tbl in all_tables: + try: + described = await ctx.explorer.describe_table(tbl.name) + except Exception: + continue + schema_lines.append(f"테이블: {tbl.name}") + for col in described.columns: + try: + sample_sql = ( + f"SELECT DISTINCT {col.name} FROM {tbl.qualified} " + f"WHERE {col.name} IS NOT NULL LIMIT {_SAMPLE_LIMIT}" + ) + rows = await ctx.explorer.execute(sample_sql, _SAMPLE_LIMIT) + samples = [str(r.get(col.name, r.get(list(r.keys())[0], ""))) for r in rows] + except Exception: + samples = [] + sample_str = f" 샘플: {samples}" if samples else "" + schema_lines.append(f"- {col.name} ({col.type}){sample_str}") + schema_lines.append("") + + schema_block = "\n".join(schema_lines) + prompt = _build_prompt(org_name, schema_block) + + completion = await ctx.llm.complete([Message(role=Role.USER, content=prompt)]) + domain, terms = _extract_result(completion.content) + + if not terms: + return ToolResult( + call_id="", + content="LLM이 용어를 추출하지 못했습니다. 다시 시도해주세요.", + is_error=True, + ) + + # org 등록 + ctx.store.kv_set( + scope, + f"{_ORG_PREFIX}:{org_lower}", + json.dumps({"name": org_name, "domain": domain, "registered_at": time.time()}, ensure_ascii=False), + ) + + # cterm:{term}:guild 형식으로 저장 → 전사 공통 레이어 (narrow→wide 최하단) + saved_terms: list[str] = [] + for t in terms: + term = str(t.get("term", "")).strip() + definition = str(t.get("definition", "")).strip() + synonyms = t.get("synonyms", []) + if not term or not definition: + continue + entry = FedEntry( + term=term, layer="guild", entity="", + definition=definition, synonyms=synonyms, inferred=True, + ) + ctx.store.kv_set( + scope, + f"{_SEMFED_PREFIX}:{term.lower()}:guild", + entry.to_json(), + ) + syn_str = f" (= {', '.join(synonyms)})" if synonyms else "" + saved_terms.append(f"- **{term}**{syn_str}: {definition} 🤖") + + domain_line = f"📌 도메인: {domain}\n\n" if domain else "" + term_block = "\n".join(saved_terms) + return ToolResult( + call_id="", + content=( + f"✅ **{org_name}** 조직 등록 완료 — " + f"테이블 {len(all_tables)}개 스캔, 용어 {len(saved_terms)}개 추출\n\n" + f"{domain_line}" + f"**추출된 용어:**\n{term_block}" + ), + ) diff --git a/src/lang2sql/tools/semantic_federation.py b/src/lang2sql/tools/semantic_federation.py new file mode 100644 index 0000000..33bb0f5 --- /dev/null +++ b/src/lang2sql/tools/semantic_federation.py @@ -0,0 +1,349 @@ +"""SemanticFederation — 채널(팀)/전사(guild)/개인(member) 계층 비즈니스 용어 사전. + +계층 우선순위 (narrow → wide): member > channel > guild +- guild : 전사 공통 정의 (회사 전체, /org_setup이 자동 채움) +- channel: 이 채널/팀 전용 정의 (다른 채널과 충돌 없음 — 채널이 격리 경계) +- member : 개인 오버라이드 (조용히 상위 정의를 덮어씀) + +KV 키 구조 (모두 guild scope에 저장): + cterm:{term_lower}:guild → 전사 공통 + cterm:{term_lower}:channel:{ch_id} → 채널(팀) 전용 + cterm:{term_lower}:member:{user_id} → 개인 +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from ..core.ports.tool import ToolPort, ToolResult, ToolSpec + +if TYPE_CHECKING: + from ..harness.context import HarnessContext + +_KV_PREFIX = "cterm" +_LAYERS = ("guild", "channel", "member") + +_ENRICH_PREFIX = "enriched_desc" +_ENRICH_RELATIONSHIPS = "schema_relationships" + +_AMBIGUITY_SIGNALS: dict[str, str] = { + r"(^|_)(created|registered|joined|signup)(_at|_date)?$": "신규/최초 가입 기준 용어", + r"(^|_)(last|latest|recent)_(login|visit|active|seen|access)(_at|_date)?$": "활성화 기준 용어", + r"(^|_)(first|initial)_(order|purchase|buy)(_at|_date)?$": "첫 구매 기준 용어", + r"(^|_)(status|state|type|category|tier|grade|rank|segment)$": "상태/분류 기반 용어", + r"(^|_)(score|point|level|rating)$": "점수/등급 기반 용어", + r"(^|_)(is_|has_|can_).+": "boolean 조건 기반 용어", +} + + +def _kv_key(term: str, layer: str, entity: str) -> str: + base = f"{_KV_PREFIX}:{term.strip().lower()}:{layer}" + if layer == "guild": + return base + return f"{base}:{entity.strip().lower()}" + + +@dataclass +class FedEntry: + term: str + layer: str # guild | channel | member + entity: str # channel_id (channel layer), user_id (member layer), "" (guild layer) + definition: str + synonyms: list[str] = field(default_factory=list) + inferred: bool = False + + def to_json(self) -> str: + return json.dumps( + { + "term": self.term, "layer": self.layer, "entity": self.entity, + "definition": self.definition, "synonyms": self.synonyms, + "inferred": self.inferred, + }, + ensure_ascii=False, + ) + + @staticmethod + def from_json(raw: str) -> "FedEntry": + d = json.loads(raw) + return FedEntry( + term=d["term"], layer=d["layer"], entity=d.get("entity", ""), + definition=d["definition"], synonyms=d.get("synonyms", []), + inferred=d.get("inferred", False), + ) + + +class SemanticFederationTool(ToolPort): + @property + def spec(self) -> ToolSpec: + return ToolSpec( + name="term_custom", + description=( + "비즈니스 용어 사전 관리. " + "layer=guild(전사)/channel(이 채널·팀)/member(개인). " + "lookup은 narrow→wide: member > channel > guild. " + "list=true로 전체 조회. remove=true로 삭제." + ), + parameters={ + "type": "object", + "properties": { + "term": { + "type": "string", + "description": "정식 용어명 (예: 활성고객)", + }, + "definition": { + "type": "string", + "description": "DB 컨텍스트에서의 정의 (예: 최근 30일 로그인한 users)", + }, + "layer": { + "type": "string", + "enum": ["guild", "channel", "member"], + "description": "등록 범위. guild=전사 공통, channel=이 채널(팀), member=개인(기본값)", + }, + "synonyms": { + "type": "string", + "description": "쉼표 구분 동의어 (예: active_user,활성화고객)", + }, + "inferred": { + "type": "boolean", + "description": "true 시 LLM 추론 임시 정의로 표시. 사용자 확인 후 재등록 권장.", + }, + "scan": { + "type": "boolean", + "description": "true 시 enriched schema에서 모호 용어 후보 탐색.", + }, + "remove": { + "type": "boolean", + "description": "true 시 해당 term+layer 삭제", + }, + "list": { + "type": "boolean", + "description": "true 시 현재 채널 기준 유효 용어 목록 반환", + }, + }, + }, + ) + + async def run(self, args: dict[str, Any], ctx: "HarnessContext") -> ToolResult: + if ctx.store is None: + return ToolResult(call_id="", content="❌ KV store 미설정", is_error=True) + + scope = ctx.identity.guild_id or f"dm:{ctx.identity.user_id}" + user_id = ctx.identity.user_id or "unknown" + channel_id = ctx.identity.thread_id or ctx.identity.channel_id or "" + + if args.get("list"): + return ToolResult(call_id="", content=_render_effective(ctx.store, scope, channel_id, user_id)) + + if args.get("scan"): + return ToolResult(call_id="", content=_scan_schema(ctx.store, scope)) + + term = str(args.get("term", "")).strip() + if not term: + return ToolResult(call_id="", content="❌ term 파라미터가 필요합니다.", is_error=True) + if ":" in term: + return ToolResult(call_id="", content="❌ term에 ':'를 사용할 수 없습니다.", is_error=True) + + layer = str(args.get("layer", "member")).strip().lower() + if layer not in _LAYERS: + return ToolResult( + call_id="", + content=f"❌ layer는 {list(_LAYERS)} 중 하나여야 합니다.", + is_error=True, + ) + + entity = "" if layer == "guild" else (user_id if layer == "member" else channel_id) + key = _kv_key(term, layer, entity) + + if args.get("remove"): + ctx.store.kv_delete(scope, key) + tag = _layer_tag(layer, entity, user_id, channel_id) + return ToolResult(call_id="", content=f"🗑️ **{term}** [{tag}] 삭제") + + definition = str(args.get("definition", "")).strip() + if not definition: + return ToolResult(call_id="", content="❌ definition 파라미터가 필요합니다.", is_error=True) + + raw_syns = str(args.get("synonyms", "")).strip() + synonyms = [s.strip() for s in raw_syns.split(",") if s.strip()] if raw_syns else [] + inferred = bool(args.get("inferred", False)) + + entry = FedEntry(term=term, layer=layer, entity=entity, + definition=definition, synonyms=synonyms, inferred=inferred) + ctx.store.kv_set(scope, key, entry.to_json()) + + tag = _layer_tag(layer, entity, user_id, channel_id) + syn_str = f" (= {', '.join(synonyms)})" if synonyms else "" + inferred_badge = " 🤖추론" if inferred else "" + return ToolResult( + call_id="", + content=f"✅ **{term}** [{tag}]{syn_str}{inferred_badge}: {definition}", + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _layer_tag(layer: str, entity: str, user_id: str, channel_id: str) -> str: + if layer == "guild": + return "전사" + if layer == "channel": + return f"채널:{channel_id}" + return f"개인:{user_id}" + + +# --------------------------------------------------------------------------- +# Schema scan +# --------------------------------------------------------------------------- + +def _scan_schema(store: Any, scope: str) -> str: + col_entries = store.kv_list_prefix(scope, _ENRICH_PREFIX + ":") + if not col_entries: + return "⚠️ enriched schema가 없습니다. 먼저 `/enrich`를 실행해 스키마를 보강하세요." + + col_map: dict[str, str] = {} + for key, desc in col_entries: + parts = key.split(":", 2) + if len(parts) == 3: + col_map[f"{parts[1]}.{parts[2]}"] = desc + + raw_rels = store.kv_get(scope, _ENRICH_RELATIONSHIPS) + relationships: list[str] = json.loads(raw_rels) if raw_rels else [] + + candidates: dict[str, list[tuple[str, str, str]]] = {} + for col_key, desc in col_map.items(): + table, col = col_key.split(".", 1) + for pattern, signal_type in _AMBIGUITY_SIGNALS.items(): + if re.search(pattern, col, re.IGNORECASE): + candidates.setdefault(signal_type, []).append((table, col, desc)) + break + + if not candidates: + return f"스키마에서 모호 용어를 암시하는 컬럼을 찾지 못했습니다. (스캔한 컬럼 수: {len(col_map)}개)" + + lines = [ + "## Business Terminology — 스키마 스캔 결과\n", + "다음 컬럼들이 모호한 비즈니스 용어를 암시합니다.", + "각 항목에 대해 term_custom 등록 여부를 사용자에게 확인하세요.\n", + ] + for signal_type, cols in candidates.items(): + lines.append(f"### {signal_type}") + for table, col, desc in cols: + desc_str = f" — {desc}" if desc else "" + lines.append(f"- `{table}.{col}`{desc_str}") + lines.append("") + + if relationships: + lines.append("### 테이블 관계 (참고)") + for rel in relationships: + lines.append(f"- {rel}") + lines.append("") + + lines.append( + "---\n위 컬럼을 바탕으로 모호 용어 정의를 추론하고 `term_custom` 툴로 `inferred=true` 등록하거나, " + "사용자에게 어느 범위(guild/channel/member)로 등록할지 확인하세요." + ) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# System-prompt helpers +# --------------------------------------------------------------------------- + +def _load_all(store: Any, scope: str) -> dict[str, list[FedEntry]]: + """KV에서 모든 cterm 엔트리를 {term_lower: [FedEntry]} 로 반환.""" + raw = store.kv_list_prefix(scope, _KV_PREFIX + ":") + by_term: dict[str, list[FedEntry]] = {} + for key, val in raw: + # cterm:{term}:guild or cterm:{term}:channel:{id} or cterm:{term}:member:{id} + parts = key.split(":", 3) + if len(parts) < 3: + continue + layer = parts[2] + if layer not in _LAYERS: + continue + try: + entry = FedEntry.from_json(val) + except (ValueError, KeyError): + continue + by_term.setdefault(parts[1], []).append(entry) + return by_term + + +def build_prompt_section(store: Any, scope: str, channel_id: str, user_id: str) -> str: + """현재 채널 기준 narrow→wide lookup 용어 섹션 + 모호 용어 지침 반환.""" + by_term = _load_all(store, scope) + + if not by_term: + return _AMBIGUOUS_TERM_POLICY + + lines: list[str] = [] + for term_lower in sorted(by_term): + line = _resolve_term(by_term[term_lower], channel_id, user_id) + if line: + lines.append(line) + + header = ( + "## Business Terminology\n" + "(lookup 우선순위: 개인 > 채널(팀) > 전사)\n" + ) + body = "\n".join(lines) if lines else "(없음)" + return header + body + "\n\n" + _AMBIGUOUS_TERM_POLICY + + +_AMBIGUOUS_TERM_POLICY = """\ +## Ambiguous Term Policy +사전에 없는 주관적/모호한 표현(예: 활성화고객, 신규고객, 우량고객)을 발견하면: +1. 현재 DB 스키마 컨텍스트에서 가장 합리적인 해석으로 SQL을 작성하고 실행한다. +2. 쿼리 후 사용한 해석을 명시하고, term_custom 등록 여부와 범위(guild/channel/member)를 사용자에게 묻는다. + 예: "'신규고객'을 'users.created_at >= NOW()-30일'로 해석했습니다. 이 정의를 어느 범위로 등록할까요?" +3. 사용자가 범위를 지정하면 term_custom 툴로 즉시 등록한다 (inferred=true). +4. inferred=true 엔트리가 이미 있으면 해당 정의를 우선 사용하되, 사용자에게 확정 여부를 확인한다.\ +""" + + +def _fmt_entry(e: FedEntry, tag: str) -> str: + syns = ", ".join(e.synonyms) + syn_str = f" (= {syns})" if syns else "" + inferred_badge = " 🤖" if e.inferred else "" + return f"- **{e.term}** [{tag}]{syn_str}{inferred_badge}: {e.definition}" + + +def _resolve_term(entries: list[FedEntry], channel_id: str, user_id: str) -> str: + """narrow→wide lookup: member > channel > guild.""" + # 1. 개인 오버라이드 + for e in entries: + if e.layer == "member" and e.entity.lower() == user_id.lower(): + return _fmt_entry(e, f"개인:{user_id}") + + # 2. 이 채널 정의 + for e in entries: + if e.layer == "channel" and e.entity.lower() == channel_id.lower(): + return _fmt_entry(e, "채널") + + # 3. 전사 공통 + for e in entries: + if e.layer == "guild": + return _fmt_entry(e, "전사") + + return "" + + +def _render_effective(store: Any, scope: str, channel_id: str, user_id: str) -> str: + """Discord /term_custom list 응답 — 현재 채널 기준 유효 용어 목록.""" + by_term = _load_all(store, scope) + if not by_term: + return "등록된 용어가 없습니다.\n`/term_custom`으로 용어를 추가하세요." + + lines = ["**Business Terminology — 현재 채널 기준 유효 정의**\n"] + for term_lower in sorted(by_term): + line = _resolve_term(by_term[term_lower], channel_id, user_id) + if line: + lines.append(line) + + if len(lines) == 1: + lines.append("(이 채널에 적용되는 용어 정의가 없습니다)") + return "\n".join(lines) diff --git a/tests/test_integration.py b/tests/test_integration.py index 413f2ff..6fa6e9c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -26,7 +26,7 @@ def _ctx(): def test_v1_tools_registered(): _, ctx = _ctx() names = {s.name for s in ctx.tools.specs()} - assert names == {"run_sql", "explore_schema", "enrich_schema", "define_metric", "ask_user", "remember", "ingest_doc"} + assert names == {"run_sql", "explore_schema", "enrich_schema", "define_metric", "term_custom", "org_setup", "ask_user", "remember", "ingest_doc"} def test_run_sql_passes_gate_and_returns_rows():