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
11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,14 @@ jobs:
# isn't part of this PR's diff).
run: cd sdk/python && pip install -e ".[dev,connectors]" # NOSONAR python:S4830 python:S5445
- name: Install connector/pack deps
# google-auth is only needed by the Google Sheet connector, so it lives in
# that connector's requirements rather than the SDK's extras.
run: pip install -r connectors/google_sheet/requirements.txt # NOSONAR python:S4830 python:S5445
# Deps that belong to a specific connector/pack rather than the SDK's
# extras — google-auth for the Google Sheet connector, pyyaml for the
# EcomOps knowledge base. Every requirements.txt under connectors/ and
# packs/ is installed, so adding a new one needs no CI change.
# Kept on one line so the NOSONAR marker lands on the same line as the
# pip invocation — on a `run: |` block it sits on the YAML key instead
# and suppresses nothing.
run: find connectors packs -name requirements.txt -print0 | xargs -0 -I{} pip install -r {} # NOSONAR python:S4830 python:S5445
- name: Lint
# Same ephemeral-CI-runner rationale as the installs above.
run: pip install ruff && ruff check sdk/python connectors packs # NOSONAR python:S4830 python:S5445
Expand Down
49 changes: 47 additions & 2 deletions packs/ecomops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pytest packs/ecomops/tests
| `intents.py` | Phân loại ý định: `RuleBasedIntentClassifier`, `LLMIntentClassifier`, `HybridIntentClassifier` |
| `extraction.py` | Trích mã đơn hàng & số điện thoại từ tin nhắn |
| `prompts.py` | Thư viện prompt tiếng Việt theo từng ý định |
| `knowledge.py` | Lấy chính sách shop (phí ship, đổi trả…) cho model soạn tin |
| `guardrails.py` | Chặn câu trả lời rủi ro trước khi gửi cho khách |
| `workflow.py` | `EcomOpsWorkflow` — pipeline nối tất cả lại |

Expand Down Expand Up @@ -98,7 +99,45 @@ asyncio.run(main())
Khi nào chuyển nhân viên: khiếu nại (`complaint`), độ tin cậy thấp, guardrail chặn,
hoặc soạn tin lỗi.

## 4. Demo end-to-end (Zalo + Google Sheet)
## 4. Knowledge Base (bắt buộc nếu muốn trả lời phí ship / đổi trả)

System prompt bắt model **chỉ được trả lời dựa trên phần "Dữ liệu"**. Nếu không
cấp Knowledge Base thì câu hỏi về phí ship hay chính sách đổi trả sẽ không có gì
để trả lời — bot chỉ có thể nói sẽ kiểm tra lại. Đây là chủ ý: thà nói "để shop
kiểm tra" còn hơn bịa ra một con số.

Hai cách cấp dữ liệu:

**a) MagiC Knowledge Hub** (khuyến nghị — dùng chung với các workload khác):

```bash
cd packs
MAGIC_URL=http://localhost:8080 MAGIC_API_KEY=your-key python -m ecomops.seed_knowledge
# hoặc nạp chính sách thật của shop:
python -m ecomops.seed_knowledge /duong/dan/chinh-sach-shop.yaml
```

```python
from ecomops import MagicKnowledgeLookup
wf = EcomOpsWorkflow(..., knowledge_lookup=MagicKnowledgeLookup(MAGIC_URL, MAGIC_API_KEY))
```

**b) File YAML tại chỗ** (shop không chạy Knowledge Hub / core in-memory):

```python
from ecomops import StaticKnowledgeLookup
wf = EcomOpsWorkflow(..., knowledge_lookup=StaticKnowledgeLookup.from_yaml("chinh-sach-shop.yaml"))
```

Mỗi entry được gắn 2 tag: `ecomops` + topic (`shipping`, `return`, `order`,
`product`, `complaint`). Lookup lọc theo cả hai, nên câu hỏi về phí ship không bị
trả nhầm chính sách đổi trả, và cũng không kéo về kiến thức khác của tổ chức chỉ
vì trùng từ khoá.

> ⚠️ `sample_knowledge.yaml` là **dữ liệu mẫu để chạy thử**. Thay bằng chính sách
> thật của shop trước khi dùng với khách — nội dung sai ở đây sẽ thành câu trả lời sai.

## 5. Demo end-to-end (Zalo + Google Sheet)

`example_worker.py` nối: webhook Zalo → tra đơn trong Google Sheet → trả lời có
guardrail → gửi lại qua Zalo.
Expand All @@ -114,11 +153,17 @@ Sheet cần tab `Orders` với dòng header khớp Common Schema
(`id`, `customer_id`, `status`, `total_amount`, `shipping_fee`, `created_at`) —
xem `connectors/google_sheet/README.md`.

