"""事件抽取(通用·时间锚定·多模型决策)—— 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)}个时间锚定事件"}