Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: 채널 기반 비즈니스 용어 사전 (Semantic Federation) 구현
- SemanticFederationTool(/term_custom): 채널(팀)/전사/개인 3계층 용어 등록·조회·삭제
- OrgSetupTool(/org_setup): DB 스캔 + LLM으로 팀별 용어 자동 추출 → 전사(guild) 레이어 저장
- Discord UI: /term_custom 호출 시 범위 selectbox → 용어 입력 modal (2단계 UX)
- 채널이 팀 경계 역할 → 팀 멤버십 관리 불필요, 충돌 설계상 없음
- lookup: 개인 > 채널(팀) > 전사(guild) (narrow→wide, git branch 방식)
- kv_list_prefix() 추가 + LIKE 와일드카드 이스케이프 보안 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
  • Loading branch information
thrcle and claude committed Jun 7, 2026
commit 5743854b39ecb9d7739fcc672f1777b199920db2
16 changes: 14 additions & 2 deletions src/lang2sql/adapters/storage/sqlite_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------

Expand Down
27 changes: 27 additions & 0 deletions src/lang2sql/frontends/discord/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))))
Expand Down
34 changes: 34 additions & 0 deletions src/lang2sql/frontends/discord/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
121 changes: 121 additions & 0 deletions src/lang2sql/frontends/discord/term_wizard.py
Original file line number Diff line number Diff line change
@@ -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,
)
7 changes: 7 additions & 0 deletions src/lang2sql/harness/system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
7 changes: 6 additions & 1 deletion src/lang2sql/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]


Expand All @@ -39,6 +42,8 @@ def build_default_tools(
ExploreSchema(),
EnrichSchema(),
DefineMetric(),
SemanticFederationTool(),
OrgSetupTool(),
AskUser(),
Remember(memory),
IngestDoc(ingestion, source, extractor),
Expand Down
Loading