dp-zp-agent/tools/crzp/storage/graph.py
2026-11-15 03:36:33 +01:00

106 lines
5.0 KiB
Python

"""Neo4j projection of read CRZP records and fixed relation templates.
Labels are separate from the ZP Wiki graph (CrzpThesis, CrzpPerson,
CrzpKeyword, CrzpSchool), so the two sources never mix. People are keyed by
the name exactly as CRZP writes it; namesakes are not merged with ZP Wiki
people. Only these fixed templates run; the model never writes Cypher. When
Neo4j is unavailable, the same aggregation runs over the read rows in Python.
"""
from __future__ import annotations
import logging
import threading
from collections import Counter
from tools.crzp.source.parse import fold
logger = logging.getLogger(__name__)
ROLE_REL = {"author": "AUTHOR", "supervisor": "SUPERVISOR", "opponent": "OPPONENT"}
ROLE_FIELD = {"author": "authors", "supervisor": "supervisors", "opponent": "opponents"}
_driver = None
_lock = threading.Lock()
PROJECT = """
UNWIND $rows AS row
MERGE (t:CrzpThesis {sid: row.sid})
SET t.title = row.title, t.year = row.year, t.work_type = row.work_type, t.url = row.url
MERGE (s:CrzpSchool {name: row.school})
MERGE (t)-[:AT]->(s)
FOREACH (name IN row.authors | MERGE (p:CrzpPerson {name: name}) MERGE (t)-[:AUTHOR]->(p))
FOREACH (name IN row.supervisors | MERGE (p:CrzpPerson {name: name}) MERGE (t)-[:SUPERVISOR]->(p))
FOREACH (name IN row.opponents | MERGE (p:CrzpPerson {name: name}) MERGE (t)-[:OPPONENT]->(p))
FOREACH (k IN row.keywords | MERGE (w:CrzpKeyword {key: k.key}) ON CREATE SET w.label = k.label
MERGE (t)-[:HAS_KEYWORD]->(w))
"""
# Relation templates: {source} and {target} are taken only from ROLE_REL.
RELATION = """
MATCH (t:CrzpThesis)-[:{source}]->(p:CrzpPerson) WHERE t.sid IN $sids AND p.name IN $names
MATCH (t)-[:{target}]->(o:CrzpPerson) WHERE NOT o.name IN $names
RETURN o.name AS name, count(DISTINCT t) AS works ORDER BY works DESC, name
"""
SHARED_KEYWORDS = """
MATCH (t:CrzpThesis {sid: $sid})-[:HAS_KEYWORD]->(k:CrzpKeyword)<-[:HAS_KEYWORD]-(o:CrzpThesis)
WHERE o.sid IN $candidates
RETURN o.sid AS sid, collect(k.label) AS shared ORDER BY size(shared) DESC, sid
"""
def _session():
global _driver
from tools.graphrag.graph_db import create_neo4j_driver, get_neo4j_settings
with _lock:
if _driver is None:
_driver = create_neo4j_driver(connection_timeout=3, max_transaction_retry_time=3)
return _driver.session(database=get_neo4j_settings().database)
def project(rows: list[dict]) -> bool:
"""Write the rows (idempotent MERGE); False when Neo4j is unavailable."""
data = [{**{k: row.get(k) for k in ("sid", "title", "year", "work_type", "url")},
"school": row.get("school") or "", "authors": row.get("authors", []),
"supervisors": row.get("supervisors", []), "opponents": row.get("opponents", []),
"keywords": [{"key": fold(k), "label": k} for k in row.get("keywords", [])]}
for row in rows if row.get("sid")]
if not data:
return False
try:
with _session() as session:
session.execute_write(lambda tx: tx.run(PROJECT, rows=data).consume())
return True
except Exception:
logger.warning("CRZP projection to Neo4j failed; Python aggregation is used.")
return False
def relation(rows: list[dict], names: list[str], source: str, target: str) -> list[tuple[str, int]]:
"""People in the target role on the works where `names` have the source role."""
sids = [r["sid"] for r in rows if r.get("sid")]
if len(sids) == len(rows) and project(rows):
try:
with _session() as session:
result = session.run(RELATION.format(source=ROLE_REL[source], target=ROLE_REL[target]),
sids=sids, names=names)
return [(r["name"], r["works"]) for r in result]
except Exception:
logger.warning("CRZP relation query failed; Python aggregation is used.")
counts = Counter()
for row in rows:
if any(n in names for n in row.get(ROLE_FIELD[source], [])):
counts.update({o for o in row.get(ROLE_FIELD[target], []) if o not in names})
return sorted(counts.items(), key=lambda item: (-item[1], item[0]))
def shared_keywords(work: dict, candidates: list[dict]) -> list[tuple[dict, list[str]]]:
"""Candidates sharing CRZP keywords with the work, most shared first."""
by_sid = {c["sid"]: c for c in candidates if c.get("sid") and c.get("sid") != work.get("sid")}
if work.get("sid") and project([work, *by_sid.values()]):
try:
with _session() as session:
result = session.run(SHARED_KEYWORDS, sid=work["sid"], candidates=list(by_sid))
return [(by_sid[r["sid"]], r["shared"]) for r in result if r["sid"] in by_sid]
except Exception:
logger.warning("CRZP keyword query failed; Python comparison is used.")
own = {fold(k): k for k in work.get("keywords", [])}
scored = [(c, [own[fold(k)] for k in c.get("keywords", []) if fold(k) in own]) for c in by_sid.values()]
return sorted([s for s in scored if s[1]], key=lambda s: (-len(s[1]), s[0]["sid"]))