## 5. Giới hạn hiện tại
## 6. Giới hạn hiện tại

- Từ khoá phân loại là **danh sách cố định trong code**, chưa cấu hình được qua file.
- Chưa có Case Management (tạo case khi khiếu nại/đổi trả) — thuộc Tuần 3–4 của roadmap.
- `example_worker.py` quét toàn bộ dải `Orders!A1:H500` mỗi lần tra đơn; với sheet
lớn nên thay bằng Nhanh.vn connector hoặc thêm cache.
- Guardrail dựa trên cụm từ, không phải mô hình — bắt được các mẫu hứa hẹn phổ biến,
nhưng không thay thế được người duyệt với các đơn giá trị cao.

## 7. Chạy test

```bash
pytest packs/ecomops/tests
```
3 changes: 3 additions & 0 deletions packs/ecomops/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
LLMIntentClassifier,
RuleBasedIntentClassifier,
)
from .knowledge import MagicKnowledgeLookup, StaticKnowledgeLookup
from .workflow import EcomOpsWorkflow, LLMDrafter, WorkflowResult

__all__ = [
Expand All @@ -25,6 +26,8 @@
"GuardrailContext",
"GuardrailViolation",
"Severity",
"MagicKnowledgeLookup",
"StaticKnowledgeLookup",
"EcomOpsWorkflow",
"LLMDrafter",
"WorkflowResult",
Expand Down
5 changes: 5 additions & 0 deletions packs/ecomops/example_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from zalo.webhook import serve # noqa: E402

from ecomops.intents import HybridIntentClassifier, LLMIntentClassifier # noqa: E402
from ecomops.knowledge import MagicKnowledgeLookup # noqa: E402
from ecomops.workflow import EcomOpsWorkflow, LLMDrafter # noqa: E402

logging.basicConfig(level=logging.INFO)
Expand Down Expand Up @@ -89,6 +90,10 @@ def main() -> None:
classifier=HybridIntentClassifier(llm=LLMIntentClassifier(MAGIC_URL, MAGIC_API_KEY)),
drafter=LLMDrafter(MAGIC_URL, MAGIC_API_KEY),
order_lookup=make_order_lookup(sheet),
# Chính sách shop lấy từ MagiC Knowledge Hub — nạp trước bằng
# `python -m ecomops.seed_knowledge`, nếu không câu hỏi về phí ship /
# đổi trả sẽ không có dữ liệu để trả lời.
knowledge_lookup=MagicKnowledgeLookup(MAGIC_URL, MAGIC_API_KEY),
allowed_contacts=zalo_cfg.get("shop_hotlines", []),
)

Expand Down
189 changes: 189 additions & 0 deletions packs/ecomops/knowledge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""Knowledge lookup for EcomOps drafts.

The system prompt tells the model to answer *only* from the "Dữ liệu" block, so
without a knowledge source the shipping-fee and return-policy intents have
nothing to answer from — the bot can only say it will check and get back. This
module supplies that block.

Two backends:

- `MagicKnowledgeLookup` — reads MagiC's Knowledge Hub (`GET /api/v1/knowledge`),
so policies live in one place shared with every other MagiC workload.
- `StaticKnowledgeLookup` — a YAML file, for shops running the pack without a
Knowledge Hub (in-memory core, no Postgres).

Both are plain async callables matching the workflow's `knowledge_lookup`
signature, so they're interchangeable.
"""

import logging
from pathlib import Path
from typing import Any

import httpx

from .intents import Intent

logger = logging.getLogger("magic_packs.ecomops.knowledge")

# Tag every EcomOps entry carries, so the pack's lookups don't drag in unrelated
# org knowledge that happens to share a keyword.
ECOMOPS_TAG = "ecomops"

# Per-intent tag + the keyword query sent to the Hub. The Hub's `q` search is
# keyword-based over title/content, so the query is deliberately in the same
# Vietnamese wording the sample entries use.
INTENT_TOPICS: dict[Intent, tuple[str, str]] = {
Intent.SHIPPING_FEE: ("shipping", "phí ship vận chuyển"),
Intent.RETURN_EXCHANGE: ("return", "đổi trả hoàn tiền bảo hành"),
Intent.ORDER_STATUS: ("order", "trạng thái đơn hàng giao hàng"),
Intent.PRODUCT_INFO: ("product", "sản phẩm size màu chất liệu"),
Intent.COMPLAINT: ("complaint", "khiếu nại xử lý sự cố"),
}


def topic_for(intent: Intent) -> str | None:
"""The knowledge tag an intent needs, or None if it needs no policy data."""
topic = INTENT_TOPICS.get(intent)
return topic[0] if topic else None


class MagicKnowledgeLookup:
"""Pulls shop policy snippets from MagiC's Knowledge Hub."""

