@miravia_test/miravia-publish-skill 0.1.4 → 0.1.6
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/dist/SKILL.md
CHANGED
|
@@ -25,7 +25,7 @@ description: Miravia 商品发布 AI Skill。仅负责商品创建(手动一
|
|
|
25
25
|
| 文件 | 职责 |
|
|
26
26
|
|------|------|
|
|
27
27
|
| `scripts/publish_module.py` | 发布流程主控(类目/品牌/属性/图片/SKU/合规/确认/创建) |
|
|
28
|
-
| `scripts/extraction_engine.py` | 从源文本抽取 `MiraviaListingSchema
|
|
28
|
+
| `scripts/extraction_engine.py` | 从源文本抽取 `MiraviaListingSchema`;Excel/CSV 按表头规则抽取(无需 LLM key) |
|
|
29
29
|
| `scripts/file_reader.py` | 读取本地文件、文件夹或网页 URL |
|
|
30
30
|
| `scripts/field_definitions.py` | 商品字段定义与术语说明,供 LLM 抽取与提示使用 |
|
|
31
31
|
| `scripts/product_client.py` | Miravia 商品/图片/类目/品牌/合规 API 封装 |
|
|
@@ -75,7 +75,7 @@ description: Miravia 商品发布 AI Skill。仅负责商品创建(手动一
|
|
|
75
75
|
- 首次操作时,Skill 返回 `status: need_authorization`,列出三项授权请求(读取卖家资料、访问商品数据、更新商品数据)。
|
|
76
76
|
- 卖家确认后,加 `--authorized` 重新运行命令,进入后续流程。
|
|
77
77
|
1. 读取 `--source` 指定的文件/文件夹/URL。
|
|
78
|
-
2.
|
|
78
|
+
2. 抽取 `MiraviaListingSchema`;可通过 `--output-schema` 仅查看抽取结果。**Excel/CSV 源按表头规则直接抽取,无需 LLM key**;PDF/Word/图片/URL 源使用 LLM 抽取(需配置 OPENAI_API_KEY 或 DASHSCOPE_API_KEY)。
|
|
79
79
|
3. **Stage 1 - 类目/标题/品牌确认(CTB)**:
|
|
80
80
|
- 解析 category / brand,返回候选供确认。
|
|
81
81
|
- 生成 `ctb_confirm_token`,返回 `status: need_ctb_confirm`。
|
|
@@ -119,6 +119,7 @@ description: Miravia 商品发布 AI Skill。仅负责商品创建(手动一
|
|
|
119
119
|
| `need_images` | 部分图片迁移失败 | 替换图片后重跑 |
|
|
120
120
|
| `unsupported_format` | 上传了不支持的文件格式(zip/txt/psd 等) | 提示仅支持 PDF/Word/Excel/Image/URL,允许重新上传 |
|
|
121
121
|
| `file_empty_or_corrupted` | 文件内容为空或损坏(0KB PDF / 乱码 Word) | 提示文件内容为空或损坏,允许重新上传 |
|
|
122
|
+
| `llm_not_configured` | 非表格源(PDF/Word/图片/URL)走 LLM 抽取时未配置 LLM API key(OPENAI_API_KEY / DASHSCOPE_API_KEY);Excel/CSV 源不会触发此状态 | 提示配置 key 后带 --source 重跑,或改用 Excel/CSV 上传;不要绕过确认流程改走手动模式 |
|
|
122
123
|
| `invalid_url` | 输入的 URL 无法抓取 | 提示检查 URL 后重试 |
|
|
123
124
|
| `title_invalid` | 商品标题不符合规范(过短/过长/词数不足) | 提示具体错误 + 标题格式建议(Brand+Category+Model+Features+Scenario),阻断后续流程,禁止转 Draft |
|
|
124
125
|
| `need_category_permission` | 卖家无该类目发布权限 | 询问是否以 Draft 模式发布(待审批通过后自动激活),提供类目权限申请链接 https://sellercenter.miravia.es/apps/setting/category;或更换类目后重跑 |
|
package/dist/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.6
|
|
@@ -102,6 +102,20 @@ class MiraviaListingSchema:
|
|
|
102
102
|
# ---------------------------------------------------------------------------
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
class LLMNotConfiguredError(RuntimeError):
|
|
106
|
+
"""Raised when AI extraction is requested but no LLM API key is configured.
|
|
107
|
+
|
|
108
|
+
Callers must surface this as a structured "llm_not_configured" status so
|
|
109
|
+
agents prompt for the key instead of silently bypassing the confirm-first
|
|
110
|
+
extraction flow.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
LLM_NOT_CONFIGURED_HINT = (
|
|
115
|
+
"No LLM API key configured. Set OPENAI_API_KEY or DASHSCOPE_API_KEY."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
105
119
|
class LLMClient:
|
|
106
120
|
"""Minimal OpenAI-compatible LLM client."""
|
|
107
121
|
|
|
@@ -181,8 +195,12 @@ class ExtractionEngine:
|
|
|
181
195
|
|
|
182
196
|
def extract(self, source_text: str) -> MiraviaListingSchema:
|
|
183
197
|
if not self.client.is_configured():
|
|
184
|
-
|
|
185
|
-
|
|
198
|
+
# Never silently degrade to heuristic extraction: without LLM the
|
|
199
|
+
# confirm-first flow (ctb → field summary → publish) breaks because
|
|
200
|
+
# agents judge the regex output unreliable and bypass the skill.
|
|
201
|
+
# Fail loudly so the caller returns a structured status instead.
|
|
202
|
+
logger.error("LLM not configured; refusing heuristic fallback")
|
|
203
|
+
raise LLMNotConfiguredError(LLM_NOT_CONFIGURED_HINT)
|
|
186
204
|
|
|
187
205
|
schema_fields = "\n".join(f"- {f.name}" for f in fields(MiraviaListingSchema))
|
|
188
206
|
messages = [
|
|
@@ -318,3 +336,246 @@ def validate_title(title: Optional[str]) -> Optional[str]:
|
|
|
318
336
|
if len(title.split()) < 5:
|
|
319
337
|
return "Title should contain at least 5 words"
|
|
320
338
|
return None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
# Rule-based table extraction (Excel/CSV) — no LLM key required
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# Header synonym map: schema field -> candidate header spellings (zh/en/es).
|
|
347
|
+
# Headers are normalized (lowercase, spaces/punctuation stripped) before
|
|
348
|
+
# matching; each field may bind to multiple columns (e.g. several image
|
|
349
|
+
# columns all feed product_images).
|
|
350
|
+
_TABLE_FIELD_SYNONYMS: Dict[str, tuple] = {
|
|
351
|
+
# Keep more specific fields BEFORE generic ones (matching runs
|
|
352
|
+
# exact -> prefix -> substring, but dict order breaks exact ties).
|
|
353
|
+
"discounted_price": ("折扣价", "活动价", "促销价", "折后价", "specialprice", "discountprice", "saleprice", "preciodescuento", "preciorebajado"),
|
|
354
|
+
"general_price": ("价格", "售价", "原价", "价格€", "price", "generalprice", "precio"),
|
|
355
|
+
"product_title": ("标题", "商品标题", "产品名称", "品名", "title", "producttitle", "productname", "titulo", "nombre"),
|
|
356
|
+
"brand_name": ("品牌", "brand", "brandname", "marca"),
|
|
357
|
+
"category_name": ("类目", "类目名", "叶子类目", "category", "categoryname", "categoria"),
|
|
358
|
+
"category_id": ("类目id", "类目id", "categoryid", "categoryid"),
|
|
359
|
+
"product_long_description": ("描述", "详情", "商品描述", "description", "descripcion"),
|
|
360
|
+
"product_images": ("图片", "主图", "细节图", "image", "images", "imageurl", "picture", "photo", "imagen", "imagenes", "foto"),
|
|
361
|
+
"seller_sku": ("sellersku", "sku", "商家编码", "商品编码", "skucode", "reference", "referencia"),
|
|
362
|
+
"ean_code": ("ean", "eancode", "条码", "条形码", "barcode", "codigodebarras"),
|
|
363
|
+
"stock": ("库存", "数量", "stock", "quantity", "qty", "inventario", "cantidad"),
|
|
364
|
+
"weight_kg": ("重量", "包裹重量", "weight", "peso"),
|
|
365
|
+
"length_cm": ("长度", "长cm", "长", "length", "largura"),
|
|
366
|
+
"width_cm": ("宽度", "宽cm", "宽", "width", "ancho"),
|
|
367
|
+
"height_cm": ("高度", "高cm", "高", "height", "altura"),
|
|
368
|
+
"manufacturer_name": ("制造商", "厂家", "manufacturer", "fabricante"),
|
|
369
|
+
"eu_responsible_person_name": ("欧盟负责人", "欧代", "euresponsibleperson", "eurp"),
|
|
370
|
+
"variation_1": ("颜色", "color", "colour", "variation1", "variation", "款式"),
|
|
371
|
+
"variation_2": ("尺寸", "尺码", "size", "talla", "variation2"),
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
# Fields that describe the product as a whole (first non-empty row wins);
|
|
375
|
+
# everything else is SKU-level and collected per row into raw_skus.
|
|
376
|
+
_PRODUCT_LEVEL_FIELDS = {
|
|
377
|
+
"product_title",
|
|
378
|
+
"brand_name",
|
|
379
|
+
"category_name",
|
|
380
|
+
"category_id",
|
|
381
|
+
"product_long_description",
|
|
382
|
+
"weight_kg",
|
|
383
|
+
"length_cm",
|
|
384
|
+
"width_cm",
|
|
385
|
+
"height_cm",
|
|
386
|
+
"manufacturer_name",
|
|
387
|
+
"eu_responsible_person_name",
|
|
388
|
+
}
|
|
389
|
+
# Row-level columns whose header name doubles as the variation dimension name.
|
|
390
|
+
_VARIATION_SOURCE_FIELDS = {"variation_1": "variation_name_1", "variation_2": "variation_name_2"}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _norm_header(header: Any) -> str:
|
|
394
|
+
"""Normalize a table header for synonym matching."""
|
|
395
|
+
return re.sub(r"[\s_\-—·()()【】\[\].,,、::]+", "", str(header)).lower()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _match_table_field(header_norm: str) -> Optional[str]:
|
|
399
|
+
"""Resolve a normalized header to a schema field via 3-pass matching
|
|
400
|
+
(exact -> prefix -> substring) to keep specific fields from being
|
|
401
|
+
shadowed by generic synonyms (e.g. "special price" vs "price")."""
|
|
402
|
+
for pass_kind in ("exact", "prefix", "contains"):
|
|
403
|
+
for field_name, synonyms in _TABLE_FIELD_SYNONYMS.items():
|
|
404
|
+
for syn in synonyms:
|
|
405
|
+
if pass_kind == "exact" and header_norm == syn:
|
|
406
|
+
return field_name
|
|
407
|
+
if pass_kind == "prefix" and header_norm.startswith(syn):
|
|
408
|
+
return field_name
|
|
409
|
+
if pass_kind == "contains" and syn in header_norm:
|
|
410
|
+
return field_name
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _clean_numeric(value: Any) -> Optional[str]:
|
|
415
|
+
"""Extract a plain number string from a cell (handles €10,99 / 10.99 / 10)."""
|
|
416
|
+
if value is None:
|
|
417
|
+
return None
|
|
418
|
+
s = str(value).strip()
|
|
419
|
+
if not s:
|
|
420
|
+
return None
|
|
421
|
+
m = re.search(r"(\d+(?:[.,]\d+)?)", s)
|
|
422
|
+
if not m:
|
|
423
|
+
return None
|
|
424
|
+
return m.group(1).replace(",", ".")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _clean_cell(value: Any) -> Optional[str]:
|
|
428
|
+
"""Non-empty string view of a cell."""
|
|
429
|
+
if value is None:
|
|
430
|
+
return None
|
|
431
|
+
s = str(value).strip()
|
|
432
|
+
return s or None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _extract_image_urls(value: Any) -> List[str]:
|
|
436
|
+
"""Collect http(s) image URLs from a cell (may hold several)."""
|
|
437
|
+
if value is None:
|
|
438
|
+
return []
|
|
439
|
+
return re.findall(r"https?://[^\s,;]+", str(value), re.IGNORECASE)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def extract_schema_from_tables(content_items: List[Dict[str, Any]]) -> Optional[MiraviaListingSchema]:
|
|
443
|
+
"""Extract a listing schema from structured Excel/CSV tables by rule.
|
|
444
|
+
|
|
445
|
+
Uses header synonyms instead of an LLM, so sellers never need an LLM
|
|
446
|
+
API key for the mainstream Excel/CSV flow. Returns None when no table
|
|
447
|
+
data is present or nothing useful was matched, letting the caller fall
|
|
448
|
+
back to LLM extraction for non-structured sources.
|
|
449
|
+
"""
|
|
450
|
+
tables: List[Dict[str, Any]] = []
|
|
451
|
+
for item in content_items or []:
|
|
452
|
+
if item.get("type") != "table":
|
|
453
|
+
continue
|
|
454
|
+
content = item.get("content")
|
|
455
|
+
if not content:
|
|
456
|
+
continue
|
|
457
|
+
for table in (content if isinstance(content, list) else [content]):
|
|
458
|
+
if isinstance(table, dict) and table.get("rows"):
|
|
459
|
+
tables.append(table)
|
|
460
|
+
|
|
461
|
+
if not tables:
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
schema = MiraviaListingSchema()
|
|
465
|
+
# column plans: field -> (table_index, header) pairs, preserving order
|
|
466
|
+
column_plan: Dict[str, List[tuple]] = {}
|
|
467
|
+
|
|
468
|
+
for t_idx, table in enumerate(tables):
|
|
469
|
+
for header in table.get("headers", []):
|
|
470
|
+
field_name = _match_table_field(_norm_header(header))
|
|
471
|
+
if field_name:
|
|
472
|
+
column_plan.setdefault(field_name, []).append((t_idx, header))
|
|
473
|
+
|
|
474
|
+
if not column_plan:
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
all_rows: List[tuple] = [] # (t_idx, row_dict)
|
|
478
|
+
for t_idx, table in enumerate(tables):
|
|
479
|
+
for row in table.get("rows", []):
|
|
480
|
+
if isinstance(row, dict):
|
|
481
|
+
all_rows.append((t_idx, row))
|
|
482
|
+
if not all_rows:
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
def first_non_empty(field_name: str) -> Optional[str]:
|
|
486
|
+
for t_idx, header in column_plan.get(field_name, []):
|
|
487
|
+
for r_idx, row in all_rows:
|
|
488
|
+
if r_idx == t_idx:
|
|
489
|
+
value = _clean_cell(row.get(header))
|
|
490
|
+
if value:
|
|
491
|
+
return value
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
# Product-level fields: first non-empty cell wins.
|
|
495
|
+
for field_name in _PRODUCT_LEVEL_FIELDS:
|
|
496
|
+
value = first_non_empty(field_name)
|
|
497
|
+
if value:
|
|
498
|
+
setattr(schema, field_name, value)
|
|
499
|
+
|
|
500
|
+
# Price/stock need plain numbers. Weight/dimensions keep their original
|
|
501
|
+
# string (e.g. "250g", "15cm"): the caller's _convert_weight/_convert_length
|
|
502
|
+
# normalize units, and stripping the unit here would misread grams as kg.
|
|
503
|
+
for field_name in ("general_price", "discounted_price", "stock"):
|
|
504
|
+
value = _clean_numeric(first_non_empty(field_name))
|
|
505
|
+
if value:
|
|
506
|
+
setattr(schema, field_name, value)
|
|
507
|
+
|
|
508
|
+
# Category/brand IDs keep raw values when present.
|
|
509
|
+
cat_id = _clean_cell(first_non_empty("category_id"))
|
|
510
|
+
if cat_id and str(cat_id).isdigit():
|
|
511
|
+
schema.category_id = str(cat_id)
|
|
512
|
+
|
|
513
|
+
# Images: product-level union of all image columns across rows.
|
|
514
|
+
seen_urls: set = set()
|
|
515
|
+
for t_idx, header in column_plan.get("product_images", []):
|
|
516
|
+
for r_idx, row in all_rows:
|
|
517
|
+
if r_idx == t_idx:
|
|
518
|
+
for url in _extract_image_urls(row.get(header)):
|
|
519
|
+
if url not in seen_urls:
|
|
520
|
+
seen_urls.add(url)
|
|
521
|
+
schema.product_images.append(url)
|
|
522
|
+
|
|
523
|
+
# Variations: dimension name from header, options from distinct row values.
|
|
524
|
+
for src_field, name_field in _VARIATION_SOURCE_FIELDS.items():
|
|
525
|
+
if src_field not in column_plan:
|
|
526
|
+
continue
|
|
527
|
+
first_t_idx, first_header = column_plan[src_field][0]
|
|
528
|
+
dim_name = _clean_cell(first_header)
|
|
529
|
+
options: List[str] = []
|
|
530
|
+
for t_idx, header in column_plan[src_field]:
|
|
531
|
+
for r_idx, row in all_rows:
|
|
532
|
+
if r_idx == t_idx:
|
|
533
|
+
value = _clean_cell(row.get(header))
|
|
534
|
+
if value and value not in options:
|
|
535
|
+
options.append(value)
|
|
536
|
+
if options:
|
|
537
|
+
setattr(schema, name_field, dim_name)
|
|
538
|
+
if name_field == "variation_name_1":
|
|
539
|
+
schema.variation_options_1 = options
|
|
540
|
+
else:
|
|
541
|
+
schema.variation_options_2 = options
|
|
542
|
+
|
|
543
|
+
# SKU rows: one raw_skus entry per data row.
|
|
544
|
+
for t_idx, row in all_rows:
|
|
545
|
+
sku: Dict[str, Any] = {}
|
|
546
|
+
for field_name, sku_key in (
|
|
547
|
+
("seller_sku", "seller_sku"),
|
|
548
|
+
("ean_code", "ean_code"),
|
|
549
|
+
("variation_1", "variation_1"),
|
|
550
|
+
("variation_2", "variation_2"),
|
|
551
|
+
):
|
|
552
|
+
for c_t_idx, header in column_plan.get(field_name, []):
|
|
553
|
+
if c_t_idx == t_idx:
|
|
554
|
+
value = _clean_cell(row.get(header))
|
|
555
|
+
if value:
|
|
556
|
+
sku[sku_key] = value
|
|
557
|
+
break
|
|
558
|
+
for field_name, sku_key in (("general_price", "price"), ("discounted_price", "special_price"), ("stock", "quantity")):
|
|
559
|
+
for c_t_idx, header in column_plan.get(field_name, []):
|
|
560
|
+
if c_t_idx == t_idx:
|
|
561
|
+
value = _clean_numeric(row.get(header))
|
|
562
|
+
if value:
|
|
563
|
+
sku[sku_key] = value
|
|
564
|
+
break
|
|
565
|
+
sku_images: List[str] = []
|
|
566
|
+
for c_t_idx, header in column_plan.get("product_images", []):
|
|
567
|
+
if c_t_idx == t_idx:
|
|
568
|
+
sku_images.extend(_extract_image_urls(row.get(header)))
|
|
569
|
+
if sku_images:
|
|
570
|
+
sku["sku_images"] = sku_images
|
|
571
|
+
if sku:
|
|
572
|
+
schema.raw_skus.append(sku)
|
|
573
|
+
|
|
574
|
+
# Only claim success when something meaningful was extracted; otherwise
|
|
575
|
+
# the caller falls back to LLM extraction.
|
|
576
|
+
if schema.product_title or schema.general_price or schema.raw_skus:
|
|
577
|
+
# Mirror single-SKU price/stock to top-level convenience fields.
|
|
578
|
+
if not schema.general_price and schema.raw_skus:
|
|
579
|
+
schema.general_price = schema.raw_skus[0].get("price")
|
|
580
|
+
return schema
|
|
581
|
+
return None
|
package/dist/scripts/i18n.py
CHANGED
|
@@ -172,6 +172,8 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
172
172
|
"pub_unsupported_format": "Unsupported file format: {source}. Only PDF, Word, Excel, Image, and URL are supported.",
|
|
173
173
|
"pub_invalid_url": "Failed to fetch URL: {source}. Please check the URL and try again.",
|
|
174
174
|
"pub_url_fetch_failed": "Failed to fetch content from {source}: {error}. Please check the URL and try again.",
|
|
175
|
+
"pub_llm_not_configured": "AI extraction is unavailable: no LLM API key is configured (OPENAI_API_KEY or DASHSCOPE_API_KEY).",
|
|
176
|
+
"pub_llm_not_configured_next": "Configure the LLM API key in the environment, then rerun with --source. Do not bypass the confirm-first flow with manual mode.",
|
|
175
177
|
"pub_create_requires_category": "create_product requires --category-id (leaf category)",
|
|
176
178
|
"pub_create_requires_name": "create_product requires --product-name",
|
|
177
179
|
"pub_create_preview_message": "Please review the product creation details below and confirm before I create the product.",
|
|
@@ -807,6 +809,8 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
|
|
|
807
809
|
"pub_unsupported_format": "不支持的文件格式:{source}。仅支持 PDF、Word、Excel、图片和 URL。",
|
|
808
810
|
"pub_invalid_url": "无法获取 URL:{source}。请检查 URL 后重试。",
|
|
809
811
|
"pub_url_fetch_failed": "无法从 {source} 获取内容:{error}。请检查 URL 后重试。",
|
|
812
|
+
"pub_llm_not_configured": "AI 抽取不可用:未配置 LLM API key(OPENAI_API_KEY 或 DASHSCOPE_API_KEY)。",
|
|
813
|
+
"pub_llm_not_configured_next": "请在环境中配置 LLM API key 后,带 --source 重新运行;不要绕过确认流程改走手动模式。",
|
|
810
814
|
"pub_create_requires_category": "创建商品必须提供 --category-id(叶子类目 ID)",
|
|
811
815
|
"pub_create_requires_name": "创建商品必须提供 --product-name(商品标题)",
|
|
812
816
|
"pub_create_preview_message": "请检查以下商品信息,确认无误后我将创建商品。",
|
|
@@ -327,6 +327,15 @@ def _print_conversational_result(result: Dict[str, Any]) -> None:
|
|
|
327
327
|
print(f"• {action}")
|
|
328
328
|
return
|
|
329
329
|
|
|
330
|
+
# LLM not configured — fail loudly so agents configure the key instead of
|
|
331
|
+
# bypassing the confirm-first flow with manual mode
|
|
332
|
+
if status == "llm_not_configured":
|
|
333
|
+
print(result.get("message", ""))
|
|
334
|
+
print()
|
|
335
|
+
for action in result.get("next_actions", []):
|
|
336
|
+
print(f"• {action}")
|
|
337
|
+
return
|
|
338
|
+
|
|
330
339
|
# Invalid URL
|
|
331
340
|
if status == "invalid_url":
|
|
332
341
|
print(result.get("message", ""))
|
|
@@ -33,8 +33,10 @@ from extraction_engine import (
|
|
|
33
33
|
CRITICAL_FIELDS,
|
|
34
34
|
ExtractionEngine,
|
|
35
35
|
LLMClient,
|
|
36
|
+
LLMNotConfiguredError,
|
|
36
37
|
MiraviaListingSchema,
|
|
37
38
|
check_missing,
|
|
39
|
+
extract_schema_from_tables,
|
|
38
40
|
validate_title,
|
|
39
41
|
)
|
|
40
42
|
from file_reader import extract_text, read_sources, get_excel_row_count
|
|
@@ -1134,7 +1136,25 @@ class PublishModule:
|
|
|
1134
1136
|
|
|
1135
1137
|
source_text = extract_text(content_items)
|
|
1136
1138
|
engine = ExtractionEngine(LLMClient())
|
|
1137
|
-
|
|
1139
|
+
|
|
1140
|
+
# Excel/CSV are structured tables: extract by header rules first so
|
|
1141
|
+
# the mainstream flow never needs an LLM API key. Fall back to LLM
|
|
1142
|
+
# only when rule extraction matches nothing usable (non-table or
|
|
1143
|
+
# unrecognized headers).
|
|
1144
|
+
schema = extract_schema_from_tables(content_items)
|
|
1145
|
+
if schema is None:
|
|
1146
|
+
try:
|
|
1147
|
+
schema = engine.extract(source_text)
|
|
1148
|
+
except LLMNotConfiguredError:
|
|
1149
|
+
# Fail loudly instead of degrading to regex: without LLM the
|
|
1150
|
+
# confirm-first order (title/brand/category before missing-field
|
|
1151
|
+
# summary) cannot be guaranteed, so agents must configure the key
|
|
1152
|
+
# rather than bypass the skill.
|
|
1153
|
+
return {
|
|
1154
|
+
"status": "llm_not_configured",
|
|
1155
|
+
"message": i18n._("pub_llm_not_configured"),
|
|
1156
|
+
"next_actions": [i18n._("pub_llm_not_configured_next")],
|
|
1157
|
+
}
|
|
1138
1158
|
|
|
1139
1159
|
if getattr(self.args, "output_schema", False):
|
|
1140
1160
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miravia_test/miravia-publish-skill",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Miravia Product Publish Skill installer — one command to install the Miravia AI product listing skill into any AI Agent (Qoder / Cursor / Claude Code). Upload materials and let the AI extract data and publish products automatically.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"miravia-publish-skill": "bin/cli.js"
|