diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9e6f1b1..c5ec201 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -99,7 +99,7 @@ - [`run_sql.py`](../src/lang2sql/tools/run_sql.py) — safety 통과 후 explorer로 실행 - [`explore_schema.py`](../src/lang2sql/tools/explore_schema.py) — 테이블/컬럼 introspection - [`enrich_schema.py`](../src/lang2sql/tools/enrich_schema.py) — LLM으로 컬럼 메타데이터 자동 보강 -- [`semantic_federation.py`](../src/lang2sql/tools/semantic_federation.py) — `term_custom`: guild/channel/member 계층 용어 사전 (KV 기반, narrow→wide lookup) +- [`semantic_federation.py`](../src/lang2sql/tools/semantic_federation.py) — `term_custom`: org/team/user 계층 용어 사전 (KV 기반, narrow→wide lookup) - [`org_setup.py`](../src/lang2sql/tools/org_setup.py) — 전사/팀 단위 용어 일괄 등록 - [`remember.py`](../src/lang2sql/tools/remember.py) — fact 저장 - [`ask_user.py`](../src/lang2sql/tools/ask_user.py) — 모호하면 사용자에게 질문 diff --git a/docs/USAGE.md b/docs/USAGE.md index c2f3c31..41ca522 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -26,7 +26,6 @@ DB가 연결되지 않으면 SQL을 실행할 수 없습니다. DB 종류(PostgreSQL, MySQL, SQLite 등)를 선택하는 안내가 나타납니다. 접속 정보를 입력하면 연결됩니다. DSN을 직접 알고 있다면 `/connect dsn:...`으로 바로 입력할 수도 있습니다. -> 관리자 권한이 필요합니다. --- @@ -87,7 +86,7 @@ DB 스키마를 분석해 비즈니스 용어 후보를 자동으로 뽑습니 같은 용어가 여러 레이어에 등록된 경우 **좁은 범위가 우선** 적용됩니다. ``` -개인(member) > 채널(channel) > 전사(guild) +개인(member) > 채널(channel) > 전사(org) ``` 예를 들어 "활성고객"을 전사에서는 "30일 내 로그인"으로 정의했더라도, 마케팅 채널에서 "14일 내 로그인"으로 따로 등록하면 마케팅 채널 안에서만 그 정의가 우선 적용됩니다. 다른 채널에는 영향이 없습니다. @@ -98,10 +97,9 @@ DB 스키마를 분석해 비즈니스 용어 후보를 자동으로 뽑습니 | 커맨드 | 설명 | |---|---| -| `/setup` | DB 연결 마법사 (관리자) | +| `/setup` | DB 연결 마법사 | | `/connect dsn:...` | DSN으로 직접 DB 연결 | | `/ingest content:...` | 텍스트에서 용어 후보 추출 | -| `/ingest ref:파일명` | 서버 파일에서 용어 후보 추출 | | `/confirm_ingest ref:... accept:... layer:...` | 추출된 후보 검토 후 등록 | | `/term_custom` | 용어 직접 등록 (위저드) | | `/term_custom action:show` | 등록된 용어 전체 조회 | @@ -111,6 +109,7 @@ DB 스키마를 분석해 비즈니스 용어 후보를 자동으로 뽑습니 | `/enrich` | DB 컬럼 메타데이터 자동 보강 | | `/remember text:...` | 사실 기억 저장 | | `/audit_me` | 내 활동 이력 조회 | +| `/help` | 사용 방법 안내 | --- @@ -119,8 +118,8 @@ DB 스키마를 분석해 비즈니스 용어 후보를 자동으로 뽑습니 **Q. 질문했는데 엉뚱한 SQL이 나와요.** 등록된 용어가 없거나 DB 메타데이터가 부족한 경우입니다. `/enrich`로 컬럼 설명을 보강하거나 `/term_custom`으로 관련 용어를 등록해보세요. -**Q. "guild 용어는 관리자만 등록 가능" 오류가 나요.** -`layer:guild`는 관리자 권한이 필요합니다. `layer:channel`로 채널 범위로 등록하거나 관리자에게 요청하세요. +**Q. "org 용어는 관리자만 등록 가능" 오류가 나요.** +`layer:org`는 관리자 권한이 필요합니다. `layer:team`으로 채널 범위로 등록하거나 관리자에게 요청하세요. **Q. 이전 대화 내용을 기억하나요?** 같은 채널(또는 DM 스레드)에서 이어지는 대화는 맥락이 유지됩니다. `/remember`로 중요한 사실을 명시적으로 저장할 수도 있습니다. diff --git a/src/lang2sql/adapters/storage/okf_bundle.py b/src/lang2sql/adapters/storage/okf_bundle.py index 1d3992e..8370594 100644 --- a/src/lang2sql/adapters/storage/okf_bundle.py +++ b/src/lang2sql/adapters/storage/okf_bundle.py @@ -105,7 +105,7 @@ def import_(self, store: "SqliteStore", kv_scope: str) -> int: # ------------------------------------------------------------------ def _scope_dir(self, entry: FedEntry) -> Path: - label = "guild" if entry.layer == "guild" else f"{entry.layer}:{entry.entity}" + label = "org" if entry.layer == "org" else f"{entry.layer}:{entry.entity}" return self.base_dir / label def _concept_path(self, entry: FedEntry) -> Path: @@ -172,7 +172,7 @@ def _md_to_entry(path: Path) -> FedEntry | None: return FedEntry( term=str(fm.get("title", path.stem)), - layer=str(fm.get("layer", "guild")), + layer=str(fm.get("layer", "org")), entity=str(fm.get("entity", "")), definition=str(fm.get("description", "")), synonyms=fm.get("synonyms") or [], diff --git a/src/lang2sql/core/identity.py b/src/lang2sql/core/identity.py index 6bd5da2..dbf4759 100644 --- a/src/lang2sql/core/identity.py +++ b/src/lang2sql/core/identity.py @@ -21,8 +21,8 @@ class ScopeLevel(str, Enum): """ THREAD = "thread" - CHANNEL = "channel" - GUILD = "guild" + CHANNEL = "team" + GUILD = "org" BUILTIN = "builtin" diff --git a/src/lang2sql/frontends/discord/bot.py b/src/lang2sql/frontends/discord/bot.py index 55af6d1..828c8d2 100644 --- a/src/lang2sql/frontends/discord/bot.py +++ b/src/lang2sql/frontends/discord/bot.py @@ -176,7 +176,7 @@ async def confirm_ingest( interaction: discord.Interaction, ref: str, accept: str = "all", - layer: str = "channel", + layer: str = "team", ) -> None: await self._run( interaction, @@ -219,7 +219,7 @@ async def term_custom( interaction: discord.Interaction, action: str = "", term: str = "", - layer: str = "member", + layer: str = "team", ) -> None: ident = to_identity(_interaction_context(interaction)) if action == "show": diff --git a/src/lang2sql/frontends/discord/commands.py b/src/lang2sql/frontends/discord/commands.py index dec7408..4ec8dba 100644 --- a/src/lang2sql/frontends/discord/commands.py +++ b/src/lang2sql/frontends/discord/commands.py @@ -179,7 +179,7 @@ async def term_custom( identity: Identity, term: str = "", definition: str = "", - layer: str = "member", + layer: str = "team", synonyms: str = "", inferred: bool = False, scan: bool = False, @@ -252,7 +252,7 @@ async def confirm_ingest( identity: Identity, ref: str, accept: str = "all", - layer: str = "channel", + layer: str = "team", ) -> OutboundMessage: """ingest_doc로 추출한 후보를 검토 후 시멘틱 레이어에 등록.""" ctx = await self._concierge.build_context(identity) @@ -270,24 +270,24 @@ async def help(self) -> OutboundMessage: **Lang2SQL 사용 가이드** **📊 질문하기** -봇을 멘션하거나 채널에서 자연어로 질문하세요. +봇을 멘션하거나 스레드에서 자연어로 질문하세요. > @Lang2SQL 이번 달 매출 상위 고객 10명 알려줘 -**🗄️ DB 연결** (관리자) +**🗄️ DB 연결** `/setup` — 안내에 따라 DB 접속 정보 입력 `/connect dsn:...` — DSN 직접 입력 **📖 비즈니스 용어 등록** `/ingest content:월매출은 SUM(orders.amount), 활성고객은 30일 내 로그인` → 후보 추출 후 아래 커맨드로 확정 -`/confirm_ingest ref:inline:xxxx accept:all layer:channel` +`/confirm_ingest ref:inline:xxxx accept:all layer:team` `/term_custom` — 용어 직접 등록 (위저드) `/term_custom action:show` — 등록된 용어 조회 `/org_setup org:회사명` — DB 스캔으로 용어 자동 추출 **🏷️ 용어 우선순위** -개인(member) > 채널(channel) > 전사(guild) +개인(user) > 팀(team) > 전사(org) 같은 채널 안에서 등록한 정의가 전사 정의보다 우선 적용됩니다. **🔧 기타** diff --git a/src/lang2sql/frontends/discord/term_wizard.py b/src/lang2sql/frontends/discord/term_wizard.py index eb4cdb8..0c2dac6 100644 --- a/src/lang2sql/frontends/discord/term_wizard.py +++ b/src/lang2sql/frontends/discord/term_wizard.py @@ -1,6 +1,6 @@ """term_wizard.py — /term_custom 등록 폼 (2단계 UI). -Step 1: Select — 전사(guild) / 채널·팀(channel) / 개인(member) 선택 +Step 1: Select — 전사(org) / 채널·팀(channel) / 개인(member) 선택 Step 2: Modal — 용어명·정의·동의어 입력 채널이 팀 경계 역할을 하므로 entity 직접 입력 불필요. @@ -23,17 +23,17 @@ _LAYER_OPTIONS = [ discord.SelectOption( label="전사 (Guild) — 회사 공통 정의", - value="guild", + value="org", description="모든 채널에서 기본값으로 사용", ), discord.SelectOption( label="채널 (팀) — 이 채널 전용 정의", - value="channel", + value="team", description="다른 채널과 충돌 없이 이 채널에서만 유효", ), discord.SelectOption( label="개인 — 나만 사용하는 정의", - value="member", + value="user", description="전사·채널 정의를 조용히 덮어씀", ), ] diff --git a/src/lang2sql/harness/system_prompt.py b/src/lang2sql/harness/system_prompt.py index aec656a..b5474f4 100644 --- a/src/lang2sql/harness/system_prompt.py +++ b/src/lang2sql/harness/system_prompt.py @@ -21,6 +21,10 @@ - Discover schema with explore_schema before guessing table or column names. - Prefer definitions from the semantic layer below over your own assumptions. - Answer concisely. Show only the final successful SQL you ran, not intermediate attempts. + +Clarification rule: +- If the user's query contains business terms (e.g. '활성고객', '월매출', '신규유저') that are NOT defined in the semantic layer and whose meaning is ambiguous, ask ONE concise clarifying question BEFORE running any SQL. Do not guess. +- After answering with SQL results, if you had to infer a term's meaning yourself, suggest the user save it: "이 정의를 저장하려면 `/term_custom`으로 등록해두세요. 다음 질문부터 자동 적용됩니다." """ diff --git a/src/lang2sql/tenancy/concierge.py b/src/lang2sql/tenancy/concierge.py index 700116f..ffbee91 100644 --- a/src/lang2sql/tenancy/concierge.py +++ b/src/lang2sql/tenancy/concierge.py @@ -150,12 +150,13 @@ def _default_llm() -> LLMPort: model = os.environ.get("LANG2SQL_LLM_MODEL", "default") # Local servers (vLLM, Ollama) speak OpenAI-compatible API; dummy key satisfies the header. api_key = os.environ.get("OPENAI_API_KEY") or "local" + timeout = float(os.environ.get("LANG2SQL_LLM_TIMEOUT", "120")) url = base_url.rstrip("/") if not url.endswith("/chat/completions"): if not url.endswith("/v1"): url = url + "/v1" url = url + "/chat/completions" - return OpenAILLM(model=model, api_key=api_key, base_url=url) + return OpenAILLM(model=model, api_key=api_key, base_url=url, timeout=timeout) if os.environ.get("OPENAI_API_KEY"): return OpenAILLM() return FakeLLM() diff --git a/src/lang2sql/tools/confirm_ingest.py b/src/lang2sql/tools/confirm_ingest.py index 05bc5f1..86955d4 100644 --- a/src/lang2sql/tools/confirm_ingest.py +++ b/src/lang2sql/tools/confirm_ingest.py @@ -58,9 +58,9 @@ def spec(self) -> ToolSpec: }, "layer": { "type": "string", - "enum": ["guild", "channel", "member"], + "enum": ["org", "team", "user"], "description": "scope to register under (default: channel)", - "default": "channel", + "default": "team", }, }, "required": ["ref"], @@ -70,7 +70,7 @@ def spec(self) -> ToolSpec: async def run(self, args: dict[str, Any], ctx: "HarnessContext") -> ToolResult: ref = (args.get("ref") or "").strip() accept = (args.get("accept") or "all").strip() - layer_raw = (args.get("layer") or "channel").strip() + layer_raw = (args.get("layer") or "team").strip() if not ref: return ToolResult(call_id="", content="'ref' is required.", is_error=True) @@ -113,8 +113,8 @@ async def run(self, args: dict[str, Any], ctx: "HarnessContext") -> ToolResult: entity = ( "" - if layer == "guild" - else (channel_id if layer == "channel" else ctx.identity.user_id) + if layer == "org" + else (channel_id if layer == "team" else ctx.identity.user_id) ) registered: list[str] = [] for cand in selected: diff --git a/src/lang2sql/tools/org_setup.py b/src/lang2sql/tools/org_setup.py index d981229..4ef8618 100644 --- a/src/lang2sql/tools/org_setup.py +++ b/src/lang2sql/tools/org_setup.py @@ -9,8 +9,8 @@ KV 저장: org:{org_lower} → {"name", "domain", "registered_at"} team:{team_lower}:{channel_id} → {"name", "domain", "registered_at"} (팀 등록 시) - cterm:{term_lower}:guild → FedEntry JSON (org 전용, guild 레이어) - cterm:{term_lower}:channel:{channel_id} → FedEntry JSON (team 등록 시, channel 레이어) + cterm:{term_lower}:org → FedEntry JSON (org 전용, guild 레이어) + cterm:{term_lower}:channel:{channel_id} → FedEntry JSON (team 등록 시, team 레이어) """ from __future__ import annotations @@ -79,7 +79,7 @@ def spec(self) -> ToolSpec: name="org_setup", description=( "조직(전사) 또는 팀(채널) 등록 및 DB 테이블 스캔으로 비즈니스 용어를 자동 추출한다. " - "org만 지정 시 guild 레이어(전사 공통), team 지정 시 channel 레이어(팀 전용)에 저장. " + "org만 지정 시 guild 레이어(전사 공통), team 지정 시 team 레이어(팀 전용)에 저장. " "DB 연결(/setup) 후 실행." ), parameters={ @@ -91,7 +91,7 @@ def spec(self) -> ToolSpec: }, "team": { "type": "string", - "description": "팀 이름 (예: 마케팅팀). 현재 채널에 팀 전용 용어를 channel 레이어에 저장. org 없이 단독 사용 가능.", + "description": "팀 이름 (예: 마케팅팀). 현재 채널에 팀 전용 용어를 team 레이어에 저장. org 없이 단독 사용 가능.", }, "clear": { "type": "boolean", @@ -118,18 +118,18 @@ async def run(self, args: dict[str, Any], ctx: "HarnessContext") -> ToolResult: scope = ctx.identity.kv_scope channel_id = ctx.identity.effective_channel_id - # team이 있으면 channel 레이어, org만 있으면 guild 레이어 + # team이 있으면 team 레이어, org만 있으면 guild 레이어 use_team = bool(team_name) - layer = "channel" if use_team else "guild" + layer = "team" if use_team else "org" - if layer == "guild" and not ctx.identity.is_admin: + if layer == "org" and not ctx.identity.is_admin: return ToolResult( call_id="", - content="❌ 전사(guild) 용어 등록·초기화는 관리자만 가능합니다.", + content="❌ 전사(org) 용어 등록·초기화는 관리자만 가능합니다.", is_error=True, ) - if layer == "channel" and not channel_id: + if layer == "team" and not channel_id: return ToolResult( call_id="", content="❌ 채널 컨텍스트 없이 팀(channel) 레이어에 등록할 수 없습니다.", @@ -161,7 +161,7 @@ async def run(self, args: dict[str, Any], ctx: "HarnessContext") -> ToolResult: ctx.store.kv_delete(scope, key) deleted += 1 ctx.store.kv_delete(scope, meta_key) - layer_label = "팀(채널)" if use_team else "전사(guild)" + layer_label = "팀(채널)" if use_team else "전사(org)" return ToolResult( call_id="", content=f"🗑️ **{display_name}** [{layer_label}] 자동 추출 용어 {deleted}개 초기화 완료 (수동 등록 용어 보존)", @@ -247,7 +247,7 @@ async def run(self, args: dict[str, Any], ctx: "HarnessContext") -> ToolResult: syn_str = f" (= {', '.join(synonyms)})" if synonyms else "" saved_terms.append(f"- **{term}**{syn_str}: {definition} 🤖") - layer_label = "팀(채널)" if use_team else "전사(guild)" + layer_label = "팀(채널)" if use_team else "전사(org)" domain_line = f"📌 도메인: {domain}\n\n" if domain else "" term_block = "\n".join(saved_terms) return ToolResult( diff --git a/src/lang2sql/tools/semantic_federation.py b/src/lang2sql/tools/semantic_federation.py index 8fa5b8a..7244457 100644 --- a/src/lang2sql/tools/semantic_federation.py +++ b/src/lang2sql/tools/semantic_federation.py @@ -1,14 +1,14 @@ -"""SemanticFederation — 채널(팀)/전사(guild)/개인(member) 계층 비즈니스 용어 사전. +"""SemanticFederation — 채널(팀)/전사(org)/개인(member) 계층 비즈니스 용어 사전. -계층 우선순위 (narrow → wide): member > channel > guild -- guild : 전사 공통 정의 (회사 전체, /org_setup이 자동 채움) -- channel: 이 채널/팀 전용 정의 (다른 채널과 충돌 없음 — 채널이 격리 경계) -- member : 개인 오버라이드 (조용히 상위 정의를 덮어씀) +계층 우선순위 (narrow → wide): user > team > org +- org : 전사 공통 정의 (회사 전체, /org_setup이 자동 채움) +- team : 이 채널/팀 전용 정의 (다른 채널과 충돌 없음 — 채널이 격리 경계) +- user : 개인 오버라이드 (조용히 상위 정의를 덮어씀) KV 키 구조 (모두 guild scope에 저장): - cterm:{term_lower}:guild → 전사 공통 - cterm:{term_lower}:channel:{ch_id} → 채널(팀) 전용 - cterm:{term_lower}:member:{user_id} → 개인 + cterm:{term_lower}:org → 전사 공통 + cterm:{term_lower}:team:{ch_id} → 채널(팀) 전용 + cterm:{term_lower}:user:{user_id} → 개인 """ from __future__ import annotations @@ -26,7 +26,7 @@ from ..harness.context import HarnessContext _KV_PREFIX = "cterm" -_LAYERS = ("guild", "channel", "member") +_LAYERS = ("org", "team", "user") def _validate_layer( @@ -36,10 +36,10 @@ def _validate_layer( layer = layer_raw.strip().lower() if layer not in _LAYERS: return layer, f"❌ layer는 {list(_LAYERS)} 중 하나여야 합니다." - if layer == "guild" and not is_admin: - return layer, "❌ guild 용어 등록·수정은 관리자만 가능합니다." - if layer == "channel" and not channel_id: - return layer, "❌ 채널 컨텍스트 없이 channel 레이어에 등록할 수 없습니다." + if layer == "org" and not is_admin: + return layer, "❌ org 용어 등록·수정은 관리자만 가능합니다." + if layer == "team" and not channel_id: + return layer, "❌ 채널 컨텍스트 없이 team 레이어에 등록할 수 없습니다." return layer, None @@ -60,7 +60,7 @@ def _validate_layer( def _kv_key(term: str, layer: str, entity: str) -> str: base = f"{_KV_PREFIX}:{term.strip().lower()}:{layer}" - if layer == "guild": + if layer == "org": return base return f"{base}:{entity.strip().lower()}" @@ -128,8 +128,8 @@ def spec(self) -> ToolSpec: name="term_custom", description=( "비즈니스 용어 사전 관리. " - "layer=guild(전사)/channel(이 채널·팀)/member(개인). " - "lookup은 narrow→wide: member > channel > guild. " + "layer=org(전사)/team(이 채널·팀)/user(개인). " + "lookup은 narrow→wide: user > team > org. " "list=true로 전체 조회. remove=true로 삭제." ), parameters={ @@ -145,8 +145,8 @@ def spec(self) -> ToolSpec: }, "layer": { "type": "string", - "enum": ["guild", "channel", "member"], - "description": "등록 범위. guild=전사 공통, channel=이 채널(팀), member=개인(기본값)", + "enum": ["org", "team", "user"], + "description": "등록 범위. org=전사 공통, channel=이 채널(팀), member=개인(기본값)", }, "synonyms": { "type": "string", @@ -216,11 +216,11 @@ async def run(self, args: dict[str, Any], ctx: "HarnessContext") -> ToolResult: # 존재하는 항목 모두 삭제 — guild layer는 admin만 삭제 가능 deleted_tags: list[str] = [] for lyr, ent in [ - ("guild", ""), - ("channel", channel_id), - ("member", user_id), + ("org", ""), + ("team", channel_id), + ("user", user_id), ]: - if lyr == "guild" and not ctx.identity.is_admin: + if lyr == "org" and not ctx.identity.is_admin: continue k = _kv_key(term, lyr, ent) if ctx.store.kv_get(scope, k) is not None: @@ -228,11 +228,11 @@ async def run(self, args: dict[str, Any], ctx: "HarnessContext") -> ToolResult: deleted_tags.append(_layer_tag(lyr, ent, user_id, channel_id)) if not deleted_tags: if not ctx.identity.is_admin: - guild_k = _kv_key(term, "guild", "") + guild_k = _kv_key(term, "org", "") if ctx.store.kv_get(scope, guild_k) is not None: return ToolResult( call_id="", - content=f"⚠️ **{term}** — 전사(guild) 항목이 존재하지만 관리자만 삭제할 수 있습니다.", + content=f"⚠️ **{term}** — 전사(org) 항목이 존재하지만 관리자만 삭제할 수 있습니다.", is_error=True, ) return ToolResult( @@ -252,14 +252,12 @@ async def run(self, args: dict[str, Any], ctx: "HarnessContext") -> ToolResult: ) layer, err = _validate_layer( - str(args.get("layer", "member")), channel_id, ctx.identity.is_admin + str(args.get("layer", "team")), channel_id, ctx.identity.is_admin ) if err: return ToolResult(call_id="", content=err, is_error=True) - entity = ( - "" if layer == "guild" else (user_id if layer == "member" else channel_id) - ) + entity = "" if layer == "org" else (user_id if layer == "user" else channel_id) key = _kv_key(term, layer, entity) definition = str(args.get("definition", "")).strip() @@ -313,9 +311,9 @@ async def run(self, args: dict[str, Any], ctx: "HarnessContext") -> ToolResult: def _layer_tag(layer: str, entity: str, user_id: str, channel_id: str) -> str: - if layer == "guild": + if layer == "org": return "전사" - if layer == "channel": + if layer == "team": return f"채널:{channel_id}" return f"개인:{user_id}" @@ -370,7 +368,7 @@ def _scan_schema(store: Any, scope: str) -> str: lines.append( "---\n위 컬럼을 바탕으로 모호 용어 정의를 추론하고 `term_custom` 툴로 `inferred=true` 등록하거나, " - "사용자에게 어느 범위(guild/channel/member)로 등록할지 확인하세요." + "사용자에게 어느 범위(org/team/user)로 등록할지 확인하세요." ) return "\n".join(lines) @@ -412,7 +410,7 @@ def _load_all(store: Any, scope: str) -> dict[str, list[FedEntry]]: ## Ambiguous Term Policy 사전에 없는 주관적/모호한 표현을 발견하면: 1. DB 스키마 기준으로 가장 합리적인 해석으로 SQL을 실행한다. -2. 실행 후 사용한 해석을 명시하고, kind(metric/rule/dimension/table)와 범위(guild/channel/member)를 사용자에게 묻는다. +2. 실행 후 사용한 해석을 명시하고, kind(metric/rule/dimension/table)와 범위(org/team/user)를 사용자에게 묻는다. 예: "'신규고객'을 'users.created_at >= NOW()-30일'로 해석했습니다. metric/rule/dimension/table 중 어느 종류이며, 어느 범위로 등록할까요?" 3. 사용자가 지정하면 term_custom 툴로 즉시 등록한다 (inferred=true). 4. inferred=true 엔트리가 이미 있으면 해당 정의를 우선 사용하되, 사용자에게 확정 여부를 확인한다.\ @@ -422,25 +420,34 @@ def _load_all(store: Any, scope: str) -> dict[str, list[FedEntry]]: def _resolve_entry( entries: list[FedEntry], channel_id: str, user_id: str ) -> FedEntry | None: - """narrow→wide lookup: member > channel > guild. 승리 FedEntry 반환.""" + """narrow→wide lookup: user > team > org. 승리 FedEntry 반환.""" for e in entries: - if e.layer == "member" and e.entity == user_id: + if e.layer == "user" and e.entity == user_id: return e for e in entries: - if e.layer == "channel" and e.entity == channel_id: + if e.layer == "team" and e.entity == channel_id: return e for e in entries: - if e.layer == "guild": + if e.layer == "org": return e return None def _tag_for(e: FedEntry) -> str: - if e.layer == "member": + if e.layer == "user": return f"개인:{e.entity}" - if e.layer == "channel": - return "채널" - return "전사" + if e.layer == "team": + return "이 채널" + return "전사 공통" + + +def _tag_for_full(e: FedEntry, current_channel_id: str) -> str: + """다른 레이어/채널 정의에 출처 레이블 반환.""" + if e.layer == "user": + return f"개인:{e.entity}" + if e.layer == "team": + return "이 채널" if e.entity == current_channel_id else "다른 채널" + return "전사 공통" def _fmt_entry(e: FedEntry, tag: str) -> str: @@ -454,7 +461,11 @@ def _fmt_entry(e: FedEntry, tag: str) -> str: def build_prompt_section(store: Any, scope: str, channel_id: str, user_id: str) -> str: - """kind별로 그룹화된 시멘틱 용어 섹션 + 모호 용어 지침 반환.""" + """kind별로 그룹화된 시멘틱 용어 섹션 + 모호 용어 지침 반환. + + 정의가 하나뿐인 용어는 그대로 주입. 여러 레이어에 정의가 있는 용어는 + 전부 레이블 붙여 주입 — LLM이 채널 컨텍스트를 보고 선택·질의 여부를 판단. + """ by_term = _load_all(store, scope) if not by_term: @@ -462,17 +473,31 @@ def build_prompt_section(store: Any, scope: str, channel_id: str, user_id: str) groups: dict[str, list[str]] = {} for term_lower in sorted(by_term): - e = _resolve_entry(by_term[term_lower], channel_id, user_id) - if e is None: + entries = by_term[term_lower] + winner = _resolve_entry(entries, channel_id, user_id) + if winner is None: continue - k = e.kind if e.kind in _KIND_SQL_HINT else "" - groups.setdefault(k, []).append(_fmt_entry(e, _tag_for(e))) + + others = [e for e in entries if e is not winner] + k = winner.kind if winner.kind in _KIND_SQL_HINT else "" + + if not others: + groups.setdefault(k, []).append(_fmt_entry(winner, _tag_for(winner))) + else: + # 여러 정의 존재 → 전부 주입, 이 채널 것을 ★로 표시 + lines = [f"- **{winner.term}** — 정의 여러 개 (채널 컨텍스트에 따라 판단):"] + lines.append(f" ★ {_tag_for(winner)}: {winner.definition}") + for e in others: + lines.append(f" · {_tag_for_full(e, channel_id)}: {e.definition}") + groups.setdefault(k, []).append("\n".join(lines)) if not groups: return _AMBIGUOUS_TERM_POLICY parts: list[str] = [ - "## Business Terminology\n(lookup 우선순위: 개인 > 채널(팀) > 전사)\n" + "## Business Terminology\n" + "(★ = 현재 채널/사용자 기준 정의. 여러 정의가 있을 때는 문맥상 명확하면 ★ 사용," + " 애매하면 사용자에게 선택 요청)\n" ] for kind in _KIND_ORDER + [""]: lines = groups.get(kind, []) diff --git a/tests/test_bench_demo.py b/tests/test_bench_demo.py index 5e3819c..e4aec8a 100644 --- a/tests/test_bench_demo.py +++ b/tests/test_bench_demo.py @@ -49,10 +49,10 @@ def test_demo_federation_resolves_distinct_definitions(): fin = demo._finance_identity() demo._define_term( - store, demo.GUILD, "active_user", "channel", demo.CH_MARKETING, "30d login" + store, demo.GUILD, "active_user", "team", demo.CH_MARKETING, "30d login" ) demo._define_term( - store, demo.GUILD, "active_user", "channel", demo.CH_FINANCE, "paid sub" + store, demo.GUILD, "active_user", "team", demo.CH_FINANCE, "paid sub" ) mkt_rendered = _render_effective(store, demo.GUILD, demo.CH_MARKETING, mkt.user_id) diff --git a/tests/test_confirm_ingest.py b/tests/test_confirm_ingest.py index 4eb8671..10477c8 100644 --- a/tests/test_confirm_ingest.py +++ b/tests/test_confirm_ingest.py @@ -135,19 +135,19 @@ def test_confirm_all_saves_fed_entries() -> None: tool = ConfirmIngest() result = asyncio.run( - tool.run({"ref": "defs.md", "accept": "all", "layer": "guild"}, ctx) + tool.run({"ref": "defs.md", "accept": "all", "layer": "org"}, ctx) ) assert not result.is_error assert "3 term(s)" in result.content for cand in _SAMPLE: - raw = store.kv_get(scope, _kv_key(cand.name, "guild", "")) + raw = store.kv_get(scope, _kv_key(cand.name, "org", "")) assert raw is not None entry = FedEntry.from_json(raw) assert entry.term == cand.name assert entry.kind == cand.kind.value - assert entry.layer == "guild" + assert entry.layer == "org" def test_confirm_by_index_saves_selected_only() -> None: @@ -157,14 +157,14 @@ def test_confirm_by_index_saves_selected_only() -> None: _seed_pending(store, scope, "defs.md", _SAMPLE) result = asyncio.run( - ConfirmIngest().run({"ref": "defs.md", "accept": "2", "layer": "guild"}, ctx) + ConfirmIngest().run({"ref": "defs.md", "accept": "2", "layer": "org"}, ctx) ) assert not result.is_error assert "1 term(s)" in result.content - assert store.kv_get(scope, _kv_key("exclude_cancelled", "guild", "")) is not None - assert store.kv_get(scope, _kv_key("monthly_revenue", "guild", "")) is None + assert store.kv_get(scope, _kv_key("exclude_cancelled", "org", "")) is not None + assert store.kv_get(scope, _kv_key("monthly_revenue", "org", "")) is None def test_confirm_channel_layer_uses_channel_entity() -> None: @@ -174,13 +174,11 @@ def test_confirm_channel_layer_uses_channel_entity() -> None: _seed_pending(store, scope, "defs.md", [_SAMPLE[0]]) asyncio.run( - ConfirmIngest().run( - {"ref": "defs.md", "accept": "all", "layer": "channel"}, ctx - ) + ConfirmIngest().run({"ref": "defs.md", "accept": "all", "layer": "team"}, ctx) ) ch_id = ctx.identity.effective_channel_id - raw = store.kv_get(scope, _kv_key("monthly_revenue", "channel", ch_id)) + raw = store.kv_get(scope, _kv_key("monthly_revenue", "team", ch_id)) assert raw is not None entry = FedEntry.from_json(raw) assert entry.entity == ch_id @@ -193,12 +191,10 @@ def test_confirm_member_layer_uses_user_id() -> None: _seed_pending(store, scope, "defs.md", [_SAMPLE[0]]) asyncio.run( - ConfirmIngest().run({"ref": "defs.md", "accept": "all", "layer": "member"}, ctx) + ConfirmIngest().run({"ref": "defs.md", "accept": "all", "layer": "user"}, ctx) ) - raw = store.kv_get( - scope, _kv_key("monthly_revenue", "member", ctx.identity.user_id) - ) + raw = store.kv_get(scope, _kv_key("monthly_revenue", "user", ctx.identity.user_id)) assert raw is not None @@ -209,7 +205,7 @@ def test_confirm_clears_pending_key_after_success() -> None: _seed_pending(store, scope, "defs.md", [_SAMPLE[0]]) asyncio.run( - ConfirmIngest().run({"ref": "defs.md", "accept": "all", "layer": "guild"}, ctx) + ConfirmIngest().run({"ref": "defs.md", "accept": "all", "layer": "org"}, ctx) ) assert store.kv_get(scope, f"{PENDING_PREFIX}:defs.md") is None @@ -222,7 +218,7 @@ def test_confirm_guild_layer_blocked_for_non_admin() -> None: _seed_pending(store, scope, "defs.md", [_SAMPLE[0]]) result = asyncio.run( - ConfirmIngest().run({"ref": "defs.md", "accept": "all", "layer": "guild"}, ctx) + ConfirmIngest().run({"ref": "defs.md", "accept": "all", "layer": "org"}, ctx) ) assert result.is_error assert "관리자" in result.content @@ -235,12 +231,10 @@ def test_confirm_channel_layer_blocked_without_channel_id() -> None: _seed_pending(store, scope, "defs.md", [_SAMPLE[0]]) result = asyncio.run( - ConfirmIngest().run( - {"ref": "defs.md", "accept": "all", "layer": "channel"}, ctx - ) + ConfirmIngest().run({"ref": "defs.md", "accept": "all", "layer": "team"}, ctx) ) assert result.is_error - assert "channel" in result.content + assert "team" in result.content def test_confirm_invalid_layer_returns_error() -> None: @@ -250,7 +244,9 @@ def test_confirm_invalid_layer_returns_error() -> None: _seed_pending(store, scope, "defs.md", [_SAMPLE[0]]) result = asyncio.run( - ConfirmIngest().run({"ref": "defs.md", "accept": "all", "layer": "team"}, ctx) + ConfirmIngest().run( + {"ref": "defs.md", "accept": "all", "layer": "department"}, ctx + ) ) assert result.is_error assert "layer" in result.content @@ -299,7 +295,7 @@ def test_confirm_with_okf_bundle_exports_files() -> None: result = asyncio.run( ConfirmIngest().run( - {"ref": "defs.md", "accept": "all", "layer": "guild"}, ctx + {"ref": "defs.md", "accept": "all", "layer": "org"}, ctx ) ) assert not result.is_error diff --git a/tests/test_discord.py b/tests/test_discord.py index 1436f93..edfcc1d 100644 --- a/tests/test_discord.py +++ b/tests/test_discord.py @@ -115,7 +115,7 @@ async def scenario() -> tuple[str, str]: ident, term="active_user", definition="logged in within 30 days", - layer="channel", + layer="team", ) shown = await handlers.term_custom(ident, list_all=True) return defined.text, shown.text @@ -147,7 +147,7 @@ def test_term_custom_is_scope_isolated() -> None: async def scenario() -> str: await handlers.term_custom( - marketing, term="active_user", definition="30d login", layer="channel" + marketing, term="active_user", definition="30d login", layer="team" ) return (await handlers.term_custom(product, list_all=True)).text diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 72d3f73..1305fa7 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -17,7 +17,7 @@ def test_fed_entry_from_json_coerces_string_synonyms() -> None: raw_json = json.dumps( { "term": "active_user", - "layer": "guild", + "layer": "org", "entity": "", "definition": "30d login", "synonyms": "활성유저, active", @@ -35,7 +35,7 @@ def test_fed_entry_from_json_handles_null_synonyms() -> None: raw_json = json.dumps( { "term": "revenue", - "layer": "guild", + "layer": "org", "entity": "", "definition": "gross revenue", "synonyms": None, @@ -54,14 +54,14 @@ def test_render_effective_string_synonyms_in_kv_does_not_character_join() -> Non bad_json = json.dumps( { "term": "active_user", - "layer": "guild", + "layer": "org", "entity": "", "definition": "30d login", "synonyms": "활성유저, active", "inferred": False, } ) - store.kv_set(scope, _kv_key("active_user", "guild", ""), bad_json) + store.kv_set(scope, _kv_key("active_user", "org", ""), bad_json) rendered = _render_effective(store, scope, "", "u1") assert "active_user" in rendered assert "활, 성" not in rendered # character-join would produce this @@ -107,7 +107,7 @@ def test_term_custom_remove_emits_audit_event() -> None: # Write then remove asyncio.run( SemanticFederationTool().run( - {"term": "active_user", "definition": "30d login", "layer": "guild"}, ctx + {"term": "active_user", "definition": "30d login", "layer": "org"}, ctx ) ) asyncio.run( @@ -136,7 +136,7 @@ def test_guild_write_requires_admin() -> None: result = asyncio.run( SemanticFederationTool().run( - {"term": "revenue", "definition": "gross revenue", "layer": "guild"}, ctx + {"term": "revenue", "definition": "gross revenue", "layer": "org"}, ctx ) ) assert result.is_error @@ -159,7 +159,7 @@ def test_guild_remove_non_admin_skips_guild_keeps_own_entry() -> None: ) asyncio.run( SemanticFederationTool().run( - {"term": "revenue", "definition": "gross revenue", "layer": "guild"}, + {"term": "revenue", "definition": "gross revenue", "layer": "org"}, admin_ctx, ) ) @@ -172,7 +172,7 @@ def test_guild_remove_non_admin_skips_guild_keeps_own_entry() -> None: ) asyncio.run( SemanticFederationTool().run( - {"term": "revenue", "definition": "my override", "layer": "member"}, + {"term": "revenue", "definition": "my override", "layer": "user"}, member_ctx, ) ) @@ -183,8 +183,8 @@ def test_guild_remove_non_admin_skips_guild_keeps_own_entry() -> None: ) scope = "g1" - assert member_ctx.store.kv_get(scope, _kv_key("revenue", "guild", "")) is not None - assert member_ctx.store.kv_get(scope, _kv_key("revenue", "member", "u1")) is None + assert member_ctx.store.kv_get(scope, _kv_key("revenue", "org", "")) is not None + assert member_ctx.store.kv_get(scope, _kv_key("revenue", "user", "u1")) is None def test_parse_synonyms_strips_list_items() -> None: @@ -216,9 +216,9 @@ def test_channel_layer_term_visible_from_thread_context() -> None: scope = "g1" store.kv_set( scope, - _kv_key("active_user", "channel", "c1"), + _kv_key("active_user", "team", "c1"), FedEntry( - term="active_user", layer="channel", entity="c1", definition="30d login" + term="active_user", layer="team", entity="c1", definition="30d login" ).to_json(), ) diff --git a/tests/test_harness_loop.py b/tests/test_harness_loop.py index db73788..5272d04 100644 --- a/tests/test_harness_loop.py +++ b/tests/test_harness_loop.py @@ -53,4 +53,4 @@ def test_tool_call_id_is_stamped(): def test_scope_chain_orders_narrow_to_wide(): ident = Identity(user_id="u", guild_id="g", channel_id="c", thread_id="t") levels = [s.level.value for s in ident.scope_chain()] - assert levels == ["thread", "channel", "guild", "builtin"] + assert levels == ["thread", "team", "org", "builtin"] diff --git a/tests/test_integration.py b/tests/test_integration.py index 79bb110..ca5dcd1 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -64,7 +64,7 @@ def test_term_custom_is_scope_local(): ident, ctx = _ctx() asyncio.run( SemanticFederationTool().run( - {"term": "active_user", "definition": "30d login", "layer": "channel"}, ctx + {"term": "active_user", "definition": "30d login", "layer": "team"}, ctx ) ) rendered = _render_effective( @@ -82,7 +82,7 @@ def test_term_custom_emits_audit_event(): ctx = asyncio.run(concierge.build_context(ident)) asyncio.run( SemanticFederationTool().run( - {"term": "revenue", "definition": "gross revenue", "layer": "guild"}, ctx + {"term": "revenue", "definition": "gross revenue", "layer": "org"}, ctx ) ) events = asyncio.run(ctx.audit.query(ident.user_id)) diff --git a/tests/test_okf_bundle.py b/tests/test_okf_bundle.py index 99c2ec7..68be6a5 100644 --- a/tests/test_okf_bundle.py +++ b/tests/test_okf_bundle.py @@ -23,7 +23,7 @@ def _populate(store: SqliteStore, scope: str, entries: list[FedEntry]) -> None: def test_entry_to_md_contains_required_okf_fields() -> None: entry = FedEntry( term="활성고객", - layer="guild", + layer="org", entity="", definition="30일 내 로그인한 users", kind="metric", @@ -34,13 +34,13 @@ def test_entry_to_md_contains_required_okf_fields() -> None: assert "type: Metric" in md assert "title: 활성고객" in md assert "description:" in md - assert "layer: guild" in md + assert "layer: org" in md def test_md_to_entry_roundtrip() -> None: entry = FedEntry( term="순매출", - layer="channel", + layer="team", entity="mkt-123", definition="환불 제외 매출", synonyms=["net revenue"], @@ -59,7 +59,7 @@ def test_md_to_entry_roundtrip() -> None: assert restored is not None assert restored.term == "순매출" assert restored.kind == "metric" - assert restored.layer == "channel" + assert restored.layer == "team" assert restored.entity == "mkt-123" assert restored.applies_to == "orders" assert restored.tags == ["finance"] @@ -68,7 +68,7 @@ def test_md_to_entry_roundtrip() -> None: def test_md_to_entry_unknown_type_becomes_empty_kind() -> None: - md = "---\ntype: Playbook\ntitle: foo\ndescription: bar\nlayer: guild\nentity: ''\ninferred: false\n---\n\nbar\n" + md = "---\ntype: Playbook\ntitle: foo\ndescription: bar\nlayer: org\nentity: ''\ninferred: false\n---\n\nbar\n" with tempfile.NamedTemporaryFile( suffix=".md", mode="w", delete=False, encoding="utf-8" ) as f: @@ -99,11 +99,11 @@ def test_export_creates_kind_based_folders() -> None: store = SqliteStore() scope = "g1" entries = [ - FedEntry("활성고객", "guild", "", "30일 로그인", kind="metric"), - FedEntry("orders", "guild", "", "주문 테이블", kind="table"), - FedEntry("환불제외", "guild", "", "status != refunded", kind="rule"), - FedEntry("고객등급", "guild", "", "users.tier", kind="dimension"), - FedEntry("기타용어", "guild", "", "정의 없음", kind=""), + FedEntry("활성고객", "org", "", "30일 로그인", kind="metric"), + FedEntry("orders", "org", "", "주문 테이블", kind="table"), + FedEntry("환불제외", "org", "", "status != refunded", kind="rule"), + FedEntry("고객등급", "org", "", "users.tier", kind="dimension"), + FedEntry("기타용어", "org", "", "정의 없음", kind=""), ] _populate(store, scope, entries) @@ -112,11 +112,11 @@ def test_export_creates_kind_based_folders() -> None: count = bundle.export(store, scope) assert count == 5 - assert (Path(tmp) / "guild" / "metrics" / "활성고객.md").exists() - assert (Path(tmp) / "guild" / "tables" / "orders.md").exists() - assert (Path(tmp) / "guild" / "rules" / "환불제외.md").exists() - assert (Path(tmp) / "guild" / "dimensions" / "고객등급.md").exists() - assert (Path(tmp) / "guild" / "misc" / "기타용어.md").exists() + assert (Path(tmp) / "org" / "metrics" / "활성고객.md").exists() + assert (Path(tmp) / "org" / "tables" / "orders.md").exists() + assert (Path(tmp) / "org" / "rules" / "환불제외.md").exists() + assert (Path(tmp) / "org" / "dimensions" / "고객등급.md").exists() + assert (Path(tmp) / "org" / "misc" / "기타용어.md").exists() def test_export_separates_scopes() -> None: @@ -126,8 +126,8 @@ def test_export_separates_scopes() -> None: store, scope, [ - FedEntry("활성고객", "guild", "", "30일 로그인", kind="metric"), - FedEntry("활성고객", "channel", "mkt", "7일 구매", kind="metric"), + FedEntry("활성고객", "org", "", "30일 로그인", kind="metric"), + FedEntry("활성고객", "team", "mkt", "7일 구매", kind="metric"), ], ) @@ -135,8 +135,8 @@ def test_export_separates_scopes() -> None: bundle = OkfBundle(tmp) bundle.export(store, scope) - assert (Path(tmp) / "guild" / "metrics" / "활성고객.md").exists() - assert (Path(tmp) / "channel:mkt" / "metrics" / "활성고객.md").exists() + assert (Path(tmp) / "org" / "metrics" / "활성고객.md").exists() + assert (Path(tmp) / "team:mkt" / "metrics" / "활성고객.md").exists() def test_import_restores_kv_from_files() -> None: @@ -144,7 +144,7 @@ def test_import_restores_kv_from_files() -> None: scope = "g1" original = FedEntry( "순매출", - "guild", + "org", "", "환불 제외 매출", kind="metric", @@ -162,7 +162,7 @@ def test_import_restores_kv_from_files() -> None: count = bundle.import_(empty_store, scope) assert count == 1 - key = _kv_key("순매출", "guild", "") + key = _kv_key("순매출", "org", "") raw = empty_store.kv_get(scope, key) assert raw is not None restored = FedEntry.from_json(raw) @@ -173,7 +173,7 @@ def test_import_restores_kv_from_files() -> None: def test_import_skips_reserved_files() -> None: with tempfile.TemporaryDirectory() as tmp: - guild_dir = Path(tmp) / "guild" + guild_dir = Path(tmp) / "org" guild_dir.mkdir() (guild_dir / "index.md").write_text("# index", encoding="utf-8") (guild_dir / "log.md").write_text("# log", encoding="utf-8") @@ -189,7 +189,7 @@ def test_full_roundtrip_preserves_all_fields() -> None: scope = "g1" original = FedEntry( term="월매출", - layer="member", + layer="user", entity="user-99", definition="당월 발생 매출 합계", synonyms=["monthly revenue"], @@ -206,13 +206,13 @@ def test_full_roundtrip_preserves_all_fields() -> None: restored_store = SqliteStore() bundle.import_(restored_store, scope) - key = _kv_key("월매출", "member", "user-99") + key = _kv_key("월매출", "user", "user-99") raw = restored_store.kv_get(scope, key) assert raw is not None restored = FedEntry.from_json(raw) assert restored.term == "월매출" - assert restored.layer == "member" + assert restored.layer == "user" assert restored.entity == "user-99" assert restored.kind == "metric" assert restored.applies_to == "orders.amount" diff --git a/tests/test_persistence.py b/tests/test_persistence.py index f101187..bf07787 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -25,9 +25,9 @@ def test_kv_federation_survives_new_instance(tmp_path) -> None: writer = SqliteStore(db) entry = FedEntry( - term="revenue", layer="guild", entity="", definition="sum of order totals" + term="revenue", layer="org", entity="", definition="sum of order totals" ) - writer.kv_set(scope, _kv_key("revenue", "guild", ""), entry.to_json()) + writer.kv_set(scope, _kv_key("revenue", "org", ""), entry.to_json()) writer.close() reader = SqliteStore(db) @@ -44,13 +44,13 @@ def test_kv_channel_overrides_guild_persisted(tmp_path) -> None: store = SqliteStore(db) store.kv_set( scope, - _kv_key("active_user", "guild", ""), - FedEntry("active_user", "guild", "", "guild def").to_json(), + _kv_key("active_user", "org", ""), + FedEntry("active_user", "org", "", "guild def").to_json(), ) store.kv_set( scope, - _kv_key("active_user", "channel", "c1"), - FedEntry("active_user", "channel", "c1", "channel def").to_json(), + _kv_key("active_user", "team", "c1"), + FedEntry("active_user", "team", "c1", "channel def").to_json(), ) store.close() diff --git a/tests/test_semantic.py b/tests/test_semantic.py index f6e7a1f..98421a1 100644 --- a/tests/test_semantic.py +++ b/tests/test_semantic.py @@ -32,13 +32,13 @@ def test_channel_overrides_guild() -> None: scope = "g1" store.kv_set( scope, - _kv_key("active_user", "guild", ""), - FedEntry("active_user", "guild", "", "30d login").to_json(), + _kv_key("active_user", "org", ""), + FedEntry("active_user", "org", "", "30d login").to_json(), ) store.kv_set( scope, - _kv_key("active_user", "channel", "c1"), - FedEntry("active_user", "channel", "c1", "7d core action").to_json(), + _kv_key("active_user", "team", "c1"), + FedEntry("active_user", "team", "c1", "7d core action").to_json(), ) rendered = _render_effective(store, scope, "c1", "u1") @@ -51,8 +51,8 @@ def test_guild_fills_gap_when_channel_missing() -> None: scope = "g1" store.kv_set( scope, - _kv_key("revenue", "guild", ""), - FedEntry("revenue", "guild", "", "net revenue").to_json(), + _kv_key("revenue", "org", ""), + FedEntry("revenue", "org", "", "net revenue").to_json(), ) rendered = _render_effective(store, scope, "c1", "u1") @@ -64,18 +64,18 @@ def test_member_overrides_channel_and_guild() -> None: scope = "g1" store.kv_set( scope, - _kv_key("active_user", "guild", ""), - FedEntry("active_user", "guild", "", "guild def").to_json(), + _kv_key("active_user", "org", ""), + FedEntry("active_user", "org", "", "guild def").to_json(), ) store.kv_set( scope, - _kv_key("active_user", "channel", "c1"), - FedEntry("active_user", "channel", "c1", "channel def").to_json(), + _kv_key("active_user", "team", "c1"), + FedEntry("active_user", "team", "c1", "channel def").to_json(), ) store.kv_set( scope, - _kv_key("active_user", "member", "u1"), - FedEntry("active_user", "member", "u1", "member def").to_json(), + _kv_key("active_user", "user", "u1"), + FedEntry("active_user", "user", "u1", "member def").to_json(), ) rendered = _render_effective(store, scope, "c1", "u1") @@ -89,13 +89,13 @@ def test_two_channels_isolated() -> None: scope = "g1" store.kv_set( scope, - _kv_key("active_user", "channel", "mkt"), - FedEntry("active_user", "channel", "mkt", "30d login").to_json(), + _kv_key("active_user", "team", "mkt"), + FedEntry("active_user", "team", "mkt", "30d login").to_json(), ) store.kv_set( scope, - _kv_key("active_user", "channel", "fin"), - FedEntry("active_user", "channel", "fin", "paid subscriber").to_json(), + _kv_key("active_user", "team", "fin"), + FedEntry("active_user", "team", "fin", "paid subscriber").to_json(), ) mkt = _render_effective(store, scope, "mkt", "u1") @@ -121,7 +121,7 @@ def test_build_prompt_section_includes_ambiguous_term_policy() -> None: def test_fed_entry_kind_applies_to_tags_roundtrip() -> None: entry = FedEntry( term="활성고객", - layer="guild", + layer="org", entity="", definition="30일 내 로그인한 users", kind="metric", @@ -141,7 +141,7 @@ def test_fed_entry_backward_compat_missing_new_fields() -> None: old_json = json.dumps( { "term": "revenue", - "layer": "guild", + "layer": "org", "entity": "", "definition": "net revenue", "synonyms": [], @@ -159,7 +159,7 @@ def test_fmt_entry_shows_kind_badge() -> None: entry = FedEntry( term="활성고객", - layer="guild", + layer="org", entity="", definition="30일 내 로그인", kind="metric", @@ -184,9 +184,9 @@ def test_prompt_section_groups_by_kind() -> None: store, "g1", [ - FedEntry("월매출", "guild", "", "SUM(orders.amount)", kind="metric"), - FedEntry("환불제외", "guild", "", "status != refunded", kind="rule"), - FedEntry("고객등급", "guild", "", "users.tier", kind="dimension"), + FedEntry("월매출", "org", "", "SUM(orders.amount)", kind="metric"), + FedEntry("환불제외", "org", "", "status != refunded", kind="rule"), + FedEntry("고객등급", "org", "", "users.tier", kind="dimension"), ], ) section = build_prompt_section(store, "g1", "c1", "u1") @@ -204,7 +204,7 @@ def test_prompt_section_kind_headers_contain_sql_hint() -> None: _seed( store, "g1", - [FedEntry("월매출", "guild", "", "SUM(orders.amount)", kind="metric")], + [FedEntry("월매출", "org", "", "SUM(orders.amount)", kind="metric")], ) section = build_prompt_section(store, "g1", "c1", "u1") @@ -214,7 +214,7 @@ def test_prompt_section_kind_headers_contain_sql_hint() -> None: def test_prompt_section_unknown_kind_goes_to_기타() -> None: store = SqliteStore() - _seed(store, "g1", [FedEntry("알수없음", "guild", "", "정의 없음", kind="")]) + _seed(store, "g1", [FedEntry("알수없음", "org", "", "정의 없음", kind="")]) section = build_prompt_section(store, "g1", "c1", "u1") assert "### 기타" in section @@ -226,7 +226,7 @@ def test_prompt_section_skips_empty_kind_groups() -> None: _seed( store, "g1", - [FedEntry("월매출", "guild", "", "SUM(orders.amount)", kind="metric")], + [FedEntry("월매출", "org", "", "SUM(orders.amount)", kind="metric")], ) section = build_prompt_section(store, "g1", "c1", "u1") @@ -244,9 +244,9 @@ def test_resolve_entry_member_wins_over_channel() -> None: from lang2sql.tools.semantic_federation import _resolve_entry entries = [ - FedEntry("t", "guild", "", "guild-def"), - FedEntry("t", "channel", "c1", "channel-def"), - FedEntry("t", "member", "u1", "member-def"), + FedEntry("t", "org", "", "guild-def"), + FedEntry("t", "team", "c1", "channel-def"), + FedEntry("t", "user", "u1", "member-def"), ] result = _resolve_entry(entries, "c1", "u1") assert result is not None @@ -257,8 +257,8 @@ def test_resolve_entry_channel_wins_over_guild() -> None: from lang2sql.tools.semantic_federation import _resolve_entry entries = [ - FedEntry("t", "guild", "", "guild-def"), - FedEntry("t", "channel", "c1", "channel-def"), + FedEntry("t", "org", "", "guild-def"), + FedEntry("t", "team", "c1", "channel-def"), ] result = _resolve_entry(entries, "c1", "u1") assert result is not None @@ -268,5 +268,5 @@ def test_resolve_entry_channel_wins_over_guild() -> None: def test_resolve_entry_returns_none_when_no_match() -> None: from lang2sql.tools.semantic_federation import _resolve_entry - entries = [FedEntry("t", "channel", "other-channel", "def")] + entries = [FedEntry("t", "team", "other-channel", "def")] assert _resolve_entry(entries, "c1", "u1") is None