def __init__(self, magic_url: str, api_key: str = "", top_k: int = 3, timeout: float = 10.0):
self.magic_url = magic_url.rstrip("/")
self.api_key = api_key
self.top_k = top_k
self.timeout = timeout

async def __call__(self, intent: Intent) -> list[str]:
topic = INTENT_TOPICS.get(intent)
if topic is None:
return []
tag, query = topic

headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.get(
f"{self.magic_url}/api/v1/knowledge", headers=headers, params={"q": query}
)
resp.raise_for_status()
entries = resp.json()
except (httpx.HTTPError, ValueError) as e:
# Missing policy data degrades the answer; it shouldn't drop the
# message. The workflow still drafts, and the guardrails still stop
# anything the model invents to fill the gap.
logger.warning("knowledge lookup failed for %s: %s", intent.value, e)
return []

if not isinstance(entries, list):
return []

# The Hub has no server-side tag filter, so narrow here: an entry must
# carry both the ecomops tag and this intent's topic tag.
snippets = []
for entry in entries:
if not isinstance(entry, dict):
continue
tags = entry.get("tags") or []
if ECOMOPS_TAG in tags and tag in tags:
content = (entry.get("content") or "").strip()
if content:
snippets.append(content)
return snippets[: self.top_k]


class StaticKnowledgeLookup:
"""Serves policy snippets from a local mapping — no Knowledge Hub needed."""

def __init__(self, entries: dict[str, list[str]] | None = None):
self.entries = entries or {}

async def __call__(self, intent: Intent) -> list[str]:
tag = topic_for(intent)
return list(self.entries.get(tag, [])) if tag else []

@classmethod
def from_yaml(cls, path: str | Path) -> "StaticKnowledgeLookup":
"""Load from a YAML mapping of topic -> list of snippets.

Imports yaml lazily so the rest of the pack stays dependency-free for
callers that build their knowledge mapping in code.
"""
import yaml

data = yaml.safe_load(_resolve_yaml_path(path).read_text(encoding="utf-8")) or {}
return cls({str(k): [str(s) for s in (v or [])] for k, v in data.items()})


def sample_knowledge_path() -> Path:
return Path(__file__).parent / "sample_knowledge.yaml"


_ALLOWED_SUFFIXES = {".yaml", ".yml"}


def _resolve_yaml_path(path: str | Path) -> Path:
"""Validate a caller-supplied knowledge path before touching the filesystem.

`seed_knowledge.py` takes this straight from argv and everything it reads is
POSTed into the Knowledge Hub, where it becomes queryable. An unvalidated
path therefore turns "seed the knowledge base" into "copy any file this
process can read into a searchable store" — so constrain it to an existing
regular YAML file.

`resolve()` runs first on purpose: it collapses `..` and follows symlinks, so
a `policy.yaml` symlinked at `/etc/shadow` is checked by its real name and
fails the suffix test rather than sailing through it.
"""
resolved = Path(path).expanduser().resolve()
if resolved.suffix.lower() not in _ALLOWED_SUFFIXES:
raise ValueError(
f"knowledge file must be .yaml or .yml, got {resolved.suffix or '(no suffix)'}: {resolved}"
)
if not resolved.is_file():
raise FileNotFoundError(f"knowledge file not found: {resolved}")
return resolved


def knowledge_entries_for_seeding(path: str | Path | None = None) -> list[dict[str, Any]]:
"""Turn the sample YAML into `POST /api/v1/knowledge` payloads.

Each entry is tagged `ecomops` plus its topic so `MagicKnowledgeLookup` can
find it again.
"""
import yaml

src = _resolve_yaml_path(path) if path else sample_knowledge_path()
data = yaml.safe_load(src.read_text(encoding="utf-8")) or {}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

payloads = []
for topic, snippets in data.items():
for i, snippet in enumerate(snippets or [], start=1):
payloads.append(
{
"title": f"EcomOps — {topic} #{i}",
"content": str(snippet).strip(),
"tags": [ECOMOPS_TAG, str(topic)],
"scope": "org",
}
)
return payloads


