Files
cnfinancialscraper/scripts/knowledge_sync.py
T

749 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
知识库总结与同步引擎 v7.7.0 (knowledge_sync.py)
把爬取/汇总的金融数据「按需求总结」为知识笔记,并同步到主流知识库
(IMA / Dify / Obsidian / 本地兜底),同时支持从知识库反向检索
校对爬取结果的数字/事实一致性。零 pip 依赖(HTTP 走 http_utils)。
能力:
- summarize_to_notes : 聚合数据 → 知识笔记(overall/target/negative 三种需求模式)
- ObsidianAdapter : 本地 Vault 目录,Markdown + YAML frontmatter
- ImaAdapter : 腾讯 IMA OpenAPI(import_doc + add_knowledge + search)
- DifyAdapter : Dify Knowledge API(create-by-text + retrieve + 文档更新)
- LocalKnowledgeAdapter : data/knowledge_base/ 本地兜底(无凭证即可用)
- sync_to_knowledge_base : 汇总 → 总结 → 推送指定适配器,写同步日志
- verify_against_knowledge_base : 用知识库条目反向校对爬取数字/事实
配置(data/knowledge_config.json,均可用环境变量覆盖):
{
"target": "ima", # 默认适配器
"obsidian": {"vault_path": "D:/vault"},
"ima": {"knowledge_base_id": "..."}, # client_id/api_key 走环境变量
"dify": {"base_url": "https://api.dify.ai", "dataset_id": "...",
"dataset_name": "cn-financial"} # api_key 走环境变量
}
环境变量:
IMA_OPENAPI_CLIENTID / IMA_OPENAPI_APIKEY(或 ~/.config/ima/client_id、api_key)
DIFY_API_KEY / DIFY_BASE_URL / DIFY_DATASET_ID
"""
from __future__ import annotations
import hashlib
import json
import os
import re
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
try: # 包导入(from scripts import ...)
from .data_aggregator import (aggregate_snapshots, sentiment_statistics,
title_similarity)
from .http_utils import http_get, http_post
except ImportError: # 直接导入(scripts/ 已在 sys.path)
from data_aggregator import (aggregate_snapshots, sentiment_statistics,
title_similarity)
from http_utils import http_get, http_post
SKILL_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = SKILL_DIR / "data"
CONFIG_FILE = DATA_DIR / "knowledge_config.json"
KB_DIR = DATA_DIR / "knowledge_base" # 本地兜底库
SYNC_LOG = DATA_DIR / "knowledge_sync_log.json"
class KnowledgeBaseError(Exception):
"""知识库操作失败(凭证缺失 / 接口报错 / 路径无效)。"""
@dataclass
class KnowledgeNote:
"""一篇知识笔记(适配器无关的统一载体)。"""
title: str
content: str # Markdown 正文
tags: List[str] = field(default_factory=list)
source_refs: List[str] = field(default_factory=list) # 来源 URL
category: str = "金融数据汇总"
def to_frontmatter_markdown(self) -> str:
"""带 YAML frontmatter 的 Markdown(Obsidian 风格)。"""
fm = ["---"]
fm.append(f"title: {self.title}")
fm.append(f"category: {self.category}")
if self.tags:
fm.append("tags: [" + ", ".join(self.tags) + "]")
fm.append(f"created: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
fm.append("---")
return "\n".join(fm) + "\n\n" + self.content
# ════════════════ 1. 按需总结器 ════════════════
def _fmt_kv(pairs) -> str:
return "、".join(f"{k}({v})" for k, v in pairs) if pairs else "—"
def _render_overview_md(agg: Dict[str, Any], requirement: str = "") -> str:
st = agg.get("statistics") or {}
lines = ["## 概览", ""]
if requirement:
lines += [f"> 总结需求:{requirement}", ""]
lines += [
f"- 快照数:{agg.get('snapshot_count', 0)},原始条目 "
f"{agg.get('raw_count', 0)},去重后 {agg.get('dedup_count', 0)}",
f"- 负面占比 {st.get('neg_ratio', 0):.1%},"
f"正面占比 {st.get('pos_ratio', 0):.1%},平均分 {st.get('avg_score', 0)}",
"",
]
senti = st.get("sentiment") or {}
if senti:
lines += ["## 情感分布", "",
" | ".join(f"{k}: {v}" for k, v in senti.items()), ""]
hot = st.get("hot_keywords") or []
if hot:
lines += ["## 高频关键词", "", _fmt_kv(hot[:15]), ""]
by_target = agg.get("by_target") or []
if by_target:
lines += ["## 目标机构截面", "",
"| 机构 | 条数 | 负面 | 负面占比 | 最高等级 |",
"|---|---|---|---|---|"]
for row in by_target[:20]:
lines.append(f"| {row.get('target_name', '')} | {row['count']} | "
f"{row['negative']} | {row['neg_ratio']:.1%} | "
f"{row['severity_top']} |")
lines.append("")
return "\n".join(lines)
def _render_articles_md(articles: List[Dict[str, Any]], limit: int = 20,
heading: str = "## 重点条目") -> str:
if not articles:
return ""
lines = [heading, ""]
for a in articles[:limit]:
ref = a.get("url") or ""
lines.append(f"- **[{a.get('sentiment', '')}/{a.get('severity', '')}]** "
f"{a.get('title', '')} \n"
f" 来源:{a.get('source', '')} "
f"时间:{a.get('publish_time', '')} "
+ (f"[原文]({ref})" if ref else ""))
lines.append("")
return "\n".join(lines)
def summarize_to_notes(agg: Optional[Dict[str, Any]] = None,
mode: str = "overall",
target: str = "",
requirement: str = "",
top_articles: int = 20,
snapshot_dir: Optional[Path] = None) -> List[KnowledgeNote]:
"""按需求把聚合结果总结为知识笔记列表。
Args:
agg: 聚合结果(默认现场聚合 data/sentiment_snapshots/)
mode: overall=总体汇总 / target=按目标机构(需 target)/
negative=仅负面风险
requirement: 用户自定义总结需求(写入笔记头部,指导后续阅读)
top_articles: 每篇笔记附带的重点条目上限
"""
if agg is None:
agg = aggregate_snapshots(snapshot_dir)
if not agg.get("articles"):
return []
articles = agg["articles"]
tags = ["金融舆情", "cn-financial-scraper"]
refs = [a["url"] for a in articles[:30] if a.get("url")]
if mode == "target":
if not target:
raise ValueError("mode='target' 时必须指定 target")
related = [a for a in articles
if target in (a.get("target_name") or "")
or target in (a.get("title") or "")]
if not related:
return []
st = sentiment_statistics(related)
content = [f"# {target} 舆情总结", ""]
if requirement:
content += [f"> 总结需求:{requirement}", ""]
content += [
f"- 相关条目 {len(related)} 条,负面 {st['sentiment'].get('negative', 0)} 条"
f"({st['neg_ratio']:.1%}),正面 {st['sentiment'].get('positive', 0)} 条",
"",
f"高频关键词:{_fmt_kv(st.get('hot_keywords') or [])}",
"",
_render_articles_md(related, top_articles),
]
return [KnowledgeNote(
title=f"{target} 舆情总结 {datetime.now():%Y%m%d}",
content="\n".join(content), tags=tags + [target],
source_refs=[a["url"] for a in related[:30] if a.get("url")],
category="机构舆情")]
if mode == "negative":
neg = [a for a in articles if a.get("sentiment") == "negative"]
if not neg:
return []
neg.sort(key=lambda a: float(a.get("sentiment_score") or 0)) # 分数越低越严重
st = sentiment_statistics(neg)
content = [
"# 负面舆情风险总结", "",
f"> 总结需求:{requirement}" if requirement else "> 仅汇总负面条目", "",
f"- 负面条目 {len(neg)} 条",
f"- 高频风险词:{_fmt_kv(st.get('hot_keywords') or [])}",
"",
_render_articles_md(neg, top_articles, heading="## 负面条目"),
]
return [KnowledgeNote(
title=f"负面舆情风险总结 {datetime.now():%Y%m%d}",
content="\n".join(content), tags=tags + ["风险"],
source_refs=[a["url"] for a in neg[:30] if a.get("url")],
category="风险提示")]
# mode == "overall"
content = "# 金融舆情总体汇总\n\n"
content += _render_overview_md(agg, requirement)
content += _render_articles_md(articles, top_articles)
return [KnowledgeNote(
title=f"金融舆情总体汇总 {datetime.now():%Y%m%d}",
content=content, tags=tags, source_refs=refs,
category="金融数据汇总")]
# ════════════════ 2. 配置加载 ════════════════
def load_kb_config(path: Optional[Path] = None) -> Dict[str, Any]:
"""加载 data/knowledge_config.json(不存在返回空)。"""
p = Path(path) if path else CONFIG_FILE
if not p.exists():
return {}
try:
doc = json.loads(p.read_text(encoding="utf-8"))
return doc if isinstance(doc, dict) else {}
except Exception:
return {}
def save_kb_config(cfg: Dict[str, Any], path: Optional[Path] = None) -> Path:
p = Path(path) if path else CONFIG_FILE
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
return p
def _ima_credentials() -> Dict[str, str]:
"""IMA 凭证:环境变量优先,其次 ~/.config/ima/client_id|api_key。"""
cid = os.environ.get("IMA_OPENAPI_CLIENTID", "").strip()
key = os.environ.get("IMA_OPENAPI_APIKEY", "").strip()
if not cid or not key:
home = Path.home() / ".config" / "ima"
if not cid and (home / "client_id").exists():
cid = (home / "client_id").read_text(encoding="utf-8").strip()
if not key and (home / "api_key").exists():
key = (home / "api_key").read_text(encoding="utf-8").strip()
return {"client_id": cid, "api_key": key}
# ════════════════ 3. 适配器 ════════════════
class KnowledgeAdapter:
"""知识库适配器基类:push 写入、search 检索(校对用)。"""
name = "base"
def available(self) -> bool: # pragma: no cover - 子类覆盖
raise NotImplementedError
def push(self, note: KnowledgeNote) -> Dict[str, Any]:
raise NotImplementedError
def search(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
raise NotImplementedError
# -- 通用:同步历史(本地所有适配器共享,用于幂等/审计) --
def note_digest(self, note: KnowledgeNote) -> str:
return hashlib.md5((note.title + "\n" + note.content).encode("utf-8")
).hexdigest()[:16]
class LocalKnowledgeAdapter(KnowledgeAdapter):
"""本地兜底知识库:data/knowledge_base/<category>/<title>.md + index.json。"""
name = "local"
def __init__(self, base_dir: Optional[Path] = None):
self.base_dir = Path(base_dir) if base_dir else KB_DIR
self.index_file = self.base_dir / "_index.json"
def available(self) -> bool:
return True
def _load_index(self) -> Dict[str, Any]:
if self.index_file.exists():
try:
return json.loads(self.index_file.read_text(encoding="utf-8"))
except Exception:
pass
return {"entries": []}
def _save_index(self, idx: Dict[str, Any]) -> None:
self.index_file.parent.mkdir(parents=True, exist_ok=True)
self.index_file.write_text(
json.dumps(idx, ensure_ascii=False, indent=1), encoding="utf-8")
@staticmethod
def _safe_name(title: str) -> str:
return re.sub(r'[\\/:*?"<>|\r\n]+', "_", title).strip()[:80] or "untitled"
def push(self, note: KnowledgeNote) -> Dict[str, Any]:
cat_dir = self.base_dir / self._safe_name(note.category)
cat_dir.mkdir(parents=True, exist_ok=True)
digest = self.note_digest(note)
idx = self._load_index()
# 幂等:同 digest 已存在则跳过
for e in idx["entries"]:
if e.get("digest") == digest:
return {"adapter": self.name, "status": "skipped",
"path": e.get("path"), "digest": digest}
fp = cat_dir / (self._safe_name(note.title) + ".md")
suffix = 1
while fp.exists():
fp = cat_dir / f"{self._safe_name(note.title)}_{suffix}.md"
suffix += 1
fp.write_text(note.to_frontmatter_markdown(), encoding="utf-8")
idx["entries"].append({
"title": note.title, "category": note.category,
"tags": note.tags, "path": str(fp),
"digest": digest,
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
self._save_index(idx)
return {"adapter": self.name, "status": "pushed",
"path": str(fp), "digest": digest}
def search(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
"""标题/正文关键词检索(离线,按关键词命中数排序)。"""
idx = self._load_index()
kws = [w for w in re.split(r"[\s,,]+", query) if w]
scored = []
for e in idx["entries"]:
path = Path(e.get("path", ""))
text = path.read_text(encoding="utf-8") if path.exists() else ""
hay = (e.get("title", "") + "\n" + text).lower()
hits = sum(hay.count(k.lower()) for k in kws)
if hits:
scored.append((hits, {"title": e.get("title", ""),
"path": str(path),
"snippet": text[:600],
"category": e.get("category", "")}))
scored.sort(key=lambda x: -x[0])
return [s[1] for s in scored[:limit]]
class ObsidianAdapter(LocalKnowledgeAdapter):
"""Obsidian Vault:直接写 Markdown 到 vault 目录(Obsidian 自动索引)。"""
name = "obsidian"
def __init__(self, vault_path: Optional[Path] = None):
if vault_path is None:
cfg = load_kb_config().get("obsidian") or {}
vault_path = cfg.get("vault_path") or os.environ.get("OBSIDIAN_VAULT_PATH")
super().__init__(vault_path)
def available(self) -> bool:
return bool(self.base_dir) and Path(self.base_dir).exists()
class _HttpJsonAdapter(KnowledgeAdapter):
"""远程 JSON API 适配器公共层:http_post + session 注入(便于测试)。"""
def __init__(self, session=None):
self.session = session
def _post(self, url: str, json_body: Dict, headers: Dict,
timeout: int = 30) -> Dict[str, Any]:
resp = http_post(url, json_body=json_body, headers=headers,
timeout=timeout, retries=2, session=self.session,
use_cache=False, scheme_fallback=False)
if resp is None:
raise KnowledgeBaseError(f"请求失败(无响应): {url[:100]}")
try:
return resp.json()
except Exception as exc:
raise KnowledgeBaseError(
f"响应非 JSON: {url[:100]} body={getattr(resp, 'text', '')[:200]}") from exc
class ImaAdapter(_HttpJsonAdapter):
"""腾讯 IMA OpenAPI:创建笔记 → 关联知识库;搜索用于校对。
端点(POST,Header: ima-openapi-clientid / ima-openapi-apikey):
/openapi/note/v1/import_doc 创建笔记(Markdown)
/openapi/wiki/v1/add_knowledge 关联笔记到知识库(media_type=11)
/openapi/wiki/v1/search_knowledge 知识库检索
"""
name = "ima"
BASE = "https://ima.qq.com"
def __init__(self, client_id: str = "", api_key: str = "",
knowledge_base_id: str = "", session=None):
super().__init__(session)
creds = _ima_credentials()
self.client_id = client_id or creds["client_id"]
self.api_key = api_key or creds["api_key"]
self.knowledge_base_id = (
knowledge_base_id
or os.environ.get("IMA_KNOWLEDGE_BASE_ID", "")
or (load_kb_config().get("ima") or {}).get("knowledge_base_id", ""))
def available(self) -> bool:
return bool(self.client_id and self.api_key)
def _headers(self) -> Dict[str, str]:
return {"ima-openapi-clientid": self.client_id,
"ima-openapi-apikey": self.api_key,
"Content-Type": "application/json"}
def _ensure_creds(self) -> None:
if not self.available():
raise KnowledgeBaseError(
"IMA 凭证缺失:请设置 IMA_OPENAPI_CLIENTID / IMA_OPENAPI_APIKEY "
"或 ~/.config/ima/client_id、api_key(https://ima.qq.com/agent-interface 获取)")
def push(self, note: KnowledgeNote) -> Dict[str, Any]:
self._ensure_creds()
# 1) 创建笔记(content_format=1 → Markdown)
doc = self._post(self.BASE + "/openapi/note/v1/import_doc",
{"content_format": 1, "title": note.title,
"content": note.to_frontmatter_markdown()},
self._headers())
if doc.get("code") not in (0, None):
raise KnowledgeBaseError(f"IMA import_doc 失败: {doc}")
note_id = (doc.get("data") or {}).get("note_id") or doc.get("note_id", "")
result: Dict[str, Any] = {"adapter": self.name, "status": "pushed",
"note_id": note_id}
# 2) 若配置了知识库则关联
if self.knowledge_base_id and note_id:
add = self._post(self.BASE + "/openapi/wiki/v1/add_knowledge",
{"media_type": 11, "title": note.title,
"knowledge_base_id": self.knowledge_base_id,
"note_id": note_id},
self._headers())
result["linked"] = add.get("code") == 0
return result
def search(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
self._ensure_creds()
body: Dict[str, Any] = {"query": query, "cursor": "", "limit": limit}
if self.knowledge_base_id:
body["knowledge_base_id"] = self.knowledge_base_id
doc = self._post(self.BASE + "/openapi/wiki/v1/search_knowledge",
body, self._headers())
if doc.get("code") not in (0, None):
raise KnowledgeBaseError(f"IMA search_knowledge 失败: {doc}")
data = doc.get("data") or doc
items = data.get("items") or data.get("list") or []
return [{"title": it.get("title", ""),
"snippet": it.get("content", "") or it.get("summary", ""),
"source": "ima"} for it in items[:limit]]
class DifyAdapter(_HttpJsonAdapter):
"""Dify Knowledge API:create-by-text 写入 / retrieve 检索 / 同名文档更新。
端点(Bearer Token):
GET /v1/datasets 知识库列表(定位 dataset)
POST /v1/datasets 创建空知识库
GET /v1/datasets/{id}/documents 文档列表(同名检测)
POST /v1/datasets/{id}/document/create-by-text 新增文档
POST /v1/datasets/{id}/documents/{doc}/update-by-text 更新已有文档
POST /v1/datasets/{id}/retrieve 语义检索(校对用)
"""
name = "dify"
def __init__(self, api_key: str = "", base_url: str = "",
dataset_id: str = "", dataset_name: str = "cn-financial-scraper",
session=None):
super().__init__(session)
cfg = load_kb_config().get("dify") or {}
self.api_key = api_key or os.environ.get("DIFY_API_KEY", "")
self.base_url = (base_url or os.environ.get("DIFY_BASE_URL")
or cfg.get("base_url") or "https://api.dify.ai").rstrip("/")
self.dataset_id = dataset_id or os.environ.get("DIFY_DATASET_ID") \
or cfg.get("dataset_id", "")
self.dataset_name = dataset_name or cfg.get("dataset_name",
"cn-financial-scraper")
def available(self) -> bool:
return bool(self.api_key)
def _headers(self) -> Dict[str, str]:
return {"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"}
def _ensure_key(self) -> None:
if not self.available():
raise KnowledgeBaseError(
"Dify API Key 缺失:请设置 DIFY_API_KEY 环境变量 "
"(Dify 控制台 → 知识库 → API 访问)")
def _get(self, url: str) -> Dict[str, Any]:
resp = http_get(url, headers=self._headers(), timeout=30,
retries=2, session=self.session, use_cache=False,
detect_soft_block=False, scheme_fallback=False)
if resp is None:
raise KnowledgeBaseError(f"请求失败(无响应): {url[:100]}")
try:
return resp.json()
except Exception as exc:
raise KnowledgeBaseError(f"响应非 JSON: {url[:100]}") from exc
def _resolve_dataset(self) -> str:
"""dataset_id 未配置时按名称查找,找不到则创建。"""
if self.dataset_id:
return self.dataset_id
doc = self._get(f"{self.base_url}/v1/datasets?limit=100")
for ds in (doc.get("data") or []):
if ds.get("name") == self.dataset_name:
self.dataset_id = ds["id"]
return self.dataset_id
created = self._post(f"{self.base_url}/v1/datasets",
{"name": self.dataset_name,
"indexing_technique": "high_quality"},
self._headers())
self.dataset_id = created.get("id", "")
if not self.dataset_id:
raise KnowledgeBaseError(f"Dify 创建知识库失败: {created}")
return self.dataset_id
def _find_document(self, dataset_id: str, name: str) -> str:
doc = self._get(f"{self.base_url}/v1/datasets/{dataset_id}"
f"/documents?keyword={name}&limit=20")
for d in (doc.get("data") or []):
if d.get("name") == name:
return d.get("id", "")
return ""
def push(self, note: KnowledgeNote) -> Dict[str, Any]:
self._ensure_key()
ds = self._resolve_dataset()
name = note.title[:80]
existing = self._find_document(ds, name)
body = {
"name": name,
"text": note.to_frontmatter_markdown(),
"indexing_technique": "high_quality",
"doc_language": "Chinese",
"process_rule": {"mode": "automatic"},
}
if existing:
doc = self._post(f"{self.base_url}/v1/datasets/{ds}"
f"/documents/{existing}/update-by-text",
{"name": name,
"text": body["text"],
"doc_language": "Chinese",
"process_rule": {"mode": "automatic"}},
self._headers())
status = "updated"
else:
doc = self._post(f"{self.base_url}/v1/datasets/{ds}"
"/document/create-by-text", body, self._headers())
status = "pushed"
if "document" not in doc and "result" not in doc and doc.get("code"):
raise KnowledgeBaseError(f"Dify 写入失败: {doc}")
return {"adapter": self.name, "status": status,
"dataset_id": ds,
"document_id": (doc.get("document") or {}).get("id", "")}
def search(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
self._ensure_key()
ds = self._resolve_dataset()
doc = self._post(f"{self.base_url}/v1/datasets/{ds}/retrieve",
{"query": query,
"retrieval_model": {
"search_method": "semantic_search",
"reranking_enable": False,
"top_k": limit,
"score_threshold_enabled": False}},
self._headers())
records = doc.get("records") or []
return [{"title": (r.get("segment") or {}).get("document", {}).get("name", ""),
"snippet": (r.get("segment") or {}).get("content", ""),
"score": r.get("score"),
"source": "dify"} for r in records[:limit]]
ADAPTERS: Dict[str, type] = {
"local": LocalKnowledgeAdapter,
"obsidian": ObsidianAdapter,
"ima": ImaAdapter,
"dify": DifyAdapter,
}
def get_adapter(name: Optional[str] = None, **kwargs) -> KnowledgeAdapter:
"""按名称取适配器(默认读配置 target,未配置兜底 local)。"""
name = (name or load_kb_config().get("target") or "local").lower()
cls = ADAPTERS.get(name)
if cls is None:
raise KnowledgeBaseError(f"未知知识库适配器: {name}(支持 "
+ "/".join(ADAPTERS) + ")")
return cls(**kwargs)
# ════════════════ 4. 同步引擎 ════════════════
def sync_to_knowledge_base(target: Optional[str] = None,
mode: str = "overall",
requirement: str = "",
agg: Optional[Dict[str, Any]] = None,
top_articles: int = 20,
target_name: str = "",
adapter: Optional[KnowledgeAdapter] = None) -> Dict[str, Any]:
"""汇总 → 按需求总结 → 推送到知识库,返回同步结果(含失败明细)。
target_name: mode='target' 时的机构/目标名(v7.7 补齐透传)。
"""
notes = summarize_to_notes(agg, mode=mode, target=target_name,
requirement=requirement,
top_articles=top_articles)
if not notes:
return {"target": (target or "local"), "pushed": 0, "skipped": 0,
"failed": [], "message": "无可总结的数据(快照为空)"}
ad = adapter or get_adapter(target)
results, failed = [], []
for note in notes:
try:
results.append(ad.push(note))
except (KnowledgeBaseError, Exception) as exc: # 单篇失败不阻断其余
failed.append({"title": note.title, "error": str(exc)})
# 同步日志(追加)
entry = {"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"target": ad.name, "mode": mode,
"pushed": sum(1 for r in results if r.get("status") == "pushed"),
"updated": sum(1 for r in results if r.get("status") == "updated"),
"skipped": sum(1 for r in results if r.get("status") == "skipped"),
"failed": failed}
try:
log = (json.loads(SYNC_LOG.read_text(encoding="utf-8"))
if SYNC_LOG.exists() else [])
except Exception:
log = []
log.append(entry)
SYNC_LOG.parent.mkdir(parents=True, exist_ok=True)
SYNC_LOG.write_text(json.dumps(log[-200:], ensure_ascii=False, indent=1),
encoding="utf-8")
return {"target": ad.name, "pushed": entry["pushed"],
"updated": entry["updated"], "skipped": entry["skipped"],
"failed": failed, "results": results}
# ════════════════ 5. 知识库校对 ════════════════
_NUM_RE = re.compile(r"\d+(?:,\d{3})*(?:\.\d+)?")
_FACT_LABELS = ("营收", "净利润", "同比", "环比", "处罚", "罚款", "评级", "净值")
def extract_facts(text: str) -> List[Dict[str, str]]:
"""从文本抽取「标签+数字」事实(用于双向比对)。"""
facts = []
for label in _FACT_LABELS:
for m in re.finditer(re.escape(label) + r"[^\n。;;]{0,24}?"
r"(" + _NUM_RE.pattern + r")", text):
facts.append({"label": label, "value": m.group(1),
"context": m.group(0).strip()})
return facts
def verify_against_knowledge_base(items: List[Dict[str, Any]],
adapter: Optional[KnowledgeAdapter] = None,
top_k: int = 3,
title_threshold: float = 0.85) -> Dict[str, Any]:
"""用知识库条目反向校对爬取结果。
对每条爬取条目:检索知识库 → 标题相似匹配 → 数字/事实比对,
输出 verified(一致)/ conflict(数字冲突)/ no_match(库中无记录)。
"""
ad = adapter or get_adapter()
results = []
for item in items:
title = item.get("title") or ""
if not title:
continue
try:
hits = ad.search(title, limit=top_k)
except KnowledgeBaseError as exc:
results.append({"title": title, "status": "error",
"error": str(exc)})
continue
matched = [h for h in hits
if title_similarity(title, h.get("title") or h.get("snippet", "")[:40])
>= title_threshold or (h.get("snippet") and title[:8] in h["snippet"])]
if not matched:
results.append({"title": title, "status": "no_match",
"hits": len(hits)})
continue
# 数字事实比对
src_facts = {f["label"] + "=" + f["value"]
for f in extract_facts(item.get("summary") or item.get("content") or "")}
conflicts = []
for h in matched:
kb_text = h.get("snippet") or h.get("content") or ""
kb_facts = {f["label"] + "=" + f["value"] for f in extract_facts(kb_text)}
for sf in src_facts:
label, val = sf.split("=", 1)
kb_same_label = {kf.split("=", 1)[1] for kf in kb_facts
if kf.startswith(label + "=")}
if kb_same_label and val not in kb_same_label:
conflicts.append({"label": label, "crawled": val,
"knowledge": sorted(kb_same_label)})
results.append({"title": title,
"status": "conflict" if conflicts else "verified",
"matched": [h.get("title", "") for h in matched],
"conflicts": conflicts})
ok = sum(1 for r in results if r["status"] == "verified")
return {"total": len(results),
"verified": ok,
"conflicts": sum(1 for r in results if r["status"] == "conflict"),
"no_match": sum(1 for r in results if r["status"] == "no_match"),
"results": results}
__all__ = [
"KnowledgeBaseError", "KnowledgeNote",
"summarize_to_notes",
"load_kb_config", "save_kb_config",
"KnowledgeAdapter", "LocalKnowledgeAdapter", "ObsidianAdapter",
"ImaAdapter", "DifyAdapter", "ADAPTERS", "get_adapter",
"sync_to_knowledge_base",
"extract_facts", "verify_against_knowledge_base",
]
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="v7.7.0 知识库总结与同步引擎")
ap.add_argument("--target", default="", help="local/obsidian/ima/dify")
ap.add_argument("--mode", default="overall",
choices=["overall", "target", "negative"])
ap.add_argument("--requirement", default="", help="自定义总结需求")
ap.add_argument("--target-name", default="", help="mode=target 时的机构名")
ap.add_argument("--verify", default="", help="校对:传入 JSON 文件路径")
args = ap.parse_args()
if args.verify:
data = json.loads(Path(args.verify).read_text(encoding="utf-8"))
report = verify_against_knowledge_base(
data if isinstance(data, list) else data.get("articles", []),
adapter=get_adapter(args.target or None))
print(json.dumps(report, ensure_ascii=False, indent=1))
else:
res = sync_to_knowledge_base(target=args.target or None,
mode=args.mode,
requirement=args.requirement)
print(json.dumps(res, ensure_ascii=False, indent=1))