feat(normalize): P2-③类内共性提取引擎——opp_normalize.py(原子化LLM合批并发→embedding+τ2并查集候选组→LLM裁决归一(相似度只当触发器)→能力域分组→覆盖率三层core≥0.6/ext≥0.4/personal);3新表atoms/std_demands/domains+clusters补normalize_status/error列;2工具normalize_cluster/coverage_report(org隔离+未归一先报错引导);params种子5项

This commit is contained in:
yumoqing 2026-09-12 11:41:51 +08:00
parent 2bf137e650
commit 1e64ab7e75
9 changed files with 832 additions and 1 deletions

View File

@ -38,6 +38,11 @@
{"name": "opp_mine_big_split", "value": "150", "desc": "超大簇质心二次细分阈值(防模板标题过度聚合)"},
{"name": "opp_mine_keep_batches", "value": "3", "desc": "每机构保留最近N个批次工作集collection,旧批自动drop"},
{"name": "opp_embed_batch", "value": "10", "desc": "rag /embed 单批文本上限"},
{"name": "opp_emb_cache_col", "value": "opp_demand_emb_cache", "desc": "共享embedding缓存collection(全机构复用)"}
{"name": "opp_emb_cache_col", "value": "opp_demand_emb_cache", "desc": "共享embedding缓存collection(全机构复用)"},
{"name": "opp_norm_atom_batch", "value": "20", "desc": "原子化每批文档数(LLM合批)"},
{"name": "opp_norm_tau2", "value": "0.85", "desc": "归一化相似度触发阈值(只当触发器,LLM裁决)"},
{"name": "opp_norm_topk", "value": "10", "desc": "原子kNN邻居数"},
{"name": "opp_cov_core", "value": "0.6", "desc": "核心共性覆盖率阈值"},
{"name": "opp_cov_ext", "value": "0.4", "desc": "扩展共性覆盖率阈值"}
]
}

View File

