"""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"]))