986 lines
35 KiB
Python
986 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
IndexEntityMixin extracted from IndexManager.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import sqlite3
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class IndexEntityMixin:
|
|
def upsert_entity(self, entity: EntityMeta, update_metadata: bool = False) -> bool:
|
|
"""
|
|
插入或更新实体 (智能合并)
|
|
|
|
- 新实体: 直接插入
|
|
- 已存在: 更新 current_json, last_appearance, updated_at
|
|
- update_metadata=True: 同时更新 canonical_name/tier/desc/is_protagonist/is_archived
|
|
|
|
返回是否为新实体
|
|
"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 检查是否存在
|
|
cursor.execute(
|
|
"SELECT id, current_json FROM entities WHERE id = ?", (entity.id,)
|
|
)
|
|
existing = cursor.fetchone()
|
|
|
|
if existing:
|
|
# 已存在: 智能合并 current_json
|
|
old_current = {}
|
|
if existing["current_json"]:
|
|
try:
|
|
old_current = json.loads(existing["current_json"])
|
|
except json.JSONDecodeError as exc:
|
|
logger.warning(
|
|
"failed to parse JSON in entities.current_json: %s",
|
|
exc,
|
|
)
|
|
|
|
# 合并 current (新值覆盖旧值)
|
|
merged_current = {**old_current, **entity.current}
|
|
|
|
if update_metadata:
|
|
# 完整更新(包括元数据)
|
|
cursor.execute(
|
|
"""
|
|
UPDATE entities SET
|
|
canonical_name = ?,
|
|
tier = ?,
|
|
desc = ?,
|
|
current_json = ?,
|
|
last_appearance = ?,
|
|
is_protagonist = ?,
|
|
is_archived = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
entity.canonical_name,
|
|
entity.tier,
|
|
entity.desc,
|
|
json.dumps(merged_current, ensure_ascii=False),
|
|
entity.last_appearance,
|
|
1 if entity.is_protagonist else 0,
|
|
1 if entity.is_archived else 0,
|
|
entity.id,
|
|
),
|
|
)
|
|
else:
|
|
# 只更新 current 和 last_appearance
|
|
cursor.execute(
|
|
"""
|
|
UPDATE entities SET
|
|
current_json = ?,
|
|
last_appearance = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
json.dumps(merged_current, ensure_ascii=False),
|
|
entity.last_appearance,
|
|
entity.id,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return False
|
|
else:
|
|
# 新实体: 插入
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO entities
|
|
(id, type, canonical_name, tier, desc, current_json,
|
|
first_appearance, last_appearance, is_protagonist, is_archived)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
entity.id,
|
|
entity.type,
|
|
entity.canonical_name,
|
|
entity.tier,
|
|
entity.desc,
|
|
json.dumps(entity.current, ensure_ascii=False),
|
|
entity.first_appearance,
|
|
entity.last_appearance,
|
|
1 if entity.is_protagonist else 0,
|
|
1 if entity.is_archived else 0,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return True
|
|
|
|
def get_entity(self, entity_id: str) -> Optional[Dict]:
|
|
"""获取单个实体"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT * FROM entities WHERE id = ?", (entity_id,))
|
|
row = cursor.fetchone()
|
|
if row:
|
|
return self._row_to_dict(row, parse_json=["current_json"])
|
|
return None
|
|
|
|
def get_entities_by_type(
|
|
self, entity_type: str, include_archived: bool = False
|
|
) -> List[Dict]:
|
|
"""按类型获取实体"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
if include_archived:
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM entities WHERE type = ?
|
|
ORDER BY last_appearance DESC
|
|
""",
|
|
(entity_type,),
|
|
)
|
|
else:
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM entities WHERE type = ? AND is_archived = 0
|
|
ORDER BY last_appearance DESC
|
|
""",
|
|
(entity_type,),
|
|
)
|
|
return [
|
|
self._row_to_dict(row, parse_json=["current_json"])
|
|
for row in cursor.fetchall()
|
|
]
|
|
|
|
def get_entities_by_tier(self, tier: str) -> List[Dict]:
|
|
"""按重要度获取实体 (核心/重要/次要/装饰)"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM entities WHERE tier = ? AND is_archived = 0
|
|
ORDER BY last_appearance DESC
|
|
""",
|
|
(tier,),
|
|
)
|
|
return [
|
|
self._row_to_dict(row, parse_json=["current_json"])
|
|
for row in cursor.fetchall()
|
|
]
|
|
|
|
def get_core_entities(self) -> List[Dict]:
|
|
"""获取所有核心实体 (用于 Context Agent 全量加载)"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT * FROM entities
|
|
WHERE (tier IN ('核心', '重要') OR is_protagonist = 1) AND is_archived = 0
|
|
ORDER BY is_protagonist DESC, tier, last_appearance DESC
|
|
""")
|
|
return [
|
|
self._row_to_dict(row, parse_json=["current_json"])
|
|
for row in cursor.fetchall()
|
|
]
|
|
|
|
def get_protagonist(self) -> Optional[Dict]:
|
|
"""获取主角实体"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT * FROM entities WHERE is_protagonist = 1 LIMIT 1")
|
|
row = cursor.fetchone()
|
|
if row:
|
|
return self._row_to_dict(row, parse_json=["current_json"])
|
|
return None
|
|
|
|
def update_entity_current(self, entity_id: str, updates: Dict) -> bool:
|
|
"""
|
|
增量更新实体的 current 字段 (不覆盖其他字段)
|
|
|
|
例如: update_entity_current("xiaoyan", {"realm": "斗师"})
|
|
"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute(
|
|
"SELECT current_json FROM entities WHERE id = ?", (entity_id,)
|
|
)
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return False
|
|
|
|
current = {}
|
|
if row["current_json"]:
|
|
try:
|
|
current = json.loads(row["current_json"])
|
|
except json.JSONDecodeError as exc:
|
|
logger.warning(
|
|
"failed to parse JSON in update_entity_current current_json: %s",
|
|
exc,
|
|
)
|
|
|
|
current.update(updates)
|
|
|
|
cursor.execute(
|
|
"""
|
|
UPDATE entities SET
|
|
current_json = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""",
|
|
(json.dumps(current, ensure_ascii=False), entity_id),
|
|
)
|
|
conn.commit()
|
|
return True
|
|
|
|
def archive_entity(self, entity_id: str) -> bool:
|
|
"""归档实体 (不删除,只是标记)"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
UPDATE entities SET is_archived = 1, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""",
|
|
(entity_id,),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
# ==================== v5.1 别名操作 ====================
|
|
|
|
def register_alias(self, alias: str, entity_id: str, entity_type: str) -> bool:
|
|
"""
|
|
注册别名 (支持一对多)
|
|
|
|
同一别名可映射多个实体 (如 "天云宗" → 地点 + 势力)
|
|
"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
try:
|
|
cursor.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO aliases (alias, entity_id, entity_type)
|
|
VALUES (?, ?, ?)
|
|
""",
|
|
(alias, entity_id, entity_type),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
except sqlite3.IntegrityError:
|
|
return False
|
|
|
|
def get_entities_by_alias(self, alias: str) -> List[Dict]:
|
|
"""
|
|
根据别名查找实体 (一对多)
|
|
|
|
返回所有匹配的实体 (可能有多个不同类型)
|
|
"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT e.*, a.entity_type as alias_type
|
|
FROM entities e
|
|
JOIN aliases a ON e.id = a.entity_id
|
|
WHERE a.alias = ?
|
|
""",
|
|
(alias,),
|
|
)
|
|
return [
|
|
self._row_to_dict(row, parse_json=["current_json"])
|
|
for row in cursor.fetchall()
|
|
]
|
|
|
|
def get_entity_aliases(self, entity_id: str) -> List[str]:
|
|
"""获取实体的所有别名"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT alias FROM aliases WHERE entity_id = ?", (entity_id,)
|
|
)
|
|
return [row["alias"] for row in cursor.fetchall()]
|
|
|
|
def remove_alias(self, alias: str, entity_id: str) -> bool:
|
|
"""移除别名"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"DELETE FROM aliases WHERE alias = ? AND entity_id = ?",
|
|
(alias, entity_id),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
# ==================== v5.1 状态变化操作 ====================
|
|
|
|
def record_state_change(self, change: StateChangeMeta) -> int:
|
|
"""
|
|
记录状态变化
|
|
|
|
返回记录 ID
|
|
"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO state_changes
|
|
(entity_id, field, old_value, new_value, reason, chapter)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
change.entity_id,
|
|
change.field,
|
|
change.old_value,
|
|
change.new_value,
|
|
change.reason,
|
|
change.chapter,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return cursor.lastrowid
|
|
|
|
def get_entity_state_changes(self, entity_id: str, limit: int = 20) -> List[Dict]:
|
|
"""获取实体的状态变化历史"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM state_changes
|
|
WHERE entity_id = ?
|
|
ORDER BY chapter DESC, id DESC
|
|
LIMIT ?
|
|
""",
|
|
(entity_id, limit),
|
|
)
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
|
|
def get_recent_state_changes(self, limit: int = 50) -> List[Dict]:
|
|
"""获取最近的状态变化"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM state_changes
|
|
ORDER BY chapter DESC, id DESC
|
|
LIMIT ?
|
|
""",
|
|
(limit,),
|
|
)
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
|
|
def get_chapter_state_changes(self, chapter: int) -> List[Dict]:
|
|
"""获取某章的所有状态变化"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM state_changes
|
|
WHERE chapter = ?
|
|
ORDER BY id
|
|
""",
|
|
(chapter,),
|
|
)
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
|
|
# ==================== v5.1 关系操作 ====================
|
|
|
|
def upsert_relationship(self, rel: RelationshipMeta) -> bool:
|
|
"""
|
|
插入或更新关系
|
|
|
|
相同 (from, to, type) 会更新 description 和 chapter
|
|
返回是否为新关系
|
|
"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 检查是否存在
|
|
cursor.execute(
|
|
"""
|
|
SELECT id FROM relationships
|
|
WHERE from_entity = ? AND to_entity = ? AND type = ?
|
|
""",
|
|
(rel.from_entity, rel.to_entity, rel.type),
|
|
)
|
|
existing = cursor.fetchone()
|
|
|
|
if existing:
|
|
cursor.execute(
|
|
"""
|
|
UPDATE relationships SET
|
|
description = ?,
|
|
chapter = ?
|
|
WHERE id = ?
|
|
""",
|
|
(rel.description, rel.chapter, existing["id"]),
|
|
)
|
|
conn.commit()
|
|
return False
|
|
else:
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO relationships
|
|
(from_entity, to_entity, type, description, chapter)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
rel.from_entity,
|
|
rel.to_entity,
|
|
rel.type,
|
|
rel.description,
|
|
rel.chapter,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return True
|
|
|
|
def get_entity_relationships(
|
|
self, entity_id: str, direction: str = "both"
|
|
) -> List[Dict]:
|
|
"""
|
|
获取实体的关系
|
|
|
|
direction: "from" | "to" | "both"
|
|
"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
if direction == "from":
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM relationships WHERE from_entity = ?
|
|
ORDER BY chapter DESC
|
|
""",
|
|
(entity_id,),
|
|
)
|
|
elif direction == "to":
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM relationships WHERE to_entity = ?
|
|
ORDER BY chapter DESC
|
|
""",
|
|
(entity_id,),
|
|
)
|
|
else: # both
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM relationships
|
|
WHERE from_entity = ? OR to_entity = ?
|
|
ORDER BY chapter DESC
|
|
""",
|
|
(entity_id, entity_id),
|
|
)
|
|
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
|
|
def get_relationship_between(self, entity1: str, entity2: str) -> List[Dict]:
|
|
"""获取两个实体之间的所有关系"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM relationships
|
|
WHERE (from_entity = ? AND to_entity = ?)
|
|
OR (from_entity = ? AND to_entity = ?)
|
|
ORDER BY chapter DESC
|
|
""",
|
|
(entity1, entity2, entity2, entity1),
|
|
)
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
|
|
def get_recent_relationships(self, limit: int = 30) -> List[Dict]:
|
|
"""获取最近建立的关系"""
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT * FROM relationships
|
|
ORDER BY chapter DESC, id DESC
|
|
LIMIT ?
|
|
""",
|
|
(limit,),
|
|
)
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
|
|
# ==================== v5.5 关系事件与图谱 ====================
|
|
|
|
def _infer_relationship_polarity(self, rel_type: str) -> int:
|
|
"""基于关系类型推断极性:-1 敌对,0 中立,1 友好。"""
|
|
t = str(rel_type or "")
|
|
positive_keywords = ("盟友", "友好", "师徒", "同伴", "亲", "爱", "合作")
|
|
negative_keywords = ("敌", "仇", "恨", "对立", "冲突", "背叛", "追杀")
|
|
|
|
if any(k in t for k in negative_keywords):
|
|
return -1
|
|
if any(k in t for k in positive_keywords):
|
|
return 1
|
|
return 0
|
|
|
|
def record_relationship_event(self, event: RelationshipEventMeta) -> int:
|
|
"""记录关系事件,返回事件 ID。"""
|
|
from_entity = str(getattr(event, "from_entity", "") or "").strip()
|
|
to_entity = str(getattr(event, "to_entity", "") or "").strip()
|
|
rel_type = str(getattr(event, "type", "") or "").strip()
|
|
if not from_entity or not to_entity or not rel_type:
|
|
return 0
|
|
|
|
action = str(getattr(event, "action", "update") or "update").strip().lower()
|
|
if action not in {"create", "update", "decay", "remove"}:
|
|
action = "update"
|
|
|
|
try:
|
|
chapter = int(getattr(event, "chapter", 0) or 0)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
if chapter <= 0:
|
|
return 0
|
|
try:
|
|
scene_index = int(getattr(event, "scene_index", 0) or 0)
|
|
except (TypeError, ValueError):
|
|
scene_index = 0
|
|
|
|
raw_polarity = getattr(event, "polarity", None)
|
|
if raw_polarity is None:
|
|
polarity = self._infer_relationship_polarity(rel_type)
|
|
else:
|
|
try:
|
|
polarity = int(raw_polarity)
|
|
except (TypeError, ValueError):
|
|
polarity = 0
|
|
if polarity > 1:
|
|
polarity = 1
|
|
elif polarity < -1:
|
|
polarity = -1
|
|
|
|
try:
|
|
strength = float(getattr(event, "strength", 0.5) or 0.5)
|
|
except (TypeError, ValueError):
|
|
strength = 0.5
|
|
strength = max(0.0, min(1.0, strength))
|
|
|
|
description = str(getattr(event, "description", "") or "").strip()
|
|
evidence = str(getattr(event, "evidence", "") or "").strip()
|
|
try:
|
|
confidence = float(getattr(event, "confidence", 1.0) or 1.0)
|
|
except (TypeError, ValueError):
|
|
confidence = 1.0
|
|
confidence = max(0.0, min(1.0, confidence))
|
|
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO relationship_events
|
|
(from_entity, to_entity, type, action, polarity, strength, description, chapter, scene_index, evidence, confidence)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
from_entity,
|
|
to_entity,
|
|
rel_type,
|
|
action,
|
|
polarity,
|
|
strength,
|
|
description,
|
|
chapter,
|
|
scene_index,
|
|
evidence,
|
|
confidence,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return int(cursor.lastrowid or 0)
|
|
|
|
def get_relationship_events(
|
|
self,
|
|
entity_id: str,
|
|
direction: str = "both",
|
|
from_chapter: Optional[int] = None,
|
|
to_chapter: Optional[int] = None,
|
|
limit: int = 100,
|
|
) -> List[Dict[str, Any]]:
|
|
"""按实体查询关系事件。"""
|
|
direction = str(direction or "both").lower()
|
|
clauses: List[str] = []
|
|
params: List[Any] = []
|
|
|
|
if direction == "from":
|
|
clauses.append("from_entity = ?")
|
|
params.append(entity_id)
|
|
elif direction == "to":
|
|
clauses.append("to_entity = ?")
|
|
params.append(entity_id)
|
|
else:
|
|
clauses.append("(from_entity = ? OR to_entity = ?)")
|
|
params.extend([entity_id, entity_id])
|
|
|
|
if from_chapter is not None:
|
|
clauses.append("chapter >= ?")
|
|
params.append(int(from_chapter))
|
|
if to_chapter is not None:
|
|
clauses.append("chapter <= ?")
|
|
params.append(int(to_chapter))
|
|
|
|
where_sql = " AND ".join(clauses) if clauses else "1=1"
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
f"""
|
|
SELECT * FROM relationship_events
|
|
WHERE {where_sql}
|
|
ORDER BY chapter DESC, id DESC
|
|
LIMIT ?
|
|
""",
|
|
(*params, int(limit)),
|
|
)
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
|
|
def get_relationship_timeline(
|
|
self,
|
|
entity1: str,
|
|
entity2: str,
|
|
from_chapter: Optional[int] = None,
|
|
to_chapter: Optional[int] = None,
|
|
limit: int = 100,
|
|
) -> List[Dict[str, Any]]:
|
|
"""查询两个实体之间的关系时间线。"""
|
|
clauses = [
|
|
"((from_entity = ? AND to_entity = ?) OR (from_entity = ? AND to_entity = ?))"
|
|
]
|
|
params: List[Any] = [entity1, entity2, entity2, entity1]
|
|
|
|
if from_chapter is not None:
|
|
clauses.append("chapter >= ?")
|
|
params.append(int(from_chapter))
|
|
if to_chapter is not None:
|
|
clauses.append("chapter <= ?")
|
|
params.append(int(to_chapter))
|
|
|
|
where_sql = " AND ".join(clauses)
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
f"""
|
|
SELECT * FROM relationship_events
|
|
WHERE {where_sql}
|
|
ORDER BY chapter ASC, id ASC
|
|
LIMIT ?
|
|
""",
|
|
(*params, int(limit)),
|
|
)
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
|
|
def _load_effective_relationship_edges(
|
|
self,
|
|
chapter: Optional[int] = None,
|
|
relation_types: Optional[List[str]] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""加载指定章节截面的有效关系边。"""
|
|
relation_types = [str(t) for t in (relation_types or []) if str(t).strip()]
|
|
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
if chapter is None:
|
|
clauses = []
|
|
params: List[Any] = []
|
|
if relation_types:
|
|
placeholders = ",".join("?" for _ in relation_types)
|
|
clauses.append(f"type IN ({placeholders})")
|
|
params.extend(relation_types)
|
|
|
|
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
cursor.execute(
|
|
f"""
|
|
SELECT from_entity, to_entity, type, description, chapter
|
|
FROM relationships
|
|
{where_sql}
|
|
ORDER BY chapter DESC, id DESC
|
|
""",
|
|
tuple(params),
|
|
)
|
|
rows = cursor.fetchall()
|
|
return [
|
|
{
|
|
"from": str(r["from_entity"]),
|
|
"to": str(r["to_entity"]),
|
|
"type": str(r["type"]),
|
|
"description": str(r["description"] or ""),
|
|
"chapter": int(r["chapter"] or 0),
|
|
"action": "snapshot",
|
|
"polarity": self._infer_relationship_polarity(str(r["type"])),
|
|
"strength": 0.5,
|
|
"evidence": "",
|
|
"confidence": 1.0,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
clauses = ["chapter <= ?"]
|
|
params = [int(chapter)]
|
|
if relation_types:
|
|
placeholders = ",".join("?" for _ in relation_types)
|
|
clauses.append(f"type IN ({placeholders})")
|
|
params.extend(relation_types)
|
|
|
|
cursor.execute(
|
|
f"""
|
|
SELECT *
|
|
FROM relationship_events
|
|
WHERE {' AND '.join(clauses)}
|
|
ORDER BY chapter DESC, id DESC
|
|
""",
|
|
tuple(params),
|
|
)
|
|
event_rows = cursor.fetchall()
|
|
|
|
# 兼容旧数据:若事件流不完整,回退 relationships 快照补边
|
|
snapshot_clauses = ["chapter <= ?"]
|
|
snapshot_params: List[Any] = [int(chapter)]
|
|
if relation_types:
|
|
placeholders = ",".join("?" for _ in relation_types)
|
|
snapshot_clauses.append(f"type IN ({placeholders})")
|
|
snapshot_params.extend(relation_types)
|
|
cursor.execute(
|
|
f"""
|
|
SELECT from_entity, to_entity, type, description, chapter
|
|
FROM relationships
|
|
WHERE {' AND '.join(snapshot_clauses)}
|
|
ORDER BY chapter DESC, id DESC
|
|
""",
|
|
tuple(snapshot_params),
|
|
)
|
|
snapshot_rows = cursor.fetchall()
|
|
|
|
# 章节截面:相同关系只保留“最近一次事件”,remove 视为已失效。
|
|
effective: List[Dict[str, Any]] = []
|
|
seen: set[tuple[str, str, str]] = set()
|
|
for row in event_rows:
|
|
key = (
|
|
str(row["from_entity"]),
|
|
str(row["to_entity"]),
|
|
str(row["type"]),
|
|
)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
action = str(row["action"] or "update")
|
|
if action == "remove":
|
|
continue
|
|
effective.append(
|
|
{
|
|
"from": key[0],
|
|
"to": key[1],
|
|
"type": key[2],
|
|
"description": str(row["description"] or ""),
|
|
"chapter": int(row["chapter"] or 0),
|
|
"action": action,
|
|
"polarity": int(row["polarity"] or 0),
|
|
"strength": float(row["strength"] or 0.5),
|
|
"evidence": str(row["evidence"] or ""),
|
|
"confidence": float(row["confidence"] or 1.0),
|
|
}
|
|
)
|
|
|
|
# 事件流缺失时,从关系快照补齐(若 key 已出现则以事件为准)
|
|
for row in snapshot_rows:
|
|
key = (
|
|
str(row["from_entity"]),
|
|
str(row["to_entity"]),
|
|
str(row["type"]),
|
|
)
|
|
if key in seen:
|
|
continue
|
|
effective.append(
|
|
{
|
|
"from": key[0],
|
|
"to": key[1],
|
|
"type": key[2],
|
|
"description": str(row["description"] or ""),
|
|
"chapter": int(row["chapter"] or 0),
|
|
"action": "snapshot",
|
|
"polarity": self._infer_relationship_polarity(key[2]),
|
|
"strength": 0.5,
|
|
"evidence": "",
|
|
"confidence": 1.0,
|
|
}
|
|
)
|
|
return effective
|
|
|
|
def build_relationship_subgraph(
|
|
self,
|
|
center_entity: str,
|
|
depth: int = 2,
|
|
chapter: Optional[int] = None,
|
|
top_edges: int = 50,
|
|
relation_types: Optional[List[str]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""按中心实体构建关系子图。"""
|
|
center_entity = str(center_entity or "").strip()
|
|
depth = max(1, int(depth or 1))
|
|
top_edges = max(1, int(top_edges or 1))
|
|
|
|
edges_all = self._load_effective_relationship_edges(
|
|
chapter=chapter,
|
|
relation_types=relation_types,
|
|
)
|
|
edges_all.sort(key=lambda x: int(x.get("chapter", 0)), reverse=True)
|
|
|
|
selected_edges: List[Dict[str, Any]] = []
|
|
selected_keys: set[tuple[str, str, str]] = set()
|
|
visited_nodes: set[str] = {center_entity} if center_entity else set()
|
|
frontier: set[str] = {center_entity} if center_entity else set()
|
|
|
|
for _ in range(depth):
|
|
if not frontier:
|
|
break
|
|
next_frontier: set[str] = set()
|
|
|
|
for edge in edges_all:
|
|
from_entity = str(edge.get("from") or "")
|
|
to_entity = str(edge.get("to") or "")
|
|
if from_entity not in frontier and to_entity not in frontier:
|
|
continue
|
|
|
|
key = (from_entity, to_entity, str(edge.get("type") or ""))
|
|
if key in selected_keys:
|
|
continue
|
|
selected_keys.add(key)
|
|
selected_edges.append(edge)
|
|
|
|
if from_entity and from_entity not in visited_nodes:
|
|
visited_nodes.add(from_entity)
|
|
next_frontier.add(from_entity)
|
|
if to_entity and to_entity not in visited_nodes:
|
|
visited_nodes.add(to_entity)
|
|
next_frontier.add(to_entity)
|
|
|
|
if len(selected_edges) >= top_edges:
|
|
break
|
|
|
|
frontier = next_frontier
|
|
if len(selected_edges) >= top_edges:
|
|
break
|
|
|
|
if center_entity and center_entity not in visited_nodes:
|
|
visited_nodes.add(center_entity)
|
|
|
|
# 查询节点详情
|
|
entity_map: Dict[str, Dict[str, Any]] = {}
|
|
if visited_nodes:
|
|
with self._get_conn() as conn:
|
|
cursor = conn.cursor()
|
|
placeholders = ",".join("?" for _ in visited_nodes)
|
|
cursor.execute(
|
|
f"""
|
|
SELECT id, canonical_name, type, tier, last_appearance
|
|
FROM entities
|
|
WHERE id IN ({placeholders})
|
|
""",
|
|
tuple(visited_nodes),
|
|
)
|
|
for row in cursor.fetchall():
|
|
entity_map[str(row["id"])] = {
|
|
"id": str(row["id"]),
|
|
"name": str(row["canonical_name"] or row["id"]),
|
|
"type": str(row["type"] or "未知"),
|
|
"tier": str(row["tier"] or "装饰"),
|
|
"last_appearance": int(row["last_appearance"] or 0),
|
|
}
|
|
|
|
nodes: List[Dict[str, Any]] = []
|
|
for entity_id in sorted(
|
|
visited_nodes,
|
|
key=lambda eid: (
|
|
0 if eid == center_entity else 1,
|
|
-(entity_map.get(eid, {}).get("last_appearance", 0)),
|
|
eid,
|
|
),
|
|
):
|
|
if entity_id in entity_map:
|
|
nodes.append(entity_map[entity_id])
|
|
else:
|
|
nodes.append(
|
|
{
|
|
"id": entity_id,
|
|
"name": entity_id or "未知",
|
|
"type": "未知",
|
|
"tier": "装饰",
|
|
"last_appearance": 0,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"center": center_entity,
|
|
"depth": depth,
|
|
"chapter": chapter,
|
|
"nodes": nodes,
|
|
"edges": selected_edges[:top_edges],
|
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
|
}
|
|
|
|
def _sanitize_mermaid_node_id(self, raw_id: str) -> str:
|
|
safe = re.sub(r"[^0-9a-zA-Z_]", "_", str(raw_id or "node"))
|
|
if not safe:
|
|
safe = "node"
|
|
if safe[0].isdigit():
|
|
safe = f"n_{safe}"
|
|
return safe
|
|
|
|
def render_relationship_subgraph_mermaid(self, graph: Dict[str, Any]) -> str:
|
|
"""将关系子图渲染为 Mermaid。"""
|
|
lines = ["```mermaid", "graph LR"]
|
|
nodes = graph.get("nodes") or []
|
|
edges = graph.get("edges") or []
|
|
|
|
if not nodes:
|
|
lines.append(" EMPTY[暂无关系数据]")
|
|
lines.append("```")
|
|
return "\n".join(lines)
|
|
|
|
node_alias: Dict[str, str] = {}
|
|
for node in nodes:
|
|
entity_id = str(node.get("id") or "")
|
|
if not entity_id:
|
|
continue
|
|
alias = self._sanitize_mermaid_node_id(entity_id)
|
|
node_alias[entity_id] = alias
|
|
label = str(node.get("name") or entity_id).replace('"', "'")
|
|
lines.append(f' {alias}["{label}"]')
|
|
|
|
for edge in edges:
|
|
from_entity = str(edge.get("from") or "")
|
|
to_entity = str(edge.get("to") or "")
|
|
if from_entity not in node_alias or to_entity not in node_alias:
|
|
continue
|
|
edge_type = str(edge.get("type") or "关联")
|
|
chapter = edge.get("chapter")
|
|
chapter_suffix = f"@{chapter}" if chapter not in (None, "") else ""
|
|
label = f"{edge_type}{chapter_suffix}".replace('"', "'")
|
|
try:
|
|
polarity = int(edge.get("polarity", 0) or 0)
|
|
except (TypeError, ValueError):
|
|
polarity = 0
|
|
if polarity < 0:
|
|
connector = "-.->"
|
|
else:
|
|
connector = "-->"
|
|
lines.append(
|
|
f" {node_alias[from_entity]} {connector}|{label}| {node_alias[to_entity]}"
|
|
)
|
|
|
|
lines.append("```")
|
|
return "\n".join(lines)
|
|
|
|
# ==================== v5.3 Override Contract 操作 ====================
|
|
|
|
|
|
def update_entity_field(self, entity_id: str, field: str, value: Any) -> bool:
|
|
"""Compatibility helper to update a single entity field in current_json."""
|
|
return self.update_entity_current(entity_id, {field: value})
|