Files
bxh/app/agents/event_miner.py

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)}个时间锚定事件"}