106 lines
5.0 KiB
Python
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"]))
|