459 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding:utf-8 -*-
"""P2-③ 类内共性提取引擎:原子化 → 归一化 → 能力域 → 覆盖率。
流程(对单个 cluster 按需执行,用户/agent 触发,不做全批自动跑——成本控制):
1. 原子化:类内文档按 20/批 LLM 提取原子需求(并发 Semaphore 3,purpose=utility),
落 opp_demand_atoms(snap×atom 一行)。
2. 归一化:原子文本向量化(rag embed 共享通道)→ 簇内 kNN + τ2(默认0.85) 并查集
得候选组 → **LLM 裁决**组内是否同一标准需求(语义铁律:相似度只当触发器)→
标准需求命名 → opp_std_demands。
3. 能力域:LLM 把标准需求分组到能力域(每簇一次调用)→ opp_demand_domains + 回填 domain_id。
4. 覆盖率:doc_count = 标准需求覆盖的 distinct snap 数;coverage = doc_count/类内文档数;
tier:core≥0.6 / ext≥0.4 / personal(阈值走 params:opp_cov_core/opp_cov_ext)。
状态:opp_clusters.normalize_status(init/running/done/failed + error 落 naming_evidence 旁路字段
normalize_error——ALTER 补列)。幂等:重跑先清该簇旧 atoms/std/domains 再算。
"""
import asyncio
import json
import logging
from .opp_common import get_db, new_id, rows_to_dicts, get_param
from .opp_mining import _cos, _centroid, demand_vec_id
logger = logging.getLogger("pipeline.opp_normalize")
# 客户端硬超时(秒):llm_call 的 _timeout 只透传给治理端点,客户端 aiohttp 无超时——
# 上游挂起时客户端会无限等(实测 atomize 卡 6 分钟 atoms=0)。wait_for 快速失败+重试。
LLM_CLIENT_TIMEOUT = 120
LLM_RETRIES = 2
async def _llm(prompt, ctx, timeout=LLM_CLIENT_TIMEOUT):
"""llm_call 客户端超时包装:wait_for + 指数退避重试,全失败抛 RuntimeError。"""
from pipeline_service.llm_bridge import llm_call
last = None
for a in range(LLM_RETRIES):
try:
return await asyncio.wait_for(
llm_call(prompt, purpose="utility", timeout=timeout,
org_id=ctx.get("org_id") or "0",
user_id=ctx.get("user_id") or "",
session_id=ctx.get("session_id") or ""),
timeout=timeout + 10)
except Exception as e:
last = e
if a < LLM_RETRIES - 1:
await asyncio.sleep(2 ** a)
raise RuntimeError("LLM 调用失败(%d次): %s" % (LLM_RETRIES, str(last)[:150]))
P = {
"opp_norm_atom_batch": "20", # 原子化每批文档数
"opp_norm_tau2": "0.85", # 归一化相似度触发阈值
"opp_norm_topk": "10", # 原子 kNN 邻居数
"opp_cov_core": "0.6", # 核心共性阈值
"opp_cov_ext": "0.4", # 扩展共性阈值
}
async def get_norm_params(sor):
cfg = {}
for k, dv in P.items():
cfg[k] = await get_param(sor, k, dv)
for ik in ("opp_norm_atom_batch", "opp_norm_topk"):
try:
cfg[ik] = int(cfg[ik])
except (TypeError, ValueError):
cfg[ik] = int(P[ik])
for fk in ("opp_norm_tau2", "opp_cov_core", "opp_cov_ext"):
try:
cfg[fk] = float(cfg[fk])
except (TypeError, ValueError):
cfg[fk] = float(P[fk])
return cfg
async def _set_norm_status(sor, cluster_id, status, err=""):
await sor.sqlExe(
"UPDATE opp_clusters SET normalize_status=${s}$, normalize_error=${e}$ WHERE id=${c}$",
{"s": status, "e": err[:500], "c": cluster_id})
await sor.sqlExe("COMMIT", {})
async def _load_cluster(sor, cluster_id):
recs = await sor.sqlExe(
"SELECT c.*, b.org_id AS b_org FROM opp_clusters c "
"LEFT JOIN opp_mining_batches b ON b.id=c.batch_id WHERE c.id=${c}$",
{"c": cluster_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return None
return rows_to_dicts(recs, limit=1)[0]
async def _cluster_snaps(sor, cluster_id):
recs = await sor.sqlExe(
"SELECT id, title, source, src_id FROM opp_demand_snap WHERE cluster_id=${c}$",
{"c": cluster_id})
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs, limit=5000)
# ══════════════════ 1. 原子化 ══════════════════
async def _atomize(sor, snaps, cfg, ctx):
"""LLM 批量提取原子需求。返回 [(snap_id, atom_text)]。"""
from pipeline_service.llm_bridge import llm_call
sem = asyncio.Semaphore(3)
bs = cfg["opp_norm_atom_batch"]
results = []
lock = asyncio.Lock()
async def _one(chunk):
titles = "\n".join("%d. %s" % (i + 1, s["title"]) for i, s in enumerate(chunk))
prompt = (
"以下是同一类软件众包需求的标题(编号列表)。从每条标题提取它表达的**原子软件需求**"
"(如「微信小程序开发」「考勤记录」「合同到期提醒」),一条标题可提取1-3个原子需求,"
"无法判断的跳过。\n" + titles +
"\n\n只输出 JSON 数组,元素为 {\"no\": 编号, \"atom\": \"原子需求文本\"},"
"不要解释、不要 markdown 代码块。")
async with sem:
try:
raw = await _llm(prompt, ctx, timeout=90)
raw = (raw or "").strip()
if raw.startswith("```"):
raw = raw.strip("`")
if raw.lower().startswith("json"):
raw = raw[4:]
arr = json.loads(raw)
items = []
for it in arr if isinstance(arr, list) else []:
if not isinstance(it, dict):
continue
no = it.get("no")
atom = str(it.get("atom") or "").strip()[:255]
# 容错:LLM 可能输出 "1"/1.0/True,统一 int() 转换后范围校验
try:
no_i = int(no)
except (TypeError, ValueError):
continue
if 1 <= no_i <= len(chunk) and atom:
items.append((chunk[no_i - 1]["id"], atom))
async with lock:
results.extend(items)
except Exception as e:
logger.debug("atomize chunk failed: %s", e)
async with lock:
results.extend([(s["id"], s["title"][:255]) for s in chunk])
chunks = [snaps[i:i + bs] for i in range(0, len(snaps), bs)]
await asyncio.gather(*(_one(ch) for ch in chunks))
# 产出率兜底:原子数 < 文档数 50% 时,缺原子的文档用标题作原子
# (众包标题本身即浓缩需求语义;保证覆盖率统计可算,2026-09-12 实测 46 文档仅 6 原子)
covered = set(sid for sid, _ in results)
if len(covered) < len(snaps) * 0.5:
for s in snaps:
if s["id"] not in covered:
results.append((s["id"], s["title"][:255]))
return results
# ══════════════════ 2. 归一化(embedding + LLM 裁决)══════════════════
async def _normalize(sor, atoms, project_id, cfg, ctx):
"""atoms=[(snap_id, atom_text)] → 标准需求组 [(name, [snap_ids], [atom_texts])]。"""
if not atoms:
return []
texts = [a[1] for a in atoms]
from pipeline_service import rag_client as rc
vecs, err = await rc.rag_embed_texts(project_id, texts, batch_size=10)
if err or not vecs or len(vecs) != len(texts):
raise RuntimeError("原子向量化失败: %s" % (err or "数量不符"))
# 簇内 kNN(VDB 临时 collection)
base = await _vdb_base(sor)
col = "opp_norm_%s" % new_id()[:12]
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": 1024},
{"name": "text", "type": "str", "max_length": 500}],
"description": "opp normalize workset", "metric": "COSINE"})
if not ok and "exist" not in str(d).lower():
raise RuntimeError("建归一化工作集失败: %s" % d)
rows = [{"id": "a%d" % i, "vector": vecs[i], "text": texts[i][:490]}
for i in range(len(texts))]
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:
await _vdb_post(sor, base, "/v1/dropcollection", {"colname": col})
raise RuntimeError("归一化 upsert 失败: %s" % d)
nbr = {}
for i in range(len(texts)):
ok, d = await _vdb_post(sor, base, "/v1/query", {
"colname": col, "vector": vecs[i], "pagerows": cfg["opp_norm_topk"],
"output_fields": ["id"]})
if not ok:
await _vdb_post(sor, base, "/v1/dropcollection", {"colname": col})
raise RuntimeError("归一化 kNN 失败: %s" % d)
rr = (d.get("data") or {}).get("rows") or []
nbr["a%d" % i] = [(float(r.get("score", 0) or 0), r.get("id"))
for r in rr if r.get("id") and r.get("id") != "a%d" % i]
await _vdb_post(sor, base, "/v1/dropcollection", {"colname": col})
# τ2 并查集得候选组
ids = ["a%d" % i for i in range(len(texts))]
groups = _union_find_groups(ids, nbr, cfg["opp_norm_tau2"])
# LLM 裁决 + 命名(每组一次,并发 3)
from pipeline_service.llm_bridge import llm_call
sem = asyncio.Semaphore(3)
out = []
lock = asyncio.Lock()
async def _judge(g):
members = [int(x[1:]) for x in g]
uniq = []
seen = set()
for m in members:
if texts[m] not in seen:
seen.add(texts[m])
uniq.append(texts[m])
if len(uniq) == 1:
name, keep = uniq[0], members
else:
prompt = (
"以下是候选的同类原子软件需求(去重后)。判断它们是否表达**同一个标准软件需求**"
"(语义相同/包含关系算同一;明显不同功能不算)。\n" +
"\n".join("- " + u for u in uniq[:12]) +
"\n\n若同一:输出 JSON {\"same\": true, \"name\": \"标准需求名(≤12字)\"};"
"若不同:输出 {\"same\": false, \"split\": [[编号...], [编号...]]} 给出应拆开的分组"
"(编号从1起,对应上面列表顺序)。只输出 JSON。")
async with sem:
try:
raw = await _llm(prompt, ctx, timeout=90)
raw = (raw or "").strip().strip("`")
if raw.lower().startswith("json"):
raw = raw[4:]
d2 = json.loads(raw)
except Exception as e:
logger.debug("judge failed, keep group: %s", e)
d2 = {"same": True, "name": uniq[0][:40]}
if d2.get("same"):
name = str(d2.get("name") or uniq[0]).strip()[:128]
keep = members
else:
# 拆分:每个子组单独成标准需求,命名用子组首条
async with lock:
for sub in (d2.get("split") or []):
idxs = [members[i - 1] for i in sub
if isinstance(i, int) and 1 <= i <= len(members)]
if idxs:
out.append((uniq[0][:128] if len(idxs) == 1 else
texts[idxs[0]][:128], idxs,
[texts[i] for i in idxs]))
return
async with lock:
out.append((name, keep, [texts[m] for m in keep]))
await asyncio.gather(*(_judge(g) for g in groups))
return out
def _union_find_groups(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
for v in ids:
for sc, nid in nbr.get(v, []):
if sc >= tau and nid in parent:
ra, rb = find(v), find(nid)
if ra != rb:
parent[rb] = ra
comps = {}
for v in ids:
comps.setdefault(find(v), []).append(v)
return sorted(comps.values(), key=len, reverse=True)
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):
from .opp_mining import vdb_post
return await vdb_post(sor, base, path, payload, timeout=timeout)
# ══════════════════ 3. 能力域 ══════════════════
async def _domains(sor, cluster_id, org_id, std_rows, ctx):
"""LLM 把标准需求分组到能力域。std_rows=[{id,name}]。返回 [(domain_name, [std_ids])]。"""
if not std_rows:
return []
from pipeline_service.llm_bridge import llm_call
names = "\n".join("%d. %s" % (i + 1, s["name"]) for i, s in enumerate(std_rows))
prompt = (
"以下是某类软件的标准需求清单。把它们归入 2-6 个**能力域**"
"(如「人员管理」「工时与考勤」「合同管理」「报表统计」)。\n" + names +
"\n\n只输出 JSON 数组 [{\"domain\": \"域名\", \"nos\": [编号...]}],覆盖全部编号,不解释。")
try:
raw = await _llm(prompt, ctx, timeout=90)
raw = (raw or "").strip().strip("`")
if raw.lower().startswith("json"):
raw = raw[4:]
arr = json.loads(raw)
except Exception as e:
logger.debug("domains failed: %s", e)
return [("通用能力", [s["id"] for s in std_rows])]
out = []
for g in arr if isinstance(arr, list) else []:
nos = g.get("nos") or []
ids = [std_rows[i - 1]["id"] for i in nos
if isinstance(i, int) and 1 <= i <= len(std_rows)]
if ids:
out.append((str(g.get("domain") or "未命名域")[:128], ids))
covered = set(i for _, ids in out for i in ids)
left = [s["id"] for s in std_rows if s["id"] not in covered]
if left:
out.append(("其他能力", left))
return out
# ══════════════════ 主流程 ══════════════════
async def normalize_cluster(sor, cluster_id, ctx):
"""对单个类别跑 原子化→归一化→能力域→覆盖率。返回 (ok, msg)。"""
cl = await _load_cluster(sor, cluster_id)
if not cl:
return False, "类别不存在"
org_id = str(cl.get("org_id") or cl.get("b_org") or "")
if org_id and org_id != (ctx.get("org_id") or ""):
return False, "类别不属于当前机构"
cfg = await get_norm_params(sor)
snaps = await _cluster_snaps(sor, cluster_id)
if not snaps:
return False, "类别内无需求快照"
project_id = ctx.get("project_id") or ""
await _set_norm_status(sor, cluster_id, "running")
try:
# 幂等:清旧结果
for tbl in ("opp_demand_atoms", "opp_std_demands", "opp_demand_domains"):
await sor.sqlExe("DELETE FROM %s WHERE cluster_id=${c}$" % tbl,
{"c": cluster_id})
await sor.sqlExe("COMMIT", {})
# 1 原子化
atoms = await _atomize(sor, snaps, cfg, ctx)
if not atoms:
await _set_norm_status(sor, cluster_id, "failed", "原子化无结果(LLM全失败)")
return False, "原子化无结果"
for sid, atom in atoms:
await sor.C("opp_demand_atoms", {
"id": new_id(), "cluster_id": cluster_id, "org_id": org_id,
"snap_id": sid, "atom_text": atom})
await sor.sqlExe("COMMIT", {})
# 2 归一化
groups = await _normalize(sor, atoms, project_id, cfg, ctx)
snap_by_id = {s["id"]: s for s in snaps}
std_rows = []
for name, members, atom_texts in groups:
sid_ = new_id()
snap_ids = sorted({atoms[m][0] for m in members})
await sor.C("opp_std_demands", {
"id": sid_, "cluster_id": cluster_id, "org_id": org_id,
"name": name[:128], "doc_count": len(snap_ids),
"coverage": 0.0, "tier": "personal",
"evidence_json": json.dumps(
{"atoms": atom_texts[:10], "snaps": snap_ids[:50]}, ensure_ascii=False)})
std_rows.append({"id": sid_, "name": name, "snap_ids": snap_ids})
# 回填原子的 std 归属
for m in members:
await sor.sqlExe(
"UPDATE opp_demand_atoms SET std_demand_id=${s}$ "
"WHERE cluster_id=${c}$ AND snap_id=${sn}$ AND atom_text=${a}$",
{"s": sid_, "c": cluster_id, "sn": atoms[m][0], "a": atoms[m][1]})
await sor.sqlExe("COMMIT", {})
# 4 覆盖率(先算再写 tier)
n_docs = len(snaps)
for s in std_rows:
cov = round(len(s["snap_ids"]) / n_docs, 4) if n_docs else 0.0
tier = "core" if cov >= cfg["opp_cov_core"] else (
"ext" if cov >= cfg["opp_cov_ext"] else "personal")
await sor.sqlExe(
"UPDATE opp_std_demands SET coverage=${v}$, tier=${t}$ WHERE id=${i}$",
{"v": cov, "t": tier, "i": s["id"]})
await sor.sqlExe("COMMIT", {})
# 3 能力域
doms = await _domains(sor, cluster_id, org_id,
[{"id": s["id"], "name": s["name"]} for s in std_rows], ctx)
for dname, std_ids in doms:
did = new_id()
await sor.C("opp_demand_domains", {
"id": did, "cluster_id": cluster_id, "org_id": org_id,
"parent_id": "", "name": dname, "std_count": len(std_ids)})
for i in range(0, len(std_ids), 200):
chunk = std_ids[i:i + 200]
ph = ",".join("${d%d}$" % j for j in range(len(chunk)))
args = {"did": did}
for j, sv in enumerate(chunk):
args["d%d" % j] = sv
await sor.sqlExe(
"UPDATE opp_std_demands SET domain_id=${did}$ WHERE id IN (%s)" % ph, args)
await sor.sqlExe("COMMIT", {})
await _set_norm_status(sor, cluster_id, "done")
core = sum(1 for s in std_rows
if len(s["snap_ids"]) / n_docs >= cfg["opp_cov_core"]) if n_docs else 0
return True, "归一化完成:原子 %d 条 → 标准需求 %d 个(核心 %d)→ 能力域 %d 个" % (
len(atoms), len(std_rows), core, len(doms))
except Exception as e:
logger.exception("normalize cluster %s failed", cluster_id)
await _set_norm_status(sor, cluster_id, "failed", str(e))
return False, "归一化失败: %s" % str(e)[:300]
async def coverage_report(sor, ctx, cluster_id):
"""覆盖率报告(org 隔离)。返回 (ok, dict|err)。"""
cl = await _load_cluster(sor, cluster_id)
if not cl:
return False, "类别不存在"
org_id = str(cl.get("org_id") or cl.get("b_org") or "")
if org_id and org_id != (ctx.get("org_id") or ""):
return False, "类别不属于当前机构"
recs = await sor.sqlExe(
"SELECT id, name, doc_count, coverage, tier, domain_id FROM opp_std_demands "
"WHERE cluster_id=${c}$ ORDER BY coverage DESC", {"c": cluster_id})
await sor.sqlExe("COMMIT", {})
stds = rows_to_dicts(recs, limit=200)
drecs = await sor.sqlExe(
"SELECT id, name, std_count FROM opp_demand_domains WHERE cluster_id=${c}$",
{"c": cluster_id})
await sor.sqlExe("COMMIT", {})
doms = rows_to_dicts(drecs, limit=50)
dom_name = {d["id"]: d["name"] for d in doms}
for s in stds:
s["domain"] = dom_name.get(s.get("domain_id") or "", "")
tiers = {}
for s in stds:
tiers[s["tier"]] = tiers.get(s["tier"], 0) + 1
return True, {
"cluster": {"id": cluster_id, "name": cl.get("name"),
"doc_count": cl.get("doc_count"),
"normalize_status": cl.get("normalize_status"),
"normalize_error": cl.get("normalize_error") or ""},
"tiers": tiers, "std_demands": stds, "domains": doms,
}