@ -75,6 +75,18 @@
"type": "str",
"length": 32
},
{
"name": "normalize_status",
"title": "归一化状态(init/running/done/failed)",
"type": "str",
"length": 16,
"default": "init"
},
{
"name": "normalize_error",
"title": "归一化失败原因",
"type": "text"
},
{
"name": "created_at",
"title": "创建时间",

View File

@ -0,0 +1,61 @@
{
"summary": [
{
"name": "opp_demand_atoms",
"title": "需求原子(类内LLM原子化结果)",
"primary": [
"id"
],
"catelog": "entity"
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "cluster_id",
"title": "所属类别ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "机构ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "snap_id",
"title": "来源需求快照ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "atom_text",
"title": "原子需求文本(LLM提取)",
"type": "str",
"length": 255,
"nullable": "no"
},
{
"name": "std_demand_id",
"title": "归一后标准需求ID",
"type": "str",
"length": 32
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
}
]
}

View File

@ -0,0 +1,61 @@
{
"summary": [
{
"name": "opp_demand_domains",
"title": "需求能力域(两层需求模型上层)",
"primary": [
"id"
],
"catelog": "entity"
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "cluster_id",
"title": "所属类别ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "机构ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "parent_id",
"title": "父能力域ID(空=顶层)",
"type": "str",
"length": 32
},
{
"name": "name",
"title": "能力域名(如:人员管理/工时与考勤)",
"type": "str",
"length": 128,
"nullable": "no"
},
{
"name": "std_count",
"title": "下辖标准需求数",
"type": "int",
"nullable": "no",
"default": "0"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
}
]
}

View File

@ -0,0 +1,81 @@
{
"summary": [
{
"name": "opp_std_demands",
"title": "标准需求(归一化结果)",
"primary": [
"id"
],
"catelog": "entity"
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "cluster_id",
"title": "所属类别ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "机构ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "name",
"title": "标准需求名(LLM裁决命名)",
"type": "str",
"length": 128,
"nullable": "no"
},
{
"name": "domain_id",
"title": "能力域ID(两层模型上层)",
"type": "str",
"length": 32
},
{
"name": "doc_count",
"title": "覆盖文档数",
"type": "int",
"nullable": "no",
"default": "0"
},
{
"name": "coverage",
"title": "覆盖率(覆盖文档数/类内文档数)",
"type": "double",
"length": 6,
"dec": 4,
"default": "0"
},
{
"name": "tier",
"title": "层级(core核心/ext扩展/personal个性)",
"type": "str",
"length": 16,
"default": "personal"
},
{
"name": "evidence_json",
"title": "证据(原子样例JSON)",
"type": "text"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
}
]
}

View File

@ -36,6 +36,38 @@ comment '研发报告'
;
-- /home/ymq/work/repos/pipeline-opportunity/models/opp_demand_atoms.json
-- 建库时请用以下语句支持emoji字符
-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
drop table if exists opp_demand_atoms;
CREATE TABLE opp_demand_atoms
(
`id` VARCHAR(32) NOT NULL comment '主键ID',
`cluster_id` VARCHAR(32) NOT NULL comment '所属类别ID',
`org_id` VARCHAR(32) NOT NULL comment '机构ID',
`snap_id` VARCHAR(32) NOT NULL comment '来源需求快照ID',
`atom_text` VARCHAR(255) NOT NULL comment '原子需求文本(LLM提取)',
`std_demand_id` VARCHAR(32) comment '归一后标准需求ID',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间'
,primary key(id)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '需求原子(类内LLM原子化结果)'
;
-- /home/ymq/work/repos/pipeline-opportunity/models/opp_approvals.json
@ -92,6 +124,8 @@ CREATE TABLE opp_clusters
`heat_rank` int DEFAULT '0' comment '热度排名',
`naming_evidence` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '命名证据(样例标题JSON)',
`centroid_snap_id` VARCHAR(32) comment '质心需求快照ID(代表样本)',
`normalize_status` VARCHAR(16) DEFAULT 'init' comment '归一化状态(init/running/done/failed)',
`normalize_error` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '归一化失败原因',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间'
@ -106,6 +140,73 @@ comment '需求类别(聚类结果)'
;
-- /home/ymq/work/repos/pipeline-opportunity/models/opp_demand_domains.json
-- 建库时请用以下语句支持emoji字符
-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
drop table if exists opp_demand_domains;
CREATE TABLE opp_demand_domains
(
`id` VARCHAR(32) NOT NULL comment '主键ID',
`cluster_id` VARCHAR(32) NOT NULL comment '所属类别ID',
`org_id` VARCHAR(32) NOT NULL comment '机构ID',
`parent_id` VARCHAR(32) comment '父能力域ID(空=顶层)',
`name` VARCHAR(128) NOT NULL comment '能力域名(如:人员管理/工时与考勤)',
`std_count` int NOT NULL DEFAULT '0' comment '下辖标准需求数',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间'
,primary key(id)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '需求能力域(两层需求模型上层)'
;
-- /home/ymq/work/repos/pipeline-opportunity/models/opp_std_demands.json
-- 建库时请用以下语句支持emoji字符
-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
drop table if exists opp_std_demands;
CREATE TABLE opp_std_demands
(
`id` VARCHAR(32) NOT NULL comment '主键ID',
`cluster_id` VARCHAR(32) NOT NULL comment '所属类别ID',
`org_id` VARCHAR(32) NOT NULL comment '机构ID',
`name` VARCHAR(128) NOT NULL comment '标准需求名(LLM裁决命名)',
`domain_id` VARCHAR(32) comment '能力域ID(两层模型上层)',
`doc_count` int NOT NULL DEFAULT '0' comment '覆盖文档数',
`coverage` double(6,4) DEFAULT '0' comment '覆盖率(覆盖文档数/类内文档数)',
`tier` VARCHAR(16) DEFAULT 'personal' comment '层级(core核心/ext扩展/personal个性)',
`evidence_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '证据(原子样例JSON)',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间'
,primary key(id)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '标准需求(归一化结果)'
;
-- /home/ymq/work/repos/pipeline-opportunity/models/opp_mining_batches.json

View File

@ -89,6 +89,10 @@ OPP_TOOLS = [
parameters={"batch_id": "批次ID"}, category="mining"),
ToolDefinition(name="opp_cluster_detail", description="查看某类别的类内需求明细样例每条带来源URL可查证用于写可行性报告时引用证据",
parameters={"cluster_id": "类别ID", "limit": "样例条数(默认20)"}, category="mining"),
ToolDefinition(name="opp_normalize_cluster", description="对某类别做类内共性提取(后台执行):原子化→语义归一→能力域→覆盖率统计。完成后用 opp_coverage_report 看核心/扩展/个性三层共性需求。耗时与类内文档数成正比LLM 调用较多,只对要深挖的类别执行)",
parameters={"cluster_id": "类别ID"}, category="mining"),
ToolDefinition(name="opp_coverage_report", description="查看某类别的共性需求覆盖率报告:核心共性(覆盖率≥60%)/扩展共性(≥40%)/个性需求三层 + 能力域分组 + 每条标准需求的覆盖文档数与证据",
parameters={"cluster_id": "类别ID"}, category="mining"),
]
# ══════════════════ 产线 prompt 片段 ══════════════════
@ -494,6 +498,40 @@ async def _h_cluster_detail(sor, p, ctx):
"样例(带来源可查证)": samples}, ensure_ascii=False, default=str)
async def _h_normalize_cluster(sor, p, ctx):
from .opp_normalize import normalize_cluster
cid = (p.get("cluster_id") or "").strip()
if not cid:
return _fmt(False, "cluster_id 必填")
ok, msg = await normalize_cluster(sor, cid, ctx)
return _fmt(ok, msg)
async def _h_coverage_report(sor, p, ctx):
from .opp_normalize import coverage_report
cid = (p.get("cluster_id") or "").strip()
if not cid:
return _fmt(False, "cluster_id 必填")
ok, res = await coverage_report(sor, ctx, cid)
if not ok:
return _fmt(False, res)
cl = res["cluster"]
if cl.get("normalize_status") != "done":
return _fmt(False, "该类别尚未完成共性提取status=%s%s),先调 opp_normalize_cluster" % (
cl.get("normalize_status"), ("" + cl.get("normalize_error")) if cl.get("normalize_error") else ""))
tiers = res["tiers"]
rows = [{"标准需求": s["name"], "能力域": s["domain"], "覆盖文档": s["doc_count"],
"覆盖率": "%.1f%%" % (float(s["coverage"] or 0) * 100), "层级": s["tier"],
"证据原子": (json.loads(s.get("evidence_json") or "{}").get("atoms") or [])[:3]}
for s in res["std_demands"]]
return json.dumps({
"类别": cl["name"], "类内文档数": cl["doc_count"],
"层级统计": {"核心(≥60%)": tiers.get("core", 0), "扩展(≥40%)": tiers.get("ext", 0),
"个性": tiers.get("personal", 0)},
"能力域": [{"": d["name"], "标准需求数": d["std_count"]} for d in res["domains"]],
"标准需求": rows}, ensure_ascii=False, default=str)
OPP_HANDLERS = {
"opp_hot_software": _h_hot_software,
"opp_daily_ai_tenders": _h_daily_ai,
@ -518,6 +556,9 @@ OPP_HANDLERS = {
"opp_mining_status": _h_mining_status,
"opp_list_clusters": _h_list_clusters,
"opp_cluster_detail": _h_cluster_detail,
# 类内共性提取P2
"opp_normalize_cluster": _h_normalize_cluster,
"opp_coverage_report": _h_coverage_report,
}

View File

@ -0,0 +1,427 @@
# -*- coding:utf-8 -*-
"""P2-③ 类内共性提取引擎:原子化 → 归一化 → 能力域 → 覆盖率。
流程对单个 cluster 按需执行用户/agent 触发不做全批自动跑成本控制
1. 原子化类内文档按 20/ LLM 提取原子需求并发 Semaphore 3purpose=utility
opp_demand_atomssnap×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/类内文档数
tiercore0.6 / ext0.4 / personal阈值走 paramsopp_cov_core/opp_cov_ext
状态opp_clusters.normalize_statusinit/running/done/failed + error naming_evidence 旁路字段
normalize_errorALTER 补列幂等重跑先清该簇旧 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")
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_call(prompt, purpose="utility", timeout=90,
org_id=ctx.get("org_id") or "0",
user_id=ctx.get("user_id") or "",
session_id=ctx.get("session_id") or "")
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 []:
no = it.get("no")
atom = str(it.get("atom") or "").strip()[:255]
if isinstance(no, int) and 1 <= no <= len(chunk) and atom:
items.append((chunk[no - 1]["id"], atom))
async with lock:
results.extend(items)
except Exception as e:
logger.debug("atomize chunk failed: %s", e)
chunks = [snaps[i:i + bs] for i in range(0, len(snaps), bs)]
await asyncio.gather(*(_one(ch) for ch in chunks))
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 "数量不符"))
# 簇内 kNNVDB 临时 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_call(prompt, purpose="utility", timeout=90,
org_id=ctx.get("org_id") or "0",
user_id=ctx.get("user_id") or "",
session_id=ctx.get("session_id") or "")
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_call(prompt, purpose="utility", timeout=90,
org_id=ctx.get("org_id") or "0",
user_id=ctx.get("user_id") or "",
session_id=ctx.get("session_id") or "")
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,
}

View File

@ -0,0 +1,42 @@
# -*- coding:utf-8 -*-
"""P2-③ 建表opp_demand_atoms / opp_std_demands / opp_demand_domains幂等 IF NOT EXISTS"""
import asyncio, os, re, sys
W = "/d/pipeline/pipeline-app"; os.chdir(W); sys.path.insert(0, W)
from appPublic.folderUtils import ProgramPath
from appPublic.jsonConfig import getConfig
from appPublic.event_dispatcher import EventDispatcher
from sqlor.dbpools import DBPools
from ahserver.serverenv import ServerEnv
p = ProgramPath(); c = getConfig(W, {"workdir": W, "ProgramPath": p}); DBPools(c.databases)
se = ServerEnv(); se.event_dispatcher = EventDispatcher(); se.get_module_dbname = lambda m: "pipeline"
NEW = ["opp_demand_atoms", "opp_std_demands", "opp_demand_domains"]
async def main():
ddl = open("/tmp/p2_tables.sql", encoding="utf-8").read()
async with DBPools().sqlorContext("pipeline") as sor:
for stmt in [s.strip() for s in ddl.split(";") if s.strip()]:
m = re.match(r"(?is)CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?", stmt)
if not m or m.group(1) not in NEW:
continue
await sor.sqlExe(stmt, {})
await sor.sqlExe("COMMIT", {})
print("table OK:", m.group(1))
for t in NEW:
r = await sor.sqlExe(
"SELECT COUNT(*) c FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name=${t}$", {"t": t})
await sor.sqlExe("COMMIT", {})
print("verify", t, "exists:", (r[0].c if r else 0))
# 索引
for name, tbl, cols in [("idx_opp_atoms_cluster", "opp_demand_atoms", "cluster_id"),
("idx_opp_atoms_snap", "opp_demand_atoms", "snap_id"),
("idx_opp_std_cluster", "opp_std_demands", "cluster_id, tier"),
("idx_opp_domains_cluster", "opp_demand_domains", "cluster_id")]:
try:
await sor.sqlExe("CREATE INDEX IF NOT EXISTS %s ON %s (%s)" % (name, tbl, cols), {})
await sor.sqlExe("COMMIT", {})
print("index OK:", name)
except Exception as e:
print("index skip:", name, str(e)[:60])
asyncio.run(main())