daiso 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -7
- package/dist/api/actionsProxy.js +10 -0
- package/dist/api/compareHandlers.js +26 -0
- package/dist/api/configStatus.js +8 -0
- package/dist/api/feedbackHandlers.js +60 -0
- package/dist/api/healthCheckDefinitions.js +170 -0
- package/dist/api/healthCheckShape.js +106 -0
- package/dist/api/healthCheckTypes.js +4 -0
- package/dist/api/healthChecks.js +3 -271
- package/dist/api/opinetHandlers.js +91 -0
- package/dist/api/routes/compareRoutes.js +9 -0
- package/dist/api/routes/feedbackRoutes.js +5 -0
- package/dist/api/routes/healthRoutes.js +17 -1
- package/dist/api/routes/opinetRoutes.js +24 -0
- package/dist/bin.js +0 -0
- package/dist/cli/commandOptions.js +24 -2
- package/dist/cli/commands/apiCommand.js +24 -0
- package/dist/cli/commands/cinema.js +65 -0
- package/dist/cli/commands/convenience.js +75 -100
- package/dist/cli/http.js +1 -1
- package/dist/cli.js +12 -1
- package/dist/cliHelp.js +55 -8
- package/dist/cliRenderer.js +15 -0
- package/dist/core/outputSchema.js +141 -0
- package/dist/core/registry.js +1 -126
- package/dist/index.js +96 -28
- package/dist/pages/openapiFullSpec.js +8 -0
- package/dist/pages/openapiSpecActions.js +70 -0
- package/dist/pages/openapiSpecPathsCompare.js +59 -0
- package/dist/pages/openapiSpecPathsFeedback.js +95 -0
- package/dist/pages/openapiSpecPathsOpinet.js +81 -0
- package/dist/pages/promptReferenceText.js +18 -1
- package/dist/pages/promptRetailCinemaText.js +21 -4
- package/dist/services/compare/client.js +133 -0
- package/dist/services/compare/index.js +17 -0
- package/dist/services/compare/tools/compareProducts.js +45 -0
- package/dist/services/compare/types.js +1 -0
- package/dist/services/feedback/client.js +109 -0
- package/dist/services/feedback/index.js +22 -0
- package/dist/services/feedback/tools/submitDeveloperRequest.js +54 -0
- package/dist/services/feedback/types.js +3 -0
- package/dist/services/gs25/client.js +1 -84
- package/dist/services/gs25/productSearch.js +97 -0
- package/dist/services/oliveyoung/client.js +1 -55
- package/dist/services/oliveyoung/normalize.js +58 -0
- package/dist/services/opinet/client.js +231 -0
- package/dist/services/opinet/index.js +26 -0
- package/dist/services/opinet/location.js +119 -0
- package/dist/services/opinet/tools.js +80 -0
- package/dist/services/opinet/types.js +1 -0
- package/openapi.json +92 -0
- package/openapi.yaml +62 -0
- package/package.json +18 -17
|
@@ -58,6 +58,20 @@ export function buildPromptReferenceText(baseUrl) {
|
|
|
58
58
|
| LOTTEMART_PRODUCT_SEARCH_FAILED | 롯데마트 상품 조회 실패 |
|
|
59
59
|
| SEVENELEVEN_INVENTORY_CHECK_FAILED | 세븐일레븐 재고 조회 실패 |
|
|
60
60
|
| PLACES_SEARCH_FAILED | 주변 장소 검색 실패 |
|
|
61
|
+
| DEVELOPER_REQUEST_SUBMIT_FAILED | 개발자 요청 저장 실패 |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 에이전트 실행 레시피
|
|
66
|
+
|
|
67
|
+
- "콜라 어디가 싸?", "컵라면 가격 비교해줘": 먼저 \`compare_products\` 또는 /api/compare/products를 호출합니다. 실제 재고와 매장 행사가가 필요하면 최저가 후보의 서비스별 재고/상품 도구로 한 번 더 확인합니다.
|
|
68
|
+
- "강남역 근처 카페", "성수동 브런치 음식점": 브랜드를 되묻지 말고 \`places_search_nearby\` 또는 /api/places/search를 호출합니다. 이 검색은 키워드 기반이므로 좌표 반경 결과처럼 단정하지 않습니다.
|
|
69
|
+
- "다이소 핫식스 재고", "올리브영 두바이초콜릿 있어?": 상품이 브랜드와 어색해 보여도 브랜드를 바꾸지 말고 먼저 다이소로 검색하거나 명시된 서비스로 검색합니다. 결과가 없을 때만 다른 서비스 대안을 제안합니다.
|
|
70
|
+
- "GS25 강남 오감자 재고": 상품명과 위치를 분리해 상품 후보를 먼저 찾고, 선택한 itemCode나 keyword와 storeKeyword로 재고를 확인합니다.
|
|
71
|
+
- "오늘 강남 CGV 시간표": 오늘 날짜는 KST 기준 YYYYMMDD로 계산하고, theaterCode가 없으면 극장 검색 후 시간표를 조회합니다.
|
|
72
|
+
- MCP 기능이 제대로 동작하지 않거나 개선/신규 기능 요청이 있으면 \`submit_developer_request\` 또는 /api/feedback/requests를 사용해 개발자에게 전달합니다. 제목과 설명은 필수입니다.
|
|
73
|
+
- 도구 호출이 실패하면 같은 도구를 한 번 더 좁은 조건으로 재시도합니다. 두 번 실패하면 실패한 서비스와 요청 조건을 밝히고 추측으로 결과를 만들지 않습니다.
|
|
74
|
+
- 결과에 imageUrl이 있으면 각 항목 이미지 표시를 우선합니다. 쿼리 문자열이 있는 이미지 URL도 그대로 사용합니다.
|
|
61
75
|
|
|
62
76
|
---
|
|
63
77
|
|
|
@@ -98,7 +112,8 @@ export function buildPromptReferenceText(baseUrl) {
|
|
|
98
112
|
11. **이마트24 재고 조회**: /api/emart24/inventory는 pluCd + storeKeyword 조합도 지원하므로, 상품 선택 뒤 매장 코드를 다시 모으지 않아도 됩니다.
|
|
99
113
|
12. **올리브영 재고 해석**: inventory.products[].storeInventory.stores[]가 있으면 그 매장별 stockLabel과 remainQuantity를 우선 사용하고, inStock는 그 주변 매장 기준 결과로 해석합니다.
|
|
100
114
|
13. **올리브영 상품 이미지 표시**: /api/oliveyoung/products 또는 oliveyoung_search_products 결과에 imageUrl이 있으면 목록형 답변에서도 각 상품 이미지를 생략하지 말고 모두 렌더링합니다. 특히 \`?l=ko\` 같은 query string을 삭제하지 말고 전체 URL 그대로 마크다운 이미지 src에 넣습니다.
|
|
101
|
-
14.
|
|
115
|
+
14. **통합 가격 후보 비교**: 사용자가 브랜드를 특정하지 않고 "어디가 싸?", "비교해줘"처럼 물으면 \`compare_products\` 또는 /api/compare/products를 먼저 사용합니다. 새 외부 키 없이 기존 상품 검색을 묶은 1차 가격 후보이므로 실제 재고/행사가는 각 서비스 상세 조회로 확인해야 합니다.
|
|
116
|
+
15. **주변 음식점/카페 검색**: 음식점이나 카페만 물으면 \`places_search_nearby\` 또는 /api/places/search를 먼저 사용합니다. 이 기능은 네이버 지역 검색 기반 키워드 검색이므로 좌표 반경 검색처럼 단정하지 말고, \`searchMode: "keyword"\`임을 필요할 때 짧게 밝혀주세요.
|
|
102
117
|
|
|
103
118
|
---
|
|
104
119
|
|
|
@@ -123,6 +138,8 @@ MCP 연결 정보: ${baseUrl}/mcp
|
|
|
123
138
|
- seveneleven_check_inventory: 세븐일레븐 재고 조회
|
|
124
139
|
- seveneleven_get_search_popwords: 세븐일레븐 인기 검색어 조회
|
|
125
140
|
- seveneleven_get_catalog_snapshot: 세븐일레븐 카탈로그 조회
|
|
141
|
+
- compare_products: 키 없는 통합 상품 가격 후보 비교
|
|
142
|
+
- submit_developer_request: MCP 오류, 개선 요청, 신규 기능 요청을 개발자에게 전달
|
|
126
143
|
- places_search_nearby: 음식점/카페 등 주변 장소 검색
|
|
127
144
|
- oliveyoung_search_products: 올리브영 상품 검색
|
|
128
145
|
- oliveyoung_find_nearby_stores: 올리브영 주변 매장 탐색
|
|
@@ -2,7 +2,24 @@
|
|
|
2
2
|
* 프롬프트 리테일/영화 API 섹션
|
|
3
3
|
*/
|
|
4
4
|
export function buildPromptRetailCinemaText(baseUrl) {
|
|
5
|
-
return `### 6-0.
|
|
5
|
+
return `### 6-0. 키 없는 통합 상품 가격 후보 비교
|
|
6
|
+
|
|
7
|
+
**설명**: 새 외부 API 키 없이 기존 다이소, GS25, 세븐일레븐, 이마트24 상품 검색을 묶어 가격 후보를 비교합니다. 실제 매장 재고와 행사가까지 확정하는 기능이 아니라 "어디가 싸 보이는지"를 빠르게 고르는 1차 검색입니다.
|
|
8
|
+
|
|
9
|
+
**URL**:
|
|
10
|
+
- ${baseUrl}/api/compare/products?keyword={검색어}
|
|
11
|
+
|
|
12
|
+
**선택 파라미터**:
|
|
13
|
+
- services: daiso,gs25,seveneleven,emart24 중 쉼표 구분 목록
|
|
14
|
+
- limit: 서비스별 최대 결과 수 (기본값: 5)
|
|
15
|
+
|
|
16
|
+
**예시**:
|
|
17
|
+
- ${baseUrl}/api/compare/products?keyword=콜라&limit=3
|
|
18
|
+
- ${baseUrl}/api/compare/products?keyword=컵라면&services=seveneleven,emart24
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
### 6-1. 주변 음식점/카페 검색
|
|
6
23
|
|
|
7
24
|
**설명**: 네이버 지역 검색으로 특정 지역의 음식점, 카페, 디저트 가게 등 주변 장소를 조회합니다. 좌표 반경 검색이 아니라 \`강남역 카페\` 같은 키워드 기반 검색입니다.
|
|
8
25
|
|
|
@@ -23,7 +40,7 @@ export function buildPromptRetailCinemaText(baseUrl) {
|
|
|
23
40
|
|
|
24
41
|
---
|
|
25
42
|
|
|
26
|
-
### 6-
|
|
43
|
+
### 6-2. 롯데마트 매장/상품 조회
|
|
27
44
|
|
|
28
45
|
**설명**: 롯데마트 계열 매장 검색과 특정 매장 기준 상품 가격/재고 조회를 제공합니다.
|
|
29
46
|
|
|
@@ -49,7 +66,7 @@ export function buildPromptRetailCinemaText(baseUrl) {
|
|
|
49
66
|
|
|
50
67
|
---
|
|
51
68
|
|
|
52
|
-
### 6-
|
|
69
|
+
### 6-3. GS25 매장/상품/재고 조회
|
|
53
70
|
|
|
54
71
|
**설명**: GS25 매장 탐색, 상품 키워드 검색, 재고 조회를 제공합니다.
|
|
55
72
|
|
|
@@ -73,7 +90,7 @@ export function buildPromptRetailCinemaText(baseUrl) {
|
|
|
73
90
|
|
|
74
91
|
---
|
|
75
92
|
|
|
76
|
-
### 6-
|
|
93
|
+
### 6-4. 세븐일레븐 상품/매장/재고/인기검색어/카탈로그 조회
|
|
77
94
|
|
|
78
95
|
**설명**: 세븐일레븐 상품 검색, 매장 검색, 재고 수량 조회, 인기 검색어, 카탈로그 스냅샷을 제공합니다.
|
|
79
96
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { fetchProducts as fetchDaisoProducts } from '../daiso/tools/searchProducts.js';
|
|
2
|
+
import { fetchGs25SearchProducts } from '../gs25/client.js';
|
|
3
|
+
import { searchSevenElevenProducts } from '../seveneleven/client.js';
|
|
4
|
+
import { searchEmart24Products } from '../emart24/client.js';
|
|
5
|
+
const DEFAULT_COMPARE_SERVICES = ['daiso', 'gs25', 'seveneleven', 'emart24'];
|
|
6
|
+
const SERVICE_NAMES = {
|
|
7
|
+
daiso: '다이소',
|
|
8
|
+
gs25: 'GS25',
|
|
9
|
+
seveneleven: '세븐일레븐',
|
|
10
|
+
emart24: '이마트24',
|
|
11
|
+
};
|
|
12
|
+
function toErrorMessage(error) {
|
|
13
|
+
return error instanceof Error ? error.message : String(error);
|
|
14
|
+
}
|
|
15
|
+
function parseServices(value) {
|
|
16
|
+
if (!value || value.trim().length === 0) {
|
|
17
|
+
return DEFAULT_COMPARE_SERVICES;
|
|
18
|
+
}
|
|
19
|
+
const allowed = new Set(DEFAULT_COMPARE_SERVICES);
|
|
20
|
+
const services = value
|
|
21
|
+
.split(',')
|
|
22
|
+
.map((item) => item.trim().toLowerCase())
|
|
23
|
+
.filter((item) => allowed.has(item));
|
|
24
|
+
return services.length > 0 ? [...new Set(services)] : DEFAULT_COMPARE_SERVICES;
|
|
25
|
+
}
|
|
26
|
+
function normalizePrice(value) {
|
|
27
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
|
|
28
|
+
}
|
|
29
|
+
function sortComparableProducts(products) {
|
|
30
|
+
const serviceOrder = new Map(DEFAULT_COMPARE_SERVICES.map((service, index) => [service, index]));
|
|
31
|
+
return [...products].sort((a, b) => {
|
|
32
|
+
if (a.price !== null && b.price !== null && a.price !== b.price) {
|
|
33
|
+
return a.price - b.price;
|
|
34
|
+
}
|
|
35
|
+
if (a.price !== null && b.price === null) {
|
|
36
|
+
return -1;
|
|
37
|
+
}
|
|
38
|
+
if (a.price === null && b.price !== null) {
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
return serviceOrder.get(a.service) - serviceOrder.get(b.service);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function searchService(service, keyword, limit) {
|
|
45
|
+
if (service === 'daiso') {
|
|
46
|
+
const result = await fetchDaisoProducts(keyword, 1, limit);
|
|
47
|
+
return result.products.map((product) => ({
|
|
48
|
+
service,
|
|
49
|
+
serviceName: SERVICE_NAMES[service],
|
|
50
|
+
code: product.id,
|
|
51
|
+
name: product.name,
|
|
52
|
+
price: normalizePrice(product.price),
|
|
53
|
+
imageUrl: product.imageUrl,
|
|
54
|
+
raw: product,
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
if (service === 'gs25') {
|
|
58
|
+
const products = await fetchGs25SearchProducts(keyword, { timeout: 15000 });
|
|
59
|
+
return products.slice(0, limit).map((product) => ({
|
|
60
|
+
service,
|
|
61
|
+
serviceName: SERVICE_NAMES[service],
|
|
62
|
+
code: product.itemCode,
|
|
63
|
+
name: product.itemName || product.shortItemName,
|
|
64
|
+
price: null,
|
|
65
|
+
imageUrl: product.imageUrl,
|
|
66
|
+
stockCheckEnabled: product.stockCheckEnabled,
|
|
67
|
+
raw: product,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
if (service === 'seveneleven') {
|
|
71
|
+
const result = await searchSevenElevenProducts({ query: keyword, page: 1, size: limit });
|
|
72
|
+
return result.products.map((product) => ({
|
|
73
|
+
service,
|
|
74
|
+
serviceName: SERVICE_NAMES[service],
|
|
75
|
+
code: product.itemCode || product.productNo,
|
|
76
|
+
name: product.itemName,
|
|
77
|
+
price: normalizePrice(product.salePrice),
|
|
78
|
+
originalPrice: normalizePrice(product.originalPrice),
|
|
79
|
+
imageUrl: product.imageUrl,
|
|
80
|
+
raw: product,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
const result = await searchEmart24Products({
|
|
84
|
+
keyword,
|
|
85
|
+
page: 1,
|
|
86
|
+
pageSize: limit,
|
|
87
|
+
sortType: 'PRICE_ASC',
|
|
88
|
+
saleProductYn: 'N',
|
|
89
|
+
});
|
|
90
|
+
return result.products.map((product) => ({
|
|
91
|
+
service,
|
|
92
|
+
serviceName: SERVICE_NAMES[service],
|
|
93
|
+
code: product.pluCd,
|
|
94
|
+
name: product.goodsName,
|
|
95
|
+
price: normalizePrice(product.viewPrice),
|
|
96
|
+
originalPrice: normalizePrice(product.originPrice),
|
|
97
|
+
raw: product,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
export function parseCompareServices(value) {
|
|
101
|
+
return parseServices(value);
|
|
102
|
+
}
|
|
103
|
+
export async function compareProducts(params) {
|
|
104
|
+
const keyword = params.keyword.trim();
|
|
105
|
+
const limit = Math.max(1, Math.min(Math.trunc(params.limit || 5), 20));
|
|
106
|
+
const services = params.services && params.services.length > 0 ? params.services : DEFAULT_COMPARE_SERVICES;
|
|
107
|
+
const settled = await Promise.allSettled(services.map(async (service) => ({
|
|
108
|
+
service,
|
|
109
|
+
products: await searchService(service, keyword, limit),
|
|
110
|
+
})));
|
|
111
|
+
const results = [];
|
|
112
|
+
const errors = [];
|
|
113
|
+
for (const item of settled) {
|
|
114
|
+
if (item.status === 'fulfilled') {
|
|
115
|
+
results.push(...item.value.products);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const service = services[settled.indexOf(item)];
|
|
119
|
+
errors.push({ service, message: toErrorMessage(item.reason) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const sorted = sortComparableProducts(results);
|
|
123
|
+
return {
|
|
124
|
+
keyword,
|
|
125
|
+
services,
|
|
126
|
+
serviceCount: services.length,
|
|
127
|
+
resultCount: sorted.length,
|
|
128
|
+
bestPrice: sorted.find((product) => product.price !== null) || null,
|
|
129
|
+
results: sorted,
|
|
130
|
+
errors,
|
|
131
|
+
note: '새 외부 키 없이 기존 공개 상품 검색을 묶은 가격 후보입니다. 실제 재고와 행사가는 매장별로 달라질 수 있습니다.',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createCompareProductsTool } from './tools/compareProducts.js';
|
|
2
|
+
const COMPARE_METADATA = {
|
|
3
|
+
id: 'compare',
|
|
4
|
+
name: '통합 비교',
|
|
5
|
+
version: '1.0.0',
|
|
6
|
+
description: '새 외부 키 없이 기존 상품 검색을 묶어 가격 후보를 비교하는 서비스',
|
|
7
|
+
};
|
|
8
|
+
class CompareService {
|
|
9
|
+
metadata = COMPARE_METADATA;
|
|
10
|
+
getTools() {
|
|
11
|
+
return [createCompareProductsTool()];
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function createCompareService() {
|
|
15
|
+
return new CompareService();
|
|
16
|
+
}
|
|
17
|
+
export * from './types.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
import { compareProducts, parseCompareServices } from '../client.js';
|
|
3
|
+
async function compareProductsHandler(args) {
|
|
4
|
+
const keyword = args.keyword?.trim();
|
|
5
|
+
if (!keyword) {
|
|
6
|
+
throw new Error('검색어(keyword)를 입력해주세요.');
|
|
7
|
+
}
|
|
8
|
+
const result = await compareProducts({
|
|
9
|
+
keyword,
|
|
10
|
+
limit: args.limit,
|
|
11
|
+
services: parseCompareServices(args.services),
|
|
12
|
+
});
|
|
13
|
+
return {
|
|
14
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
15
|
+
structuredContent: { ...result },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function createCompareProductsTool() {
|
|
19
|
+
return {
|
|
20
|
+
name: 'compare_products',
|
|
21
|
+
metadata: {
|
|
22
|
+
title: '가격 후보 통합 비교',
|
|
23
|
+
description: '새 외부 키 없이 다이소, GS25, 세븐일레븐, 이마트24 상품 검색을 묶어 가격 후보를 비교합니다.',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
keyword: z.string().describe('비교할 상품 검색어'),
|
|
26
|
+
limit: z.number().optional().default(5).describe('서비스별 최대 결과 수'),
|
|
27
|
+
services: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('쉼표로 구분한 서비스 목록(daiso,gs25,seveneleven,emart24)'),
|
|
31
|
+
},
|
|
32
|
+
outputSchema: {
|
|
33
|
+
keyword: z.string().optional(),
|
|
34
|
+
services: z.array(z.string()).optional(),
|
|
35
|
+
serviceCount: z.number().optional(),
|
|
36
|
+
resultCount: z.number().optional(),
|
|
37
|
+
bestPrice: z.unknown().optional(),
|
|
38
|
+
results: z.array(z.unknown()).optional(),
|
|
39
|
+
errors: z.array(z.unknown()).optional(),
|
|
40
|
+
note: z.string().optional(),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
handler: compareProductsHandler,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { DEVELOPER_REQUEST_SEVERITIES, DEVELOPER_REQUEST_SOURCES, DEVELOPER_REQUEST_TYPES, } from './types.js';
|
|
2
|
+
function isOneOf(value, allowed) {
|
|
3
|
+
return Boolean(value && allowed.includes(value));
|
|
4
|
+
}
|
|
5
|
+
function trimText(value, maxLength) {
|
|
6
|
+
if (typeof value !== 'string') {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
if (!trimmed) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
|
|
14
|
+
}
|
|
15
|
+
function requireText(value, fieldName, maxLength) {
|
|
16
|
+
const text = trimText(value, maxLength);
|
|
17
|
+
if (!text) {
|
|
18
|
+
throw new Error(`${fieldName}를 입력해주세요.`);
|
|
19
|
+
}
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
function normalizeType(value) {
|
|
23
|
+
return isOneOf(value, DEVELOPER_REQUEST_TYPES) ? value : 'other';
|
|
24
|
+
}
|
|
25
|
+
function normalizeSeverity(value) {
|
|
26
|
+
return isOneOf(value, DEVELOPER_REQUEST_SEVERITIES) ? value : 'medium';
|
|
27
|
+
}
|
|
28
|
+
function normalizeSource(value) {
|
|
29
|
+
return isOneOf(value, DEVELOPER_REQUEST_SOURCES) ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
function createInsertRow(input) {
|
|
32
|
+
const row = {
|
|
33
|
+
type: normalizeType(trimText(input.type, 40)),
|
|
34
|
+
severity: normalizeSeverity(trimText(input.severity, 40)),
|
|
35
|
+
title: requireText(input.title, 'title', 160),
|
|
36
|
+
description: requireText(input.description, 'description', 4000),
|
|
37
|
+
status: 'open',
|
|
38
|
+
};
|
|
39
|
+
const service = trimText(input.service, 80);
|
|
40
|
+
const toolName = trimText(input.toolName, 120);
|
|
41
|
+
const reproduction = trimText(input.reproduction, 4000);
|
|
42
|
+
const expected = trimText(input.expected, 2000);
|
|
43
|
+
const actual = trimText(input.actual, 2000);
|
|
44
|
+
const source = normalizeSource(trimText(input.source, 40));
|
|
45
|
+
if (service)
|
|
46
|
+
row.service = service;
|
|
47
|
+
if (toolName)
|
|
48
|
+
row.tool_name = toolName;
|
|
49
|
+
if (reproduction)
|
|
50
|
+
row.reproduction = reproduction;
|
|
51
|
+
if (expected)
|
|
52
|
+
row.expected = expected;
|
|
53
|
+
if (actual)
|
|
54
|
+
row.actual = actual;
|
|
55
|
+
if (source)
|
|
56
|
+
row.source = source;
|
|
57
|
+
if (input.userContext && typeof input.userContext === 'object') {
|
|
58
|
+
row.user_context = input.userContext;
|
|
59
|
+
}
|
|
60
|
+
return row;
|
|
61
|
+
}
|
|
62
|
+
function requireConfig(config) {
|
|
63
|
+
const supabaseUrl = trimText(config.supabaseUrl, 400);
|
|
64
|
+
const key = trimText(config.supabaseServiceRoleKey, 4000);
|
|
65
|
+
if (!supabaseUrl || !key) {
|
|
66
|
+
throw new Error('SUPABASE_URL과 SUPABASE_SERVICE_ROLE_KEY가 필요합니다.');
|
|
67
|
+
}
|
|
68
|
+
return { supabaseUrl: supabaseUrl.replace(/\/+$/, ''), key };
|
|
69
|
+
}
|
|
70
|
+
function stringOrUndefined(value) {
|
|
71
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
72
|
+
}
|
|
73
|
+
function resultFromRow(row, fallback) {
|
|
74
|
+
return {
|
|
75
|
+
id: stringOrUndefined(row.id) || '',
|
|
76
|
+
type: normalizeType(stringOrUndefined(row.type) || fallback.type),
|
|
77
|
+
severity: normalizeSeverity(stringOrUndefined(row.severity) || fallback.severity),
|
|
78
|
+
title: stringOrUndefined(row.title) || fallback.title,
|
|
79
|
+
service: stringOrUndefined(row.service) || fallback.service,
|
|
80
|
+
toolName: stringOrUndefined(row.tool_name) || fallback.tool_name,
|
|
81
|
+
source: normalizeSource(stringOrUndefined(row.source)) || fallback.source,
|
|
82
|
+
status: stringOrUndefined(row.status) || fallback.status,
|
|
83
|
+
createdAt: stringOrUndefined(row.created_at),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export async function submitDeveloperRequest(input, config) {
|
|
87
|
+
const { supabaseUrl, key } = requireConfig(config);
|
|
88
|
+
const row = createInsertRow(input);
|
|
89
|
+
const response = await fetch(`${supabaseUrl}/rest/v1/agent_requests`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
apikey: key,
|
|
93
|
+
Authorization: `Bearer ${key}`,
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
Prefer: 'return=representation',
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(row),
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
const detail = await response.text().catch(() => '');
|
|
101
|
+
throw new Error(`Supabase 저장 실패: ${response.status}${detail ? ` ${detail}` : ''}`);
|
|
102
|
+
}
|
|
103
|
+
const payload = (await response.json());
|
|
104
|
+
const returnedRow = Array.isArray(payload) ? payload[0] : payload;
|
|
105
|
+
if (!returnedRow || typeof returnedRow !== 'object') {
|
|
106
|
+
throw new Error('Supabase 저장 결과가 비어 있습니다.');
|
|
107
|
+
}
|
|
108
|
+
return resultFromRow(returnedRow, row);
|
|
109
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createSubmitDeveloperRequestTool } from './tools/submitDeveloperRequest.js';
|
|
2
|
+
const FEEDBACK_METADATA = {
|
|
3
|
+
id: 'feedback',
|
|
4
|
+
name: '개발자 요청',
|
|
5
|
+
version: '1.0.0',
|
|
6
|
+
description: 'AI 에이전트가 MCP 오류, 개선 요청, 신규 기능 요청을 개발자에게 전달하는 서비스',
|
|
7
|
+
};
|
|
8
|
+
class FeedbackService {
|
|
9
|
+
config;
|
|
10
|
+
metadata = FEEDBACK_METADATA;
|
|
11
|
+
constructor(config = {}) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
}
|
|
14
|
+
getTools() {
|
|
15
|
+
return [createSubmitDeveloperRequestTool(this.config)];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function createFeedbackService(config = {}) {
|
|
19
|
+
return new FeedbackService(config);
|
|
20
|
+
}
|
|
21
|
+
export * from './client.js';
|
|
22
|
+
export * from './types.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
import { submitDeveloperRequest } from '../client.js';
|
|
3
|
+
export function createSubmitDeveloperRequestTool(config) {
|
|
4
|
+
return {
|
|
5
|
+
name: 'submit_developer_request',
|
|
6
|
+
metadata: {
|
|
7
|
+
title: '개발자 요청 제출',
|
|
8
|
+
description: 'MCP 기능 오류, 개선 요청, 신규 기능 요청, 문서 문제를 개발자에게 전달하고 Supabase에 저장합니다.',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: z
|
|
11
|
+
.enum(['bug', 'improvement', 'feature', 'docs', 'other'])
|
|
12
|
+
.optional()
|
|
13
|
+
.default('other')
|
|
14
|
+
.describe('요청 유형'),
|
|
15
|
+
severity: z
|
|
16
|
+
.enum(['low', 'medium', 'high', 'critical'])
|
|
17
|
+
.optional()
|
|
18
|
+
.default('medium')
|
|
19
|
+
.describe('영향도'),
|
|
20
|
+
title: z.string().describe('짧은 제목'),
|
|
21
|
+
description: z.string().describe('요청 상세 설명'),
|
|
22
|
+
service: z.string().optional().describe('관련 서비스 ID 또는 영역'),
|
|
23
|
+
toolName: z.string().optional().describe('관련 MCP 도구 이름'),
|
|
24
|
+
reproduction: z.string().optional().describe('재현 방법'),
|
|
25
|
+
expected: z.string().optional().describe('기대 결과'),
|
|
26
|
+
actual: z.string().optional().describe('실제 결과'),
|
|
27
|
+
source: z
|
|
28
|
+
.enum(['mcp', 'chatgpt', 'claude', 'cli', 'api', 'other'])
|
|
29
|
+
.optional()
|
|
30
|
+
.default('mcp')
|
|
31
|
+
.describe('요청 출처'),
|
|
32
|
+
userContext: z.object({}).loose().optional().describe('추가 컨텍스트 JSON'),
|
|
33
|
+
},
|
|
34
|
+
outputSchema: {
|
|
35
|
+
id: z.string().optional(),
|
|
36
|
+
type: z.string().optional(),
|
|
37
|
+
severity: z.string().optional(),
|
|
38
|
+
title: z.string().optional(),
|
|
39
|
+
service: z.string().optional(),
|
|
40
|
+
toolName: z.string().optional(),
|
|
41
|
+
source: z.string().optional(),
|
|
42
|
+
status: z.string().optional(),
|
|
43
|
+
createdAt: z.string().optional(),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
handler: (async (args) => {
|
|
47
|
+
const result = await submitDeveloperRequest(args, config);
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
50
|
+
structuredContent: { ...result },
|
|
51
|
+
};
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -6,6 +6,7 @@ import { fetchJson, fetchWithTimeout, HttpError } from '../../utils/http.js';
|
|
|
6
6
|
import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js';
|
|
7
7
|
import { GS25_API } from './api.js';
|
|
8
8
|
import { normalizeStore, toNumber } from './storeUtils.js';
|
|
9
|
+
export { fetchGs25NormalizedKeyword, fetchGs25SearchProducts, } from './productSearch.js';
|
|
9
10
|
export { attachDistanceToGs25Stores, calculateDistanceM, extractGs25ProductCandidates, filterGs25StoresByKeyword, selectGs25StoresForKeyword, sortGs25Stores, } from './storeUtils.js';
|
|
10
11
|
const GS25_DEFAULT_HEADERS = {
|
|
11
12
|
Accept: 'application/json, text/plain, */*',
|
|
@@ -14,10 +15,6 @@ const GS25_DEFAULT_HEADERS = {
|
|
|
14
15
|
Origin: 'https://woodongs.com',
|
|
15
16
|
Referer: 'https://woodongs.com/',
|
|
16
17
|
};
|
|
17
|
-
const GS25_TOTAL_SEARCH_HEADERS = {
|
|
18
|
-
...GS25_DEFAULT_HEADERS,
|
|
19
|
-
'Content-Type': 'application/json',
|
|
20
|
-
};
|
|
21
18
|
const GS25_DEFAULT_FETCH_OPTIONS = {
|
|
22
19
|
retries: 1,
|
|
23
20
|
retryDelayMs: 250,
|
|
@@ -91,40 +88,6 @@ async function fetchGs25StoreStock(url, options) {
|
|
|
91
88
|
return decodeZyteHttpBody(result);
|
|
92
89
|
}
|
|
93
90
|
}
|
|
94
|
-
async function fetchGs25TotalSearchResponse(query, options) {
|
|
95
|
-
const { timeout = 20000 } = options;
|
|
96
|
-
const endpoint = new URL(GS25_API.TOTAL_SEARCH_PATH, GS25_API.APIGW_BASE_URL);
|
|
97
|
-
const bodyText = JSON.stringify({ query });
|
|
98
|
-
try {
|
|
99
|
-
return await fetchJson(endpoint.toString(), {
|
|
100
|
-
...GS25_DEFAULT_FETCH_OPTIONS,
|
|
101
|
-
method: 'POST',
|
|
102
|
-
retryUnsafeMethods: true,
|
|
103
|
-
timeout,
|
|
104
|
-
headers: GS25_TOTAL_SEARCH_HEADERS,
|
|
105
|
-
body: bodyText,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
catch (error) {
|
|
109
|
-
const zyteApiKey = options.zyteApiKey?.trim();
|
|
110
|
-
if (!(error instanceof HttpError) || error.status !== 403 || !zyteApiKey) {
|
|
111
|
-
throw error;
|
|
112
|
-
}
|
|
113
|
-
const result = await requestByZyte({
|
|
114
|
-
apiKey: zyteApiKey,
|
|
115
|
-
url: endpoint.toString(),
|
|
116
|
-
method: 'POST',
|
|
117
|
-
timeout,
|
|
118
|
-
retries: 1,
|
|
119
|
-
headers: Object.entries(GS25_TOTAL_SEARCH_HEADERS).map(([name, value]) => ({
|
|
120
|
-
name,
|
|
121
|
-
value,
|
|
122
|
-
})),
|
|
123
|
-
bodyText,
|
|
124
|
-
});
|
|
125
|
-
return decodeZyteHttpBody(result);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
91
|
function buildCacheKey(params) {
|
|
129
92
|
return [
|
|
130
93
|
params.serviceCode,
|
|
@@ -332,52 +295,6 @@ export async function fetchGs25Stores(params = {}, options = {}) {
|
|
|
332
295
|
cacheHit: false,
|
|
333
296
|
};
|
|
334
297
|
}
|
|
335
|
-
export async function fetchGs25NormalizedKeyword(keyword, options = {}) {
|
|
336
|
-
const query = keyword.trim();
|
|
337
|
-
if (query.length === 0) {
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
340
|
-
const body = await fetchGs25TotalSearchResponse(query, options);
|
|
341
|
-
const normalizedKeyword = body.SearchQueryResult?.keywordInfo?.keyword?.trim() || '';
|
|
342
|
-
const normalizedSearchKeyword = body.SearchQueryResult?.keywordInfo?.searchKeyword?.trim() || '';
|
|
343
|
-
if (normalizedKeyword.length === 0 && normalizedSearchKeyword.length === 0) {
|
|
344
|
-
return null;
|
|
345
|
-
}
|
|
346
|
-
return {
|
|
347
|
-
keyword: normalizedKeyword,
|
|
348
|
-
searchKeyword: normalizedSearchKeyword,
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
/**
|
|
352
|
-
* 키워드로 상품을 검색하여 itemCode 목록을 반환합니다.
|
|
353
|
-
* 재고 조회 API는 itemCode가 필요하므로 이 함수로 먼저 변환해야 합니다.
|
|
354
|
-
*/
|
|
355
|
-
export async function fetchGs25SearchProducts(keyword, options = {}) {
|
|
356
|
-
const query = keyword.trim();
|
|
357
|
-
if (query.length === 0) {
|
|
358
|
-
return [];
|
|
359
|
-
}
|
|
360
|
-
const body = await fetchGs25TotalSearchResponse(query, options);
|
|
361
|
-
const products = [];
|
|
362
|
-
const collections = body.SearchQueryResult?.Collection || [];
|
|
363
|
-
for (const collection of collections) {
|
|
364
|
-
const documents = collection.Documentset?.Document || [];
|
|
365
|
-
for (const doc of documents) {
|
|
366
|
-
const field = doc.field;
|
|
367
|
-
if (!field?.itemCode)
|
|
368
|
-
continue;
|
|
369
|
-
products.push({
|
|
370
|
-
itemCode: field.itemCode,
|
|
371
|
-
itemName: field.itemName || '',
|
|
372
|
-
shortItemName: field.shortItemName || '',
|
|
373
|
-
imageUrl: field.itemImageUrl || '',
|
|
374
|
-
rating: toNumber(field.starPoint),
|
|
375
|
-
stockCheckEnabled: field.stockCheckYn === 'Y',
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
return products;
|
|
380
|
-
}
|
|
381
298
|
export function clearGs25StoresCache() {
|
|
382
299
|
gs25StoresCache.clear();
|
|
383
300
|
}
|