|
@@ -0,0 +1,453 @@
|
|
|
|
|
+"""
|
|
|
|
|
+把「维度提取表」源数据(input/dimension_extraction_forms.json)拆解成知识导入接口的
|
|
|
|
|
+入参并上传。每个维度(姿势 / 身材体型 / 服装单品 / …)对应一条知识。
|
|
|
|
|
+
|
|
|
|
|
+字段映射(对齐示例 curl):
|
|
|
|
|
+ source.id ← 随机数(src_ + 13 位随机串,每次运行现生成)
|
|
|
|
|
+ source.source_type ← 固定 "other"
|
|
|
|
|
+ source.title/author← 固定 null
|
|
|
|
|
+ title ← "{维度}怎么解构" (如 姿势 → "姿势怎么解构")
|
|
|
|
|
+ content ← 把该维度全部字段(别名/推荐结果形态/提取方式/提取指令要点/通过标准/
|
|
|
|
|
+ 容忍/否决项/禁止/说明/视频提取)用大模型「变成人话」,重点突出
|
|
|
|
|
+ 【推荐结果形态】与【提取方式】;别名等关键词与推荐形态/方式原样保留。
|
|
|
|
|
+ dim_attributes ← 固定 ["what-制作解构"] dim_creations ← 固定 ["解构"]
|
|
|
|
|
+ custom_ext ← 由「别名」逐个生成 {key, type:"str", value}(key=value=别名)
|
|
|
|
|
+
|
|
|
|
|
+LLM 润色(默认开启):借助大模型把 title 与 content 写成自然、专业、好读的人话。
|
|
|
|
|
+ 长字段(指令要点/通过标准/否决项…)是「知识提取说明」,允许改写成人话;但别名关键词、
|
|
|
|
|
+ 推荐结果形态、提取方式必须原样出现(校验器兜底,缺则重试),不丢关键信息。
|
|
|
|
|
+ 关掉润色用 --no-enhance(只用结构化基线正文)。
|
|
|
|
|
+
|
|
|
|
|
+输出:每次运行在 stages/output/ 下按时间戳建一个子文件夹(如 output/20260709_144530/),
|
|
|
|
|
+ 单维度一个 JSON + 汇总 _all.json,方便按时间回看每次执行的拆解结果。
|
|
|
|
|
+
|
|
|
|
|
+三条执行指令:
|
|
|
|
|
+ # 1) 拆解入参 + 大模型润色,结果写 output/时间戳/,并打印完整 payload
|
|
|
|
|
+ python stages/import_knowledge.py --dry-run -v
|
|
|
|
|
+ # 1') 不想看打印就去掉 -v:一样润色 + 落时间戳文件夹,只是不刷屏
|
|
|
|
|
+ python stages/import_knowledge.py --dry-run
|
|
|
|
|
+
|
|
|
|
|
+ # 2) 单个上传(--only 传维度名,可逗号分隔多个)
|
|
|
|
|
+ python stages/import_knowledge.py --only 姿势
|
|
|
|
|
+
|
|
|
|
|
+ # 3) 批量上传(默认全量,input 里所有维度)
|
|
|
|
|
+ python stages/import_knowledge.py
|
|
|
|
|
+
|
|
|
|
|
+ 其它:--input <源文件> --output <目录> --api-url <后端根地址>
|
|
|
|
|
+ --from-output(直接传 output 里已审阅的 payload,不重建/不润色)
|
|
|
|
|
+ --no-enhance(关闭润色) --model <模型> --delay <毫秒,调用间隔>
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+import argparse
|
|
|
|
|
+import asyncio
|
|
|
|
|
+import json
|
|
|
|
|
+import logging
|
|
|
|
|
+import random
|
|
|
|
|
+import string
|
|
|
|
|
+import sys
|
|
|
|
|
+import time
|
|
|
|
|
+from datetime import datetime
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+import requests
|
|
|
|
|
+
|
|
|
|
|
+# stages/ 归档在子目录;补 Agent 根到 sys.path 以 import agent.llm / examples.*
|
|
|
|
|
+PROJECT_ROOT = Path(__file__).resolve().parents[3] # …/Agent
|
|
|
|
|
+sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
|
+
|
|
|
|
|
+from dotenv import load_dotenv
|
|
|
|
|
+load_dotenv()
|
|
|
|
|
+
|
|
|
|
|
+# ── 配置(对齐示例 curl)──────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+HERE = Path(__file__).resolve().parent
|
|
|
|
|
+DEFAULT_INPUT = HERE / "input" / "dimension_extraction_forms.json"
|
|
|
|
|
+DEFAULT_OUTPUT = HERE / "output"
|
|
|
|
|
+
|
|
|
|
|
+DEFAULT_API_URL = "http://47.236.83.130:8001"
|
|
|
|
|
+INGEST_ENDPOINT = "/api/v1/knowledge/ingest"
|
|
|
|
|
+
|
|
|
|
|
+DIM_ATTRIBUTES = ["what-制作解构"]
|
|
|
|
|
+DIM_CREATIONS = ["解构"]
|
|
|
|
|
+
|
|
|
|
|
+KEY_ALIAS = "别名"
|
|
|
|
|
+KEY_FORM = "推荐结果形态" # 图 #3 圈中之一:重点突出
|
|
|
|
|
+KEY_METHOD = "提取方式" # 图 #3 圈中之一:重点突出
|
|
|
|
|
+KEY_VIDEO = "视频提取"
|
|
|
|
|
+
|
|
|
|
|
+# content 里按此顺序铺陈的字段(视频提取单独嵌套处理)
|
|
|
|
|
+CONTENT_FIELDS = [
|
|
|
|
|
+ KEY_ALIAS, KEY_FORM, KEY_METHOD,
|
|
|
|
|
+ "提取指令要点", "通过标准", "容忍", "否决项", "禁止", "说明",
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+DEFAULT_MODEL = "anthropic/claude-sonnet-4-6"
|
|
|
|
|
+
|
|
|
|
|
+# ── 日志 ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+logging.basicConfig(
|
|
|
|
|
+ level=logging.INFO,
|
|
|
|
|
+ format="%(asctime)s [%(levelname)s] %(message)s",
|
|
|
|
|
+ datefmt="%H:%M:%S",
|
|
|
|
|
+)
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── 组装 ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def gen_source_id():
|
|
|
|
|
+ """随机 source.id,形如示例的 src_mrd37w05ppua7(src_ + 13 位小写数字串)。"""
|
|
|
|
|
+ rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=13))
|
|
|
|
|
+ return f"src_{rand}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _rand_num(k=8):
|
|
|
|
|
+ """一个随机数(k 位数字串),用于拼 knowledge_id。"""
|
|
|
|
|
+ return "".join(random.choices(string.digits, k=k))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _fmt_value(v):
|
|
|
|
|
+ if isinstance(v, list):
|
|
|
|
|
+ return "、".join(str(x) for x in v)
|
|
|
|
|
+ return str(v).strip()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_content(name, data):
|
|
|
|
|
+ """结构化基线正文——列出该维度全部字段(润色前的草稿,也是 --no-enhance 的产物)。"""
|
|
|
|
|
+ lines = [f"「{name}」的知识提取维度说明:"]
|
|
|
|
|
+ for field in CONTENT_FIELDS:
|
|
|
|
|
+ if field in data and data[field] not in (None, "", []):
|
|
|
|
|
+ lines.append(f"- {field}:{_fmt_value(data[field])}")
|
|
|
|
|
+ video = data.get(KEY_VIDEO)
|
|
|
|
|
+ if isinstance(video, dict) and video:
|
|
|
|
|
+ lines.append("- 视频提取:")
|
|
|
|
|
+ for k, v in video.items():
|
|
|
|
|
+ if v not in (None, "", []):
|
|
|
|
|
+ lines.append(f" · {k}:{_fmt_value(v)}")
|
|
|
|
|
+ return "\n".join(lines)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_custom_ext(data):
|
|
|
|
|
+ """custom_ext ← 别名 列表,逐个 {key, type:"str", value}(key=value=别名,保序去重)。"""
|
|
|
|
|
+ ext, seen = [], set()
|
|
|
|
|
+ for alias in (data.get(KEY_ALIAS) or []):
|
|
|
|
|
+ alias = (alias or "").strip()
|
|
|
|
|
+ if alias and alias not in seen:
|
|
|
|
|
+ seen.add(alias)
|
|
|
|
|
+ ext.append({"key": alias, "type": "str", "value": alias})
|
|
|
|
|
+ return ext
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_payload(name, data):
|
|
|
|
|
+ sid = gen_source_id()
|
|
|
|
|
+ return {
|
|
|
|
|
+ "knowledge_id": f"{sid}-{_rand_num()}", # 外层,与 source 同级;= source.id + 一个随机数
|
|
|
|
|
+ "source": {
|
|
|
|
|
+ "id": sid,
|
|
|
|
|
+ "source_type": "other",
|
|
|
|
|
+ "title": None,
|
|
|
|
|
+ "author": None,
|
|
|
|
|
+ },
|
|
|
|
|
+ "title": f"{name}怎么解构",
|
|
|
|
|
+ "content": build_content(name, data),
|
|
|
|
|
+ "dim_attributes": DIM_ATTRIBUTES,
|
|
|
|
|
+ "dim_creations": DIM_CREATIONS,
|
|
|
|
|
+ "custom_ext": build_custom_ext(data),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── LLM 润色(长字段可改写成人话;关键词/推荐形态/提取方式原样保留)────────────────
|
|
|
|
|
+
|
|
|
|
|
+ENHANCE_SYSTEM = (
|
|
|
|
|
+ "你是知识库文案润色器。给你一个「内容制作」里某个解构维度的结构化提取说明(维度名、别名、"
|
|
|
|
|
+ "推荐结果形态、提取方式,以及提取指令要点/通过标准/容忍/否决项/禁止/说明/视频提取等长字段)与一版基线草稿,"
|
|
|
|
|
+ "请把它整理成一条自然、专业、好读的『该维度怎么解构』知识条目,输出 title 和 content。\n\n"
|
|
|
|
|
+ "写作要求:\n"
|
|
|
|
|
+ "1. 把冗长的指令/标准/否决项等用人话讲清楚(可改写措辞、合并同类、分点),让人一看就懂"
|
|
|
|
|
+ "『这个维度是什么、该提取成什么、怎么提取、什么算通过、什么会被否决』。\n"
|
|
|
|
|
+ "2. 重点突出【推荐结果形态】和【提取方式】——这是该维度最核心的两点,要在正文里清楚点出。\n"
|
|
|
|
|
+ "3. 必须保留的关键信息(原样出现,不得改写/翻译/漏掉):全部【别名】词、全部【推荐结果形态】、【提取方式】。\n"
|
|
|
|
|
+ "4. 若有【视频提取】,也用一小段说清楚(载体/工具/结果形态)。\n"
|
|
|
|
|
+ "5. title 用一句话:『{维度}怎么解构』或语义等价的简洁专业说法(需含维度名与「解构」)。\n"
|
|
|
|
|
+ '只输出一个 JSON 对象: {"title": "...", "content": "..."},不要任何额外文字或解释。'
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _build_enhance_user(name, data, base):
|
|
|
|
|
+ return (
|
|
|
|
|
+ f"【维度】{name}\n"
|
|
|
|
|
+ f"【结构化数据(原始)】\n{json.dumps(data, ensure_ascii=False, indent=2)}\n\n"
|
|
|
|
|
+ f"【基线草稿 title】{base['title']}\n"
|
|
|
|
|
+ f"【基线草稿 content】\n{base['content']}\n\n"
|
|
|
|
|
+ "请输出润色后的 JSON。"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# 关键词包含判断前的规范化:抹平全角/半角括号等标点差异(LLM 常把 (线框) 写成(线框)),
|
|
|
|
|
+# 只用于「是否保留了该词」的校验,不改动实际落盘正文。
|
|
|
|
|
+_PUNCT_NORM = str.maketrans({
|
|
|
|
|
+ "(": "(", ")": ")", "[": "[", "]": "]", "{": "{", "}": "}",
|
|
|
|
|
+ "/": "/", " ": " ",
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _norm(s):
|
|
|
|
|
+ return s.translate(_PUNCT_NORM)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _make_validator(name, data):
|
|
|
|
|
+ aliases = [t.strip() for t in (data.get(KEY_ALIAS) or []) if t and t.strip()]
|
|
|
|
|
+ forms = [t.strip() for t in (data.get(KEY_FORM) or []) if t and t.strip()]
|
|
|
|
|
+ method = (data.get(KEY_METHOD) or "").strip()
|
|
|
|
|
+
|
|
|
|
|
+ def _v(d):
|
|
|
|
|
+ if not isinstance(d, dict):
|
|
|
|
|
+ return "需 JSON 对象"
|
|
|
|
|
+ title = (d.get("title") or "").strip()
|
|
|
|
|
+ content = (d.get("content") or "").strip()
|
|
|
|
|
+ if not title:
|
|
|
|
|
+ return "title 缺失"
|
|
|
|
|
+ if name not in title or "解构" not in title:
|
|
|
|
|
+ return f"title 需含维度名「{name}」与「解构」"
|
|
|
|
|
+ if not content:
|
|
|
|
|
+ return "content 缺失"
|
|
|
|
|
+ nc = _norm(content)
|
|
|
|
|
+ miss_alias = [t for t in aliases if _norm(t) not in nc]
|
|
|
|
|
+ if miss_alias:
|
|
|
|
|
+ return "content 丢了别名(须原样保留):" + "、".join(miss_alias[:10])
|
|
|
|
|
+ miss_form = [t for t in forms if _norm(t) not in nc]
|
|
|
|
|
+ if miss_form:
|
|
|
|
|
+ return "content 缺推荐结果形态(须原样保留):" + "、".join(miss_form)
|
|
|
|
|
+ if method and _norm(method) not in nc:
|
|
|
|
|
+ return f"content 缺提取方式「{method}」(须原样保留)"
|
|
|
|
|
+ return None
|
|
|
|
|
+ return _v
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _enhance_one(name, data, payload, llm_call, model, sem):
|
|
|
|
|
+ from examples.process_pipeline.script.llm_helper import call_llm_with_retry
|
|
|
|
|
+ base = {"title": payload["title"], "content": payload["content"]}
|
|
|
|
|
+ messages = [{"role": "system", "content": ENHANCE_SYSTEM},
|
|
|
|
|
+ {"role": "user", "content": _build_enhance_user(name, data, base)}]
|
|
|
|
|
+ async with sem:
|
|
|
|
|
+ d, cost = await call_llm_with_retry(
|
|
|
|
|
+ llm_call=llm_call, messages=messages, model=model,
|
|
|
|
|
+ temperature=0.3, max_tokens=2500,
|
|
|
|
|
+ validate_fn=_make_validator(name, data), task_name=f"Enhance[{name}]")
|
|
|
|
|
+ if d: # 校验通过才覆盖;失败则沿用基线草稿(不丢数据)
|
|
|
|
|
+ payload["title"] = d["title"].strip()
|
|
|
|
|
+ payload["content"] = d["content"].strip()
|
|
|
|
|
+ return cost, True
|
|
|
|
|
+ logger.warning(" ⚠️ [%s] 润色失败,沿用基线草稿", name)
|
|
|
|
|
+ return cost, False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def enhance_all(items, payloads, model, concurrency=5):
|
|
|
|
|
+ from agent.llm.openrouter import create_openrouter_llm_call
|
|
|
|
|
+ llm_call = create_openrouter_llm_call(model=model)
|
|
|
|
|
+ sem = asyncio.Semaphore(concurrency)
|
|
|
|
|
+ results = await asyncio.gather(*[
|
|
|
|
|
+ _enhance_one(name, data, payloads[i], llm_call, model, sem)
|
|
|
|
|
+ for i, (name, data) in enumerate(items)])
|
|
|
|
|
+ total_cost = sum(c for c, _ in results)
|
|
|
|
|
+ ok = sum(1 for _, good in results if good)
|
|
|
|
|
+ logger.info("🪄 LLM 润色完成:%d/%d 成功 成本 $%.4f", ok, len(items), total_cost)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── 输出落盘(按时间戳建子文件夹)──────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def write_outputs(output_base, items, payloads):
|
|
|
|
|
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
+ run_dir = Path(output_base) / ts
|
|
|
|
|
+ run_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ combined = []
|
|
|
|
|
+ for (name, _), payload in zip(items, payloads):
|
|
|
|
|
+ (run_dir / f"{name}.json").write_text(
|
|
|
|
|
+ json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
+ combined.append({"category": name, "payload": payload})
|
|
|
|
|
+ (run_dir / "_all.json").write_text(
|
|
|
|
|
+ json.dumps(combined, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
+ logger.info("📁 已写出 %d 个 payload → %s", len(payloads), run_dir)
|
|
|
|
|
+ return run_dir
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def resolve_read_dir(output_base):
|
|
|
|
|
+ """--from-output 读取目录:优先取 output_base 下最新的时间戳子文件夹;
|
|
|
|
|
+ 若 output_base 直接就是含 {name}.json 的目录(用户显式指定某次运行)则用它本身。"""
|
|
|
|
|
+ base = Path(output_base)
|
|
|
|
|
+ if not base.is_dir():
|
|
|
|
|
+ logger.error("output 目录不存在:%s(先跑一次 --dry-run 生成)", base)
|
|
|
|
|
+ sys.exit(1)
|
|
|
|
|
+ subs = [d for d in base.iterdir()
|
|
|
|
|
+ if d.is_dir() and any(p.stem != "_all" for p in d.glob("*.json"))]
|
|
|
|
|
+ if subs:
|
|
|
|
|
+ return max(subs, key=lambda d: d.name) # 时间戳名可按字典序取最新
|
|
|
|
|
+ if any(p.stem != "_all" for p in base.glob("*.json")):
|
|
|
|
|
+ return base
|
|
|
|
|
+ logger.error("output 下未找到任何 payload:%s", base)
|
|
|
|
|
+ sys.exit(1)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def load_payloads_from_output(output_base, only):
|
|
|
|
|
+ """从 output 读回已审阅的 payload(每维度一个 {name}.json)。返回 [(name, payload)]。"""
|
|
|
|
|
+ read_dir = resolve_read_dir(output_base)
|
|
|
|
|
+ logger.info("📂 从已审阅目录读取:%s", read_dir)
|
|
|
|
|
+ if only:
|
|
|
|
|
+ names = [c.strip() for c in only if c.strip()]
|
|
|
|
|
+ else:
|
|
|
|
|
+ names = sorted(p.stem for p in read_dir.glob("*.json") if p.stem != "_all")
|
|
|
|
|
+ items = []
|
|
|
|
|
+ for name in names:
|
|
|
|
|
+ pf = read_dir / f"{name}.json"
|
|
|
|
|
+ if not pf.is_file():
|
|
|
|
|
+ logger.warning(" ⚠️ 该目录缺 %s.json,跳过", name)
|
|
|
|
|
+ continue
|
|
|
|
|
+ items.append((name, json.loads(pf.read_text(encoding="utf-8"))))
|
|
|
|
|
+ return items
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── 单条写入 ──────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def ingest_one(api_url, payload, dry_run):
|
|
|
|
|
+ """调用导入接口写入一条知识,返回 (success, info_message, knowledge_id)。"""
|
|
|
|
|
+ if dry_run:
|
|
|
|
|
+ return True, "(dry-run, skipped)", None
|
|
|
|
|
+
|
|
|
|
|
+ url = api_url.rstrip("/") + INGEST_ENDPOINT
|
|
|
|
|
+ try:
|
|
|
|
|
+ resp = requests.post(url, json=payload, timeout=30)
|
|
|
|
|
+ if resp.status_code in (200, 201):
|
|
|
|
|
+ try:
|
|
|
|
|
+ kid = resp.json().get("knowledge_id", "?")
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ kid = "?"
|
|
|
|
|
+ return True, f"knowledge_id={kid}", kid
|
|
|
|
|
+ try:
|
|
|
|
|
+ detail = resp.json().get("detail", resp.text[:300])
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ detail = resp.text[:300]
|
|
|
|
|
+ return False, f"HTTP {resp.status_code}: {detail}", None
|
|
|
|
|
+ except requests.Timeout:
|
|
|
|
|
+ return False, "超时(30s)", None
|
|
|
|
|
+ except requests.RequestException as exc:
|
|
|
|
|
+ return False, str(exc), None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── 主循环 ────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def load_categories(input_path, only=None):
|
|
|
|
|
+ """读源 JSON,返回有序 [(name, data)]。only 非空时按维度名精确筛选。"""
|
|
|
|
|
+ with open(input_path, "r", encoding="utf-8") as f:
|
|
|
|
|
+ raw = json.load(f)
|
|
|
|
|
+ items = list(raw.items())
|
|
|
|
|
+ if only:
|
|
|
|
|
+ want = [c.strip() for c in only if c.strip()]
|
|
|
|
|
+ missing = [c for c in want if c not in raw]
|
|
|
|
|
+ if missing:
|
|
|
|
|
+ logger.warning("--only 中 %d 个维度不存在(跳过):%s", len(missing), ",".join(missing))
|
|
|
|
|
+ items = [(n, d) for n, d in items if n in want]
|
|
|
|
|
+ return items
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def run(input_path, output_dir, api_url, dry_run, verbose, delay_ms, only, enhance, model,
|
|
|
|
|
+ from_output=False):
|
|
|
|
|
+ mode_tag = " [DRY-RUN]" if dry_run else ""
|
|
|
|
|
+
|
|
|
|
|
+ if from_output:
|
|
|
|
|
+ # 直接上传 output 里已审阅的 payload,不重建/不润色(保证上传=所见)
|
|
|
|
|
+ loaded = load_payloads_from_output(output_dir, only)
|
|
|
|
|
+ if not loaded:
|
|
|
|
|
+ logger.error("output 中未匹配到任何 payload"); sys.exit(1)
|
|
|
|
|
+ items = [(name, None) for name, _ in loaded]
|
|
|
|
|
+ payloads = [p for _, p in loaded]
|
|
|
|
|
+ logger.info("发现 %d 个已审阅 payload。目标接口:%s%s", len(payloads), api_url, mode_tag)
|
|
|
|
|
+ else:
|
|
|
|
|
+ items = load_categories(input_path, only)
|
|
|
|
|
+ if not items:
|
|
|
|
|
+ logger.error("源文件中未匹配到任何维度:%s%s", input_path,
|
|
|
|
|
+ f"(--only {','.join(only)})" if only else "")
|
|
|
|
|
+ sys.exit(1)
|
|
|
|
|
+ enh_tag = " [ENHANCE]" if enhance else " [NO-ENHANCE]"
|
|
|
|
|
+ logger.info("发现 %d 个维度。源:%s 目标接口:%s%s%s",
|
|
|
|
|
+ len(items), input_path, api_url, mode_tag, enh_tag)
|
|
|
|
|
+ payloads = [build_payload(name, data) for name, data in items]
|
|
|
|
|
+ if enhance:
|
|
|
|
|
+ asyncio.run(enhance_all(items, payloads, model))
|
|
|
|
|
+ # 落盘(dry-run 与真实上传都留一份记录,按时间戳分文件夹便于回看)
|
|
|
|
|
+ write_outputs(output_dir, items, payloads)
|
|
|
|
|
+
|
|
|
|
|
+ ok_count = fail_count = 0
|
|
|
|
|
+ for (name, _), payload in zip(items, payloads):
|
|
|
|
|
+ n_ext = len(payload["custom_ext"])
|
|
|
|
|
+ if dry_run and verbose:
|
|
|
|
|
+ print(f"\n{'=' * 60}")
|
|
|
|
|
+ print(f"[{name}] source.id={payload['source']['id']}")
|
|
|
|
|
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
|
+
|
|
|
|
|
+ ok, msg, _ = ingest_one(api_url, payload, dry_run)
|
|
|
|
|
+ icon = "✓" if ok else "✗"
|
|
|
|
|
+ level = logging.INFO if ok else logging.WARNING
|
|
|
|
|
+ logger.log(level, " %s %-8s title=%r ext=%d %s",
|
|
|
|
|
+ icon, name, payload["title"], n_ext, msg)
|
|
|
|
|
+
|
|
|
|
|
+ if ok:
|
|
|
|
|
+ ok_count += 1
|
|
|
|
|
+ else:
|
|
|
|
|
+ fail_count += 1
|
|
|
|
|
+ if delay_ms > 0 and not dry_run:
|
|
|
|
|
+ time.sleep(delay_ms / 1000)
|
|
|
|
|
+
|
|
|
|
|
+ logger.info("完成。成功=%d 失败=%d 合计=%d", ok_count, fail_count, ok_count)
|
|
|
|
|
+ if fail_count:
|
|
|
|
|
+ sys.exit(1)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def main():
|
|
|
|
|
+ parser = argparse.ArgumentParser(
|
|
|
|
|
+ description="把维度提取表源数据拆解成知识接口入参(默认大模型润色)并上传",
|
|
|
|
|
+ formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
|
|
+ )
|
|
|
|
|
+ parser.add_argument("--input", default=str(DEFAULT_INPUT), metavar="PATH",
|
|
|
|
|
+ help=f"源 JSON 路径(默认:{DEFAULT_INPUT})")
|
|
|
|
|
+ parser.add_argument("--output", default=str(DEFAULT_OUTPUT), metavar="DIR",
|
|
|
|
|
+ help=f"payload 落盘根目录,每次运行下建时间戳子文件夹(默认:{DEFAULT_OUTPUT})")
|
|
|
|
|
+ parser.add_argument("--api-url", default=DEFAULT_API_URL, metavar="URL",
|
|
|
|
|
+ help=f"后端 API 根地址(默认:{DEFAULT_API_URL})")
|
|
|
|
|
+ parser.add_argument("--dry-run", action="store_true",
|
|
|
|
|
+ help="仅拆解 + 组装(+ 润色 + 落盘),不实际调用接口")
|
|
|
|
|
+ parser.add_argument("--verbose", "-v", action="store_true",
|
|
|
|
|
+ help="打印完整 payload JSON(不影响是否润色)")
|
|
|
|
|
+ parser.add_argument("--only", default=None, metavar="C1,C2",
|
|
|
|
|
+ help="只处理指定维度(逗号分隔维度名,如 姿势,表情)")
|
|
|
|
|
+ parser.add_argument("--from-output", action="store_true",
|
|
|
|
|
+ help="直接上传 output 最新时间戳目录里已审阅的 payload(不重建/不润色)")
|
|
|
|
|
+ parser.add_argument("--enhance", dest="enhance", action="store_true", default=True,
|
|
|
|
|
+ help="用大模型润色 title/content(默认开启)")
|
|
|
|
|
+ parser.add_argument("--no-enhance", dest="enhance", action="store_false",
|
|
|
|
|
+ help="关闭润色,只用结构化基线正文")
|
|
|
|
|
+ parser.add_argument("--model", default=DEFAULT_MODEL, metavar="MODEL",
|
|
|
|
|
+ help=f"润色用模型(默认:{DEFAULT_MODEL})")
|
|
|
|
|
+ parser.add_argument("--delay", type=int, default=100, metavar="MS",
|
|
|
|
|
+ help="两次 API 调用之间的间隔毫秒数(默认:100)")
|
|
|
|
|
+
|
|
|
|
|
+ args = parser.parse_args()
|
|
|
|
|
+ only = ([c.strip() for c in args.only.split(",") if c.strip()]
|
|
|
|
|
+ if args.only else None)
|
|
|
|
|
+ run(
|
|
|
|
|
+ input_path=args.input,
|
|
|
|
|
+ output_dir=args.output,
|
|
|
|
|
+ api_url=args.api_url,
|
|
|
|
|
+ dry_run=args.dry_run,
|
|
|
|
|
+ verbose=args.verbose,
|
|
|
|
|
+ delay_ms=args.delay,
|
|
|
|
|
+ only=only,
|
|
|
|
|
+ enhance=args.enhance,
|
|
|
|
|
+ model=args.model,
|
|
|
|
|
+ from_output=args.from_output,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ main()
|