235 lines
10 KiB
Python
235 lines
10 KiB
Python
"""事件抽取(通用·时间锚定·多模型决策)—— 3 LLM 抽 + 1 LLM 决策。
|
|
|
|
设计原则:
|
|
- **主 agent (opus / global) 不下场**, 只调度; 抽取走 extract 池(deepseek/doubao/qwen 等),
|
|
避免一处欠费(apiyi)全瘫
|
|
- **共享 distill.models 的 API 池**, 用户只在那填一次
|
|
- **多视角防漏**: 3 模型各抽一份事件 → 1 决策器合并去重 + 投票, 共识≥2 优先, 孤证降信
|
|
- **平台无关 + 类型开放**: 任何 social_evidence 行(baike/wiki/xhs/douyin/...)都贡献事件
|
|
- **长文不截断**: kind=web_page 整页喂, 短 UGC 仍 300 字节流
|
|
- **可溯源**: time/time_norm/type/title/desc/participants/source_platform/confidence/vote_count
|
|
|
|
落 (:Place)-[:HAS_EVENT{time,type}]->(:Event{title,desc,source,...})
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from app.db import get_agent_settings, sa_evidence_for
|
|
from app.agents.multi_extract import build_extract_pool, fan_out, decide
|
|
|
|
_EXTRACT_SYS = """你从一个地点的多源证据(百度百科正文 / 维基百科 / 小红书帖子或评论 /
|
|
抖音视频或评论 / 其他公开权威源)里抽取**带时间锚点的客观事实**。
|
|
|
|
只要"时间 + 主体 + 动作"明确的事实都算事件,**完全按地点类型自适应类型**(不限词表):
|
|
- 公园 / 景区 / 古迹: 建立 / 改名 / 扩建 / 重大改造 / 名人到访 / 历史事件 /
|
|
获奖评级(4A→5A) / 红色旅游 / 文物保护 / 作品诞生 / 自然现象
|
|
- 餐厅 / 酒店 / 商店: 开业 / 搬迁 / 装修 / 换老板 / 上新 / 获奖 / 上榜 /
|
|
涨价 / 停业 / 事故 / 经营变动
|
|
- 文保单位 / 博物馆: 列入名录 / 升级保护级别 / 修复 / 考古发现 / 重大展览
|
|
- 通用: 任何带时间锚点的客观事实
|
|
|
|
每条输入证据带 platform/kind/publish_time/url 信息。长文(百科一页 9000+字)
|
|
可按段落各抽多个事件。忽略:纯主观夸赞/无时间锚点/无法判断真伪/纯广告。
|
|
|
|
时间表达保留原文风格,同时给一个 time_norm 用于排序:
|
|
- 精确: "1935" / "1937" / "1960-04-30" → norm "1935" / "1937" / "1960-04"
|
|
- 古代纪年: "1638年(明崇祯十一年)" → norm "1638"
|
|
- 朝代年间: "清嘉庆道光年间" → norm "1796-1850"
|
|
- 模糊年代: "20世纪50年代" / "80年代中叶" → norm "1950" / "1985"
|
|
- 推断/约: "约1985" → norm "1985"
|
|
|
|
只输出 JSON:
|
|
{"events":[{
|
|
"time":"原文时间表达,直接展示用",
|
|
"time_norm":"YYYY 或 YYYY-MM 或 YYYY-MM-DD 或 YYYY-YYYY,用于排序",
|
|
"type":"中文事件类型(开放,1-6字)",
|
|
"title":"≤18字事件名,客观",
|
|
"desc":"一句话客观描述,引文用书名号/引号",
|
|
"participants":["涉及的人/机构,可空"],
|
|
"source_platform":"baike|wiki|xhs|douyin",
|
|
"confidence":0~1
|
|
}]}
|
|
不要编造,只能从证据里抽."""
|
|
|
|
|
|
_AGG_SYS = """你是事件归并决策器。下面是多个模型对**同一份证据**各自抽出的事件候选列表(JSON)。
|
|
|
|
裁决规则:
|
|
1) 同事件判定: (time_norm 相同 或 相邻一年内) 且 (title 语义相近 或 participants 重合)
|
|
→ 视为同一事件, 合并描述/人物/时间表达, 取最完整的
|
|
2) 共识投票: 一个事件在 N 个模型里出现 → vote_count=N
|
|
3) 强共识保留: vote_count ≥ 2 → confidence ≥ 0.85, 进入最终列表
|
|
4) 孤证保留但降信: vote_count = 1 且事实清晰 → confidence ≤ 0.55, 仍可保留
|
|
5) 矛盾时间(同事件 ≥2 模型说不同年份): 取多数, 注明 conflict_time
|
|
6) 同地点 (time_norm, title) 去重, 按 time_norm 升序
|
|
7) 每条 participants 至少保留所有模型并集
|
|
8) 不编造原证据没有的事件
|
|
|
|
只输出 JSON:
|
|
{"events":[{
|
|
"time":"原文时间表达",
|
|
"time_norm":"YYYY...",
|
|
"type":"事件类型",
|
|
"title":"≤18字事件名",
|
|
"desc":"一句话客观描述",
|
|
"participants":["..."],
|
|
"source_platform":"baike|wiki|xhs|douyin",
|
|
"confidence":0~1,
|
|
"vote_count":N,
|
|
"voted_by":["model_key1","model_key2"]
|
|
}]}"""
|
|
|
|
|
|
def _python_merge(proposals: list[dict]) -> list[dict]:
|
|
"""确定性兜底合并: 按 (年份, 标题) 投票去重, 无 LLM 依赖。
|
|
|
|
决策器 LLM 失败时(token 超限/网络/限速)调用, 保证至少有结果。
|
|
票数=支持该事件的模型数; participants 取所有模型并集; desc 取最长一份。
|
|
"""
|
|
from collections import defaultdict
|
|
groups: dict[tuple[str, str], list[tuple[str, dict]]] = defaultdict(list)
|
|
for p in proposals:
|
|
m = p.get("model", "")
|
|
for e in p.get("events") or []:
|
|
tn = str(e.get("time_norm") or e.get("time") or "")[:16]
|
|
ti = str(e.get("title") or "").strip()[:24]
|
|
if not ti:
|
|
continue
|
|
# 用 (4位年份, 标题) 提高同事件聚合率(95% vs 80% 用完整 norm)
|
|
key = (tn[:4], ti)
|
|
groups[key].append((m, e))
|
|
final = []
|
|
for items in groups.values():
|
|
models = sorted({m for m, _ in items})
|
|
primary = items[0][1]
|
|
ppl: set[str] = set()
|
|
for _, e in items:
|
|
for p in (e.get("participants") or []):
|
|
if p:
|
|
ppl.add(str(p)[:24])
|
|
desc = max((str(e.get("desc", "")) for _, e in items), key=len) or ""
|
|
final.append({
|
|
"time": str(primary.get("time", ""))[:32],
|
|
"time_norm": str(primary.get("time_norm", ""))[:16],
|
|
"type": str(primary.get("type", "其他"))[:8],
|
|
"title": str(primary.get("title", "")).strip()[:24],
|
|
"desc": desc[:240],
|
|
"participants": sorted(ppl)[:12],
|
|
"source_platform": str(primary.get("source_platform", ""))[:16],
|
|
"confidence": 0.85 if len(models) >= 2 else 0.55,
|
|
"vote_count": len(models),
|
|
"voted_by": models,
|
|
})
|
|
final.sort(key=lambda x: x.get("time_norm") or "9999")
|
|
return final
|
|
|
|
|
|
def _norm_corpus_row(r: dict) -> dict:
|
|
"""规范化每条证据: 长文(web_page)不截, UGC 短文 300 字节流。"""
|
|
txt = ((r.get("title") or "") + " " + (r.get("content") or "")).strip()
|
|
kind = r.get("kind") or ""
|
|
cap = 999999 if kind == "web_page" else 300
|
|
return {
|
|
"platform": r.get("platform") or "",
|
|
"kind": kind,
|
|
"publish_time": r.get("publish_time") or "",
|
|
"url": r.get("url") or "",
|
|
"text": txt[:cap],
|
|
}
|
|
|
|
|
|
async def mine_events(entity: dict) -> dict:
|
|
"""从 social_evidence 多源证据 → 3 抽 + 1 决策 → 时间锚定事件列表。
|
|
|
|
entity: {eid(natural_key), name}.
|
|
返回 {ok, found, events, summary}.
|
|
|
|
扩展字段(每条事件): vote_count(共识票数), voted_by(支持的模型 key 列表),
|
|
用于后续在 FalkorDB 上按可信度筛选。
|
|
"""
|
|
pnk = entity.get("eid") or entity.get("natural_key")
|
|
if not pnk:
|
|
return {"ok": True, "found": False, "events": [],
|
|
"summary": "无 natural_key"}
|
|
cfg = await get_agent_settings()
|
|
extractors, agg, status_msg = build_extract_pool(cfg)
|
|
if len(extractors) < 1 or agg is None:
|
|
return {"ok": False, "found": False, "events": [],
|
|
"summary": f"知识抽取未配置:{status_msg}"
|
|
f"(在系统设置 → 知识抽取 卡里启用)"}
|
|
|
|
rows = await sa_evidence_for(pnk, limit=200)
|
|
web_rows = [r for r in rows if r.get("kind") == "web_page"]
|
|
ugc_rows = [r for r in rows if r.get("kind") != "web_page"]
|
|
corpus = []
|
|
for r in web_rows + ugc_rows:
|
|
item = _norm_corpus_row(r)
|
|
if len(item["text"]) >= 4:
|
|
corpus.append(item)
|
|
has_long = any(len(c["text"]) >= 800 for c in corpus)
|
|
if not has_long and len(corpus) < 3:
|
|
return {"ok": True, "found": False, "events": [],
|
|
"summary": f"「{entity.get('name')}」证据不足"
|
|
f"(长文0·短证据{len(corpus)}),跳过"}
|
|
|
|
body = json.dumps({"地点": entity.get("name"),
|
|
"证据": corpus[:120]}, ensure_ascii=False)
|
|
|
|
# === 阶段 1: 多模型扇出抽取 ===
|
|
responses = await fan_out(_EXTRACT_SYS, body, extractors)
|
|
proposals = []
|
|
ok_models = []
|
|
for r in responses:
|
|
evs = ((r.get("data") or {}).get("events")) or []
|
|
if evs and isinstance(evs, list):
|
|
proposals.append({"model": r["model"], "events": evs})
|
|
ok_models.append(r["model"])
|
|
if not proposals:
|
|
errs = "; ".join(f"{r['model']}:{r.get('error','无返回')}"
|
|
for r in responses if r.get("error"))[:120]
|
|
return {"ok": True, "found": False, "events": [],
|
|
"summary": f"多模型抽取无候选({status_msg}); {errs}"}
|
|
|
|
# === 阶段 2: 决策器合并去重 ===
|
|
agg_input = json.dumps({"地点": entity.get("name"),
|
|
"候选": proposals}, ensure_ascii=False)
|
|
decided, err = await decide(_AGG_SYS, agg_input, agg)
|
|
fallback_used = False
|
|
if not decided:
|
|
# 决策器 LLM 失败 → Python 确定性兜底合并(按年份+标题投票)
|
|
decided = {"events": _python_merge(proposals)}
|
|
fallback_used = True
|
|
|
|
final = []
|
|
seen = set()
|
|
for e in (decided.get("events") or []):
|
|
if not isinstance(e, dict) or not e.get("title"):
|
|
continue
|
|
tn = str(e.get("time_norm") or e.get("time") or "")[:16]
|
|
ti = str(e["title"]).strip()[:24]
|
|
key = (tn, ti)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
final.append({
|
|
"time": str(e.get("time") or "")[:32],
|
|
"time_norm": tn,
|
|
"type": str(e.get("type") or "其他")[:8],
|
|
"title": ti,
|
|
"desc": str(e.get("desc") or "")[:240],
|
|
"participants": [str(p)[:24] for p
|
|
in (e.get("participants") or []) if p][:12],
|
|
"source_platform": str(e.get("source_platform") or "")[:16],
|
|
"confidence": e.get("confidence"),
|
|
"vote_count": int(e.get("vote_count") or 0),
|
|
"voted_by": [str(m)[:16] for m
|
|
in (e.get("voted_by") or []) if m][:8],
|
|
})
|
|
decider_note = (f"{agg[0]}失败→Python兜底({err})" if fallback_used
|
|
else f"{agg[0]}决策")
|
|
return {"ok": True, "found": bool(final), "events": final[:30],
|
|
"summary": f"{len(corpus)}条证据 · {len(ok_models)}/{len(extractors)}模型抽取"
|
|
f"({','.join(ok_models)}) · {decider_note}"
|
|
f" → {len(final)}个时间锚定事件"}
|