Initial travel knowledge graph release
This commit is contained in:
234
app/agents/event_miner.py
Normal file
234
app/agents/event_miner.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""事件抽取(通用·时间锚定·多模型决策)—— 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)}个时间锚定事件"}
|
||||
Reference in New Issue
Block a user