# -*- coding:utf-8 -*- """商机产线需求挖掘能力(P1):众包需求 → 语义聚类 → TopX/指定类型。 分层边界(pipeline-extension-patterns): · embedding 走 pipeline_service.rag_client.rag_embed_texts(凭据单点在 rag)。 · 向量近邻全部压给 VDB(Milvus)(upapp.rag-vdb),产线侧零向量计算。 · 聚类 = kNN + τ阈值 + 纯 Python 并查集(无原生依赖,Nuitka 友好,确定性可复现)。 · LLM 只做簇命名(purpose=utility),失败规则兜底不崩。 机构隔离(用户定夺 2026-09-11): · 基础数据共享——爬虫需求 + embedding 缓存 collection(opp_demand_emb_cache)全机构共用, 一条需求只嵌一次,跨机构/跨批次复用,成本摊薄。 · 分析结果隔离——批次/快照/类别全部挂 org_id,所有查询按 ctx.org_id 强制过滤。 VDB 协议(实测,详见 rag-module-operations 技能): createcollection/upsert/query(kNN=vector+pagerows)/expr标量过滤/dropcollection; kNN+expr 组合可用;output_fields 可取回 vector(缓存→工作集拷贝)。 """ import asyncio import hashlib import json import logging from .opp_common import get_db, new_id, rows_to_dicts, get_param, get_crawler_config logger = logging.getLogger("pipeline.opp_mining") DIM = 1024 # 共享 embedding 缓存 collection(基础数据,全机构共用) CACHE_COL_DEFAULT = "opp_demand_emb_cache" # 参数默认兜底(params 表优先,禁硬编码为唯一来源) P = { "opp_mine_tau": "0.75", "opp_mine_topk": "30", "opp_mine_min_cluster": "5", "opp_mine_big_split": "150", # 超过此规模的簇质心二次细分 "opp_mine_keep_batches": "3", # 每机构保留最近 N 个批次工作集 collection "opp_embed_batch": "10", # rag /embed 单批上限(实测 dashscope 兼容 10) } # 状态机(非法迁移拒绝,对齐 REPORT_TRANSITIONS 范式) BATCH_TRANSITIONS = { "pulling": {"embedding", "failed", "done"}, "embedding": {"clustering", "failed"}, "clustering": {"naming", "failed"}, "naming": {"done", "failed"}, "done": set(), "failed": {"pulling"}, # 失败可重跑 } def can_batch_transition(cur, nxt): return nxt in BATCH_TRANSITIONS.get(cur, set()) # ══════════════════ 参数 / VDB 客户端 ══════════════════ async def get_mining_params(sor): cfg = {} for k, dv in P.items(): cfg[k] = await get_param(sor, k, dv) cache_col = await get_param(sor, "opp_emb_cache_col", CACHE_COL_DEFAULT) cfg["cache_col"] = cache_col for ik in ("opp_mine_topk", "opp_mine_min_cluster", "opp_mine_big_split", "opp_mine_keep_batches", "opp_embed_batch"): try: cfg[ik] = int(cfg[ik]) except (TypeError, ValueError): cfg[ik] = int(P[ik]) try: cfg["opp_mine_tau"] = float(cfg["opp_mine_tau"]) except (TypeError, ValueError): cfg["opp_mine_tau"] = float(P["opp_mine_tau"]) return cfg async def _vdb_base(sor): recs = await sor.sqlExe("SELECT baseurl FROM upapp WHERE id='rag-vdb'", {}) await sor.sqlExe("COMMIT", {}) return (recs[0].baseurl or "").rstrip("/") if recs else "" async def vdb_post(sor, base, path, payload, timeout=40): """VDB REST 调用(aiohttp)。返回 (ok, dict|错误串)。失败显式报错,禁静默。""" try: import aiohttp async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as s: async with s.post(base + path, json=payload) as r: txt = await r.text() try: d = json.loads(txt) if txt.strip().startswith("{") else {} except Exception: d = {} if r.status != 200 or (d.get("status") and d.get("status") != "SUCCEEDED"): return False, "VDB %s HTTP%d %s" % (path, r.status, (d.get("error") or txt)[:200]) return True, d except Exception as e: return False, "VDB %s 调用失败: %s" % (path, str(e)[:160]) def _vec_rows(d): """解析 VDB 响应的 rows(兼容嵌套 data.rows)。""" data = d.get("data") if isinstance(d, dict) else None if isinstance(data, dict): return data.get("rows") or [] if isinstance(data, list): return data return d.get("rows") or [] if isinstance(d, dict) else [] def demand_vec_id(source, src_id): """需求→稳定向量ID(sha1,≤32字符,可复算幂等)。""" return hashlib.sha1(("%s:%s" % (source or "", src_id or "")).encode("utf-8")).hexdigest()[:32] # ══════════════════ 数据层:快照拉取 ══════════════════ async def pull_demands(sor, batch, ctx, cfg, scope, keyword="", category="", days=365, sources="", limit=200): """从爬虫平台拉需求 → 写 opp_demand_snap(按 batch+src_id 去重)。返回 (n, err)。""" base, token = await get_crawler_config(sor) if not token: return 0, "params 缺 tender_api_token(爬虫平台接入未配置)" org_id = ctx.get("org_id") or "" bid = batch["id"] offset = 0 total = 0 while True: params = {"days": days, "limit": limit, "offset": offset, "record_type": "demand"} if keyword: params["keyword"] = keyword if sources: params["source"] = sources # 复用爬虫只读端点 from .opp_common import crawler_get ok, res = await crawler_get(sor, "/api/demands", params) if not ok: return total, "拉取需求失败@offset%d: %s" % (offset, res) items = (res or {}).get("items") or [] matched = (res or {}).get("matched", 0) for it in items: src_id = str(it.get("id") or it.get("source_id") or "") title = (it.get("title") or "").strip() if not src_id or not title: continue # 指定类型分析:category 二次过滤(爬虫 item_category 模糊) if category and category not in (it.get("item_category") or "") and category not in title: continue snap_id = new_id() vec_id = demand_vec_id(it.get("source"), src_id) await sor.C("opp_demand_snap", { "id": snap_id, "batch_id": bid, "org_id": org_id, "src_id": src_id, "source": it.get("source") or "", "title": title[:500], "item_category": (it.get("item_category") or "")[:128], "budget_wan": it.get("budget_wan") or 0, "url": (it.get("url") or "")[:500], "publish_time": (it.get("publish_time") or "")[:32], "embed_status": "new", }) await sor.sqlExe("COMMIT", {}) # vec_id 暂存到内存映射稍后批量用;这里用 title 去重键 total += 1 offset += len(items) if not items or offset >= matched: break return total, "" # ══════════════════ 数据层:embedding 增量缓存 ══════════════════ async def _ensure_cache_collection(sor, base, col): ok, d = await vdb_post(sor, base, "/v1/createcollection", { "colname": col, "fields": [ {"name": "id", "type": "str", "is_primary": True, "max_length": 64}, {"name": "vector", "type": "fvector", "dim": DIM}, {"name": "text", "type": "str", "max_length": 2000}], "description": "opp demand embedding cache", "metric": "COSINE"}) if not ok and "exist" not in str(d).lower(): return False, d return True, d async def _cache_existing_ids(sor, base, col, ids): """批量查缓存 collection 已存在哪些 id(expr in,每批≤200)。返回 set。""" found = set() for i in range(0, len(ids), 200): chunk = ids[i:i + 200] expr = "id in [%s]" % ",".join('"%s"' % x for x in chunk) ok, d = await vdb_post(sor, base, "/v1/query", {"colname": col, "expr": expr, "output_fields": ["id"]}) if ok: for r in _vec_rows(d): if r.get("id"): found.add(r["id"]) return found async def embed_snapshots(sor, batch, project_id, cfg, on_progress=None): """增量 embedding:缓存缺的才调 rag_embed_texts,upsert 进共享缓存。 返回 (vec_id→vector dict, err)。一条需求只嵌一次(全机构复用)。""" bid = batch["id"] base = await _vdb_base(sor) if not base: return None, "upapp 缺 rag-vdb(向量库未配置)" ok, d = await _ensure_cache_collection(sor, base, cfg["cache_col"]) if not ok: return None, "建缓存 collection 失败: %s" % d snaps = rows_to_dicts(await sor.sqlExe( "SELECT id, source, src_id, title FROM opp_demand_snap WHERE batch_id=${b}$", {"b": bid}), limit=100000) await sor.sqlExe("COMMIT", {}) if not snaps: return None, "批次无需求快照" # 稳定 vec_id + 文本 id_map = {} # snap_id -> vec_id text_map = {} # vec_id -> text for s in snaps: vid = demand_vec_id(s["source"], s["src_id"]) id_map[s["id"]] = vid text_map[vid] = (s["title"] or "(无标题)")[:1900] all_vids = list(set(id_map.values())) # 查缓存已有的 cached = await _cache_existing_ids(sor, base, cfg["cache_col"], all_vids) to_embed = [v for v in all_vids if v not in cached] logger.debug("embed batch=%s total=%d cached=%d new=%d", bid, len(all_vids), len(cached), len(to_embed)) # 缺的调 rag_embed_texts vecs = {} if to_embed: from pipeline_service import rag_client as rc bs = cfg["opp_embed_batch"] texts_to_embed = [text_map[v] for v in to_embed] got, err = await rc.rag_embed_texts(project_id, texts_to_embed, batch_size=bs) if err: return None, "embedding 失败: %s" % err if len(got) != len(to_embed): return None, "embedding 数量不符(%d/%d)" % (len(got), len(to_embed)) # upsert 进缓存 rows = [{"id": to_embed[i], "vector": got[i], "text": texts_to_embed[i]} for i in range(len(to_embed))] for i in range(0, len(rows), 500): ok, d = await vdb_post(sor, base, "/v1/upsert", {"colname": cfg["cache_col"], "data": rows[i:i + 500]}) if not ok: return None, "缓存 upsert 失败: %s" % d for i, v in enumerate(to_embed): vecs[v] = got[i] # 缓存命中的也要取回向量(构建工作集用) if cached: cached = list(cached) for i in range(0, len(cached), 200): chunk = cached[i:i + 200] expr = "id in [%s]" % ",".join('"%s"' % x for x in chunk) ok, d = await vdb_post(sor, base, "/v1/query", {"colname": cfg["cache_col"], "expr": expr, "output_fields": ["id", "vector"]}) if ok: for r in _vec_rows(d): if r.get("id") and isinstance(r.get("vector"), list): vecs[r["id"]] = r["vector"] # 更新快照 embed_status for sid, vid in id_map.items(): st = "cached" if vid in vecs else "failed" await sor.sqlExe("UPDATE opp_demand_snap SET embed_status=${s}$ WHERE id=${i}$", {"s": st, "i": sid}) await sor.sqlExe("COMMIT", {}) return {"id_map": id_map, "text_map": text_map, "vecs": vecs}, "" # ══════════════════ 数据层:批次工作集 + kNN ══════════════════ async def build_batch_collection(sor, base, batch, cfg, embed_res): """从缓存拷向量到批次专属工作集 collection(聚类只在本批内算)。返回 (col, err)。""" col = "opp_mine_%s" % (batch["id"][:16].replace("-", "")) ok, d = await vdb_post(sor, base, "/v1/createcollection", { "colname": col, "fields": [ {"name": "id", "type": "str", "is_primary": True, "max_length": 64}, {"name": "vector", "type": "fvector", "dim": DIM}, {"name": "text", "type": "str", "max_length": 2000}, {"name": "snap_id", "type": "str", "max_length": 32}], "description": "opp mining batch workset", "metric": "COSINE"}) if not ok and "exist" not in str(d).lower(): return None, "建批次 collection 失败: %s" % d vecs = embed_res["vecs"] text_map = embed_res["text_map"] id_map = embed_res["id_map"] # snap_id -> vec_id rows = [] for sid, vid in id_map.items(): if vid in vecs: rows.append({"id": vid, "vector": vecs[vid], "text": text_map.get(vid, ""), "snap_id": sid}) for i in range(0, len(rows), 500): ok, d = await vdb_post(sor, base, "/v1/upsert", {"colname": col, "data": rows[i:i + 500]}) if not ok: return None, "工作集 upsert 失败: %s" % d return col, "" async def knn_graph(sor, base, col, vecs, topk): """每条向量查 topk 邻居(排除自身)。返回 vid -> [(score, vid)]。""" nbr = {} ids = list(vecs.keys()) for vid in ids: ok, d = await vdb_post(sor, base, "/v1/query", { "colname": col, "vector": vecs[vid], "pagerows": topk, "output_fields": ["id"]}) if not ok: return None, "kNN 失败@%s: %s" % (vid, d) rows = _vec_rows(d) nbr[vid] = [(float(r.get("score", 0) or 0), r.get("id")) for r in rows if r.get("id") and r.get("id") != vid] return nbr, "" # ══════════════════ 算法层:并查集聚类 ══════════════════ def _union_find(ids, nbr, tau): parent = {v: v for v in ids} def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(a, b): ra, rb = find(a), find(b) if ra != rb: parent[rb] = ra for v in ids: for sc, nid in nbr.get(v, []): if sc >= tau and nid in parent: union(v, nid) comps = {} for v in ids: comps.setdefault(find(v), []).append(v) return sorted(comps.values(), key=len, reverse=True) def _cos(a, b): return sum(x * y for x, y in zip(a, b)) def _centroid(vecs, members): n = len(members) return [sum(vecs[m][k] for m in members) / n for k in range(len(vecs[members[0]]))] def cluster_vectors(vecs, nbr, tau, min_cluster, big_split): """kNN 图 → τ 并查集 → 大簇二次细分 → 小组质心并入。返回 (final_clusters, other_ids)。 final_clusters: list[list[vid]],other_ids: 未入簇的 vid。""" ids = list(vecs.keys()) comps = _union_find(ids, nbr, tau) # 超大簇质心二次细分(众包标题模板化重复会过度聚合) pre = [] for c in comps: if len(c) > big_split: members = set(c) sub_nbr = {v: [(sc, n) for sc, n in nbr.get(v, []) if n in members] for v in c} pre.extend(_union_find(c, sub_nbr, min(tau + 0.10, 0.95))) else: pre.append(c) big = [c for c in pre if len(c) >= min_cluster] small = [c for c in pre if len(c) < min_cluster] # 小组并入最近大簇(质心相似度 ≥ τ-0.10),否则归其他 other = [] if big and small: cents = [(c, _centroid(vecs, c)) for c in big] for sc_ in small: cc = _centroid(vecs, sc_) best, bs = None, -1 for bc, bcent in cents: sim = _cos(cc, bcent) if sim > bs: best, bs = bc, sim if best is not None and bs >= tau - 0.10: best.extend(sc_) else: other.extend(sc_) else: for c in small: other.extend(c) big.sort(key=len, reverse=True) return big, other # ══════════════════ 算法层:LLM 簇命名(规则兜底)══════════════════ async def name_clusters(sor, batch, clusters, vecs, text_map, cfg, ctx): """为每个簇命名:LLM(utility) 并发调用 + 失败/异常规则兜底。 返回 list[{name, members, centroid_snap, samples, evidence}]。 性能实测教训(2026-09-11):42 簇串行 LLM 命名 540s 占批次全程 551s—— 改并发(Semaphore 限 5,防上游限流)后命名阶段应降至 ~1/5。 """ total = len(vecs) # 阶段1(纯CPU,串行):算质心/样例/排名 prepared = [] for ci, members in enumerate(clusters): cent = _centroid(vecs, members) sims = sorted(((_cos(vecs[m], cent), m) for m in members), reverse=True) samples = [] seen = set() for _, m in sims: t = text_map.get(m, "") if t and t not in seen: seen.add(t) samples.append(t) if len(samples) >= 8: break prepared.append({ "rank": ci + 1, "members": members, "size": len(members), "share": round(len(members) / total, 4) if total else 0, "centroid_vid": sims[0][1] if sims else "", "samples": samples[:8], "name": "", "evidence": "rule", }) # 阶段2:LLM 命名(并发限5;单簇失败不影响整体,走规则兜底) from pipeline_service.llm_bridge import llm_call sem = asyncio.Semaphore(5) async def _name_one(item): async with sem: try: prompt = ( "以下是同一类软件众包需求的标题样例(已去重):\n" + "\n".join("- " + s for s in item["samples"][:8]) + "\n\n请用不超过12个汉字给这一类需求起一个简洁准确的类别名(如「微信小程序开发」" "「企业网站定制」「AI短视频制作」),只输出类别名本身,不要解释、不要标点。") raw = await llm_call(prompt, purpose="utility", timeout=60, org_id=ctx.get("org_id") or "0", user_id=ctx.get("user_id") or "", session_id=ctx.get("session_id") or "") name = (raw or "").strip().splitlines()[0][:40] if raw else "" # LLM 可能输出带引号/序号,清洗 name = name.strip("「」\"'。.  ") if name: item["name"] = name item["evidence"] = "llm" except Exception as e: logger.debug("LLM 命名失败,走规则兜底: %s", e) await asyncio.gather(*(_name_one(it) for it in prepared)) # 阶段3:规则兜底 for it in prepared: if not it["name"]: it["name"] = _rule_name(it["samples"]) return prepared def _rule_name(samples): """规则兜底:取样例标题的最长公共前缀片段或首条去模板词。""" if not samples: return "未命名类别" # 去常见模板前缀 strip = ["我需要", "需要", "其他", "服务需求", "服务采购", "服务", "需求", "采购", "合作"] def clean(t): for s in strip: t = t.replace(s, "") return t.strip() cleaned = [clean(s) for s in samples if clean(s)] if not cleaned: return samples[0][:20] # 取最高频的 2-gram 词 from collections import Counter cnt = Counter() for t in cleaned: for n in (4, 3, 2): for i in range(len(t) - n + 1): cnt[t[i:i + n]] += 1 if cnt: top = cnt.most_common(1)[0][0] return top[:20] return cleaned[0][:20]