async def seed_knowledge_hub(
magic_url: str, api_key: str = "", path: str | Path | None = None, timeout: float = 15.0

Check warning on line 177 in packs/ecomops/knowledge.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this "timeout" parameter and use a timeout context manager instead.

See more on https://sonarcloud.io/project/issues?id=kienbui1995_magic&issues=AZ-XGPYhGwp10Fn53NdF&open=AZ-XGPYhGwp10Fn53NdF&pullRequest=22
) -> int:
"""Push the sample knowledge base into MagiC's Knowledge Hub. Returns count."""
payloads = knowledge_entries_for_seeding(path)
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}

async with httpx.AsyncClient(timeout=timeout) as client:
for payload in payloads:
resp = await client.post(
f"{magic_url.rstrip('/')}/api/v1/knowledge", headers=headers, json=payload
)
resp.raise_for_status()
return len(payloads)
3 changes: 3 additions & 0 deletions packs/ecomops/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# Install the framework first: pip install -e ../../sdk/python[connectors]
httpx>=0.27
# Reading sample_knowledge.yaml / a shop's own policy YAML, and the connector
# configs in example_worker.py.
pyyaml>=6.0
63 changes: 63 additions & 0 deletions packs/ecomops/sample_knowledge.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Knowledge Base mẫu cho EcomOps.
#
# Đây là DỮ LIỆU MẪU để chạy thử — thay bằng chính sách thật của shop trước khi
# dùng cho khách. Model chỉ được phép trả lời dựa trên nội dung ở đây (xem
# SYSTEM_PROMPT trong prompts.py), nên nội dung sai ở đây sẽ thành câu trả lời sai.
#
# Mỗi khoá là một "topic" khớp với intent (xem INTENT_TOPICS trong knowledge.py):
# shipping | return | order | product | complaint
#
# Nạp vào MagiC Knowledge Hub:
# python -m ecomops.seed_knowledge

shipping:
- >-
Phí ship nội thành Hà Nội và TP.HCM là 25.000đ, giao trong 1-2 ngày làm việc.
- >-
Phí ship các tỉnh khác là 35.000đ, thời gian giao dự kiến 3-5 ngày làm việc,
vùng sâu vùng xa có thể lâu hơn.
- >-
Miễn phí vận chuyển cho đơn hàng từ 500.000đ trở lên, áp dụng toàn quốc.

return:
- >-
Shop hỗ trợ đổi trả trong vòng 7 ngày kể từ ngày khách nhận hàng, với điều kiện
sản phẩm còn nguyên tem mác, chưa qua sử dụng và còn đủ hộp/phụ kiện.
- >-
Trường hợp shop giao sai mẫu, sai size hoặc sản phẩm bị lỗi từ nhà sản xuất,
shop chịu toàn bộ phí đổi trả và phí vận chuyển hai chiều.
- >-
Trường hợp khách đổi vì lý do cá nhân (không thích, đổi ý), khách chịu phí
vận chuyển chiều đổi hàng.
- >-
Sản phẩm khuyến mãi trên 50% và đồ lót không áp dụng chính sách đổi trả,
trừ khi bị lỗi từ nhà sản xuất.

order:
- >-
Các trạng thái đơn hàng: pending (chờ xác nhận), confirmed (đã xác nhận),
shipping (đang giao), delivered (đã giao), cancelled (đã huỷ),
returned (đã hoàn trả).
- >-
Đơn hàng được xác nhận trong vòng 24 giờ kể từ khi đặt. Sau khi chuyển sang
trạng thái shipping, khách có thể tra cứu vị trí đơn qua mã vận đơn.
- >-
Khách có thể huỷ đơn miễn phí khi đơn còn ở trạng thái pending hoặc confirmed.
Đơn đã chuyển sang shipping không huỷ được, khách có thể từ chối nhận hàng.

product:
- >-
Thông tin size, màu và chất liệu của từng sản phẩm được ghi trong phần mô tả
trên trang sản phẩm. Nếu khách phân vân giữa hai size, shop khuyên chọn size
lớn hơn.
- >-
Tình trạng còn hàng thay đổi liên tục trong ngày. Shop cần kiểm tra kho trước
khi xác nhận cho khách.

complaint:
- >-
Mọi khiếu nại đều được chuyển cho nhân viên phụ trách xử lý trong vòng
24 giờ làm việc. Nhân viên sẽ liên hệ lại với khách qua số điện thoại đặt hàng.
- >-
Phương án đền bù (hoàn tiền, đổi hàng, tặng voucher) chỉ do nhân viên quyết định
sau khi kiểm tra đơn — không cam kết trước với khách.
Loading
Loading