Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies = [
"discord.py>=2.3,<3.0", # Phase 1 frontend transport
"cryptography>=42.0", # EncryptedSecrets at-rest encryption
"sqlalchemy>=2.0", # generic DB explorer (one adapter, many engines)
"PyYAML>=6.0", # OKF bundle frontmatter serialization
]

[project.optional-dependencies]
Expand Down
183 changes: 183 additions & 0 deletions src/lang2sql/adapters/storage/okf_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""OkfBundle — OKF(Open Knowledge Format) 기반 지식 번들 어댑터.

KV 캐시(SqliteStore)와 양방향 sync:
- export: KV → 스코프별 .md 파일 (Git 영속, 사람이 읽을 수 있는 형태)
- import_: .md 파일 → KV (번들에서 런타임 캐시 복원)

디렉토리 구조 (OKF SPEC §3):
<base_dir>/
├── guild/
│ ├── index.md
│ ├── metrics/active_user.md
│ ├── tables/orders.md
│ ├── rules/exclude_cancelled.md
│ ├── dimensions/customer_tier.md
│ └── misc/<term>.md # kind 미지정
└── channel:<ch_id>/
├── index.md
└── metrics/active_user.md

각 .md 파일 형식 (OKF SPEC §4):
---
type: Metric
title: active_user
description: "30일 내 로그인한 users"
tags: [growth, retention]
applies_to: users
synonyms: [활성화고객]
layer: guild
entity: ""
inferred: false
timestamp: 2026-07-18T...
---

(markdown body — definition 반복 또는 추가 설명)
"""

from __future__ import annotations

import re
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING

import yaml

from ...tools.semantic_federation import (
FedEntry,
_KV_PREFIX,
_kv_key,
)

if TYPE_CHECKING:
from .sqlite_store import SqliteStore

_KIND_FOLDER: dict[str, str] = {
"metric": "metrics",
"table": "tables",
"rule": "rules",
"dimension": "dimensions",
}
_RESERVED = {"index.md", "log.md"}


class OkfBundle:
"""KV ↔ OKF .md 파일 양방향 sync 어댑터."""

def __init__(self, base_dir: str) -> None:
self.base_dir = Path(base_dir)

# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------

def export(self, store: "SqliteStore", kv_scope: str) -> int:
"""KV에서 모든 FedEntry를 읽어 .md 파일로 저장. 저장된 파일 수 반환."""
raw = store.kv_list_prefix(kv_scope, _KV_PREFIX + ":")
count = 0
for _key, val in raw:
try:
entry = FedEntry.from_json(val)
path = self._concept_path(entry)
except (ValueError, KeyError):
continue
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_entry_to_md(entry), encoding="utf-8")
count += 1
return count

def import_(self, store: "SqliteStore", kv_scope: str) -> int:
"""번들의 .md 파일을 읽어 KV로 복원. 로드된 항목 수 반환."""
count = 0
for md_file in self.base_dir.rglob("*.md"):
if md_file.name in _RESERVED:
continue
entry = _md_to_entry(md_file)
if entry is None:
continue
key = _kv_key(entry.term, entry.layer, entry.entity)
store.kv_set(kv_scope, key, entry.to_json())
count += 1
return count

# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------

def _scope_dir(self, entry: FedEntry) -> Path:
label = "guild" if entry.layer == "guild" else f"{entry.layer}:{entry.entity}"
return self.base_dir / label

def _concept_path(self, entry: FedEntry) -> Path:
folder = _KIND_FOLDER.get(entry.kind, "misc")
slug = (
re.sub(r"[/\\]", "-", entry.term.strip())
.lower()
.replace(" ", "_")
.replace(":", "-")
)
path = self._scope_dir(entry) / folder / f"{slug}.md"
if not path.resolve().is_relative_to(self.base_dir.resolve()):
raise ValueError(f"unsafe path derived from term: {entry.term!r}")
return path


# ------------------------------------------------------------------
# Serialization helpers (module-level for testability)
# ------------------------------------------------------------------


def _entry_to_md(entry: FedEntry) -> str:
"""FedEntry → OKF .md 문자열 (SPEC §4.1)."""
fm: dict = {
"type": entry.kind.capitalize() if entry.kind else "Concept",
"title": entry.term,
"description": entry.definition,
}
if entry.tags:
fm["tags"] = entry.tags
if entry.applies_to:
fm["applies_to"] = entry.applies_to
if entry.synonyms:
fm["synonyms"] = entry.synonyms
fm["layer"] = entry.layer
fm["entity"] = entry.entity
fm["inferred"] = entry.inferred
fm["timestamp"] = datetime.now(timezone.utc).isoformat()

yaml_block = yaml.dump(
fm, allow_unicode=True, default_flow_style=False, sort_keys=False
)
return f"---\n{yaml_block}---\n\n{entry.definition}\n"


def _md_to_entry(path: Path) -> FedEntry | None:
"""OKF .md 파일 → FedEntry. 파싱 실패 시 None 반환."""
text = path.read_text(encoding="utf-8")
if not text.startswith("---\n"):
return None
rest = text[4:] # skip opening "---\n"
parts = rest.split("\n---\n", 1)
if len(parts) < 2:
return None
try:
fm = yaml.safe_load(parts[0])
except yaml.YAMLError:
return None
if not isinstance(fm, dict):
return None

raw_kind = str(fm.get("type", "")).lower()
kind = raw_kind if raw_kind in _KIND_FOLDER else ""

return FedEntry(
term=str(fm.get("title", path.stem)),
layer=str(fm.get("layer", "guild")),
entity=str(fm.get("entity", "")),
definition=str(fm.get("description", "")),
synonyms=fm.get("synonyms") or [],
inferred=bool(fm.get("inferred", False)),
kind=kind,
applies_to=str(fm.get("applies_to", "")),
tags=fm.get("tags") or [],
)
35 changes: 32 additions & 3 deletions src/lang2sql/frontends/discord/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,41 @@ async def connect(interaction: discord.Interaction, dsn: str) -> None:
handlers.connect(to_identity(_interaction_context(interaction)), dsn),
)

@tree.command(name="ingest", description="Propose definitions from a document")
async def ingest(interaction: discord.Interaction, ref: str) -> None:
@tree.command(
name="ingest",
description="문서에서 비즈니스 용어 후보 추출 (ref: 파일명, content: 텍스트 직접 입력)",
)
async def ingest(
interaction: discord.Interaction,
ref: str = "",
content: str = "",
) -> None:
await self._run(
interaction,
handlers.ingest(
to_identity(_interaction_context(interaction)), ref=ref
to_identity(_interaction_context(interaction)),
ref=ref or None,
content=content or None,
),
)

@tree.command(
name="confirm_ingest",
description="ingest로 추출한 후보를 시멘틱 레이어에 등록",
)
async def confirm_ingest(
interaction: discord.Interaction,
ref: str,
accept: str = "all",
layer: str = "channel",
) -> None:
await self._run(
interaction,
handlers.confirm_ingest(
to_identity(_interaction_context(interaction)),
ref=ref,
accept=accept,
layer=layer,
),
)

Expand Down
17 changes: 17 additions & 0 deletions src/lang2sql/frontends/discord/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,23 @@ async def ingest(
result = await ctx.tools.dispatch("ingest_doc", args, ctx, "cmd:ingest")
return OutboundMessage(text=result.content)

async def confirm_ingest(
self,
identity: Identity,
ref: str,
accept: str = "all",
layer: str = "channel",
) -> OutboundMessage:
"""ingest_doc로 추출한 후보를 검토 후 시멘틱 레이어에 등록."""
ctx = await self._concierge.build_context(identity)
result = await ctx.tools.dispatch(
"confirm_ingest",
{"ref": ref, "accept": accept, "layer": layer},
ctx,
"cmd:confirm_ingest",
)
return OutboundMessage(text=result.content)


def _fmt_ts(ts: float) -> str:
"""Format an epoch timestamp as a short UTC string for audit listings."""
Expand Down
1 change: 1 addition & 0 deletions src/lang2sql/harness/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ class HarnessContext:
safety: SafetyPipelinePort | None = None
audit: AuditPort | None = None
store: SqliteStore | None = None
okf_bundle_dir: str | None = None
max_turns: int = 8
1 change: 1 addition & 0 deletions src/lang2sql/tenancy/concierge.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ async def build_context(
safety=self._safety,
audit=self._audit,
store=self._store,
okf_bundle_dir=os.getenv("OKF_BUNDLE_DIR"),
max_turns=self._max_turns,
)

Expand Down
3 changes: 3 additions & 0 deletions src/lang2sql/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ..ingestion.pipeline import IngestionPipeline
from ..memory.service import MemoryService
from .ask_user import AskUser
from .confirm_ingest import ConfirmIngest
from .enrich_schema import EnrichSchema
from .explore_schema import ExploreSchema
from .ingest_doc import IngestDoc
Expand All @@ -31,6 +32,7 @@
"Remember",
"AskUser",
"IngestDoc",
"ConfirmIngest",
]


Expand All @@ -51,4 +53,5 @@ def build_default_tools(
AskUser(),
Remember(memory),
IngestDoc(ingestion, source, extractor),
ConfirmIngest(),
]
Loading
Loading