@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. 使用 LLM 抽取 `MiraviaListingSchema`;可通过 `--output-schema` 仅查看抽取结果。
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.4
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
- logger.warning("LLM not configured; running heuristic extraction")
185
- return self._heuristic_extract(source_text)
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
@@ -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
- schema = engine.extract(source_text)
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.4",
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"