import logging from app.core.graph_db import neo4j_db logger = logging.getLogger(__name__) async def get_existing_skills_for_strand(strand_name: str) -> list[str]: """ Fetches all existing Skills under a specific Strand. We pass this to the AI so it reuses existing nodes instead of creating 100 variations of the same skill. """ if not neo4j_db.driver: return [] query = """ MATCH (s:Strand {name: $strand_name})<-[:FALLS_UNDER]-(d:Domain)<-[:BELONGS_TO]-(k:Skill) RETURN k.name AS skill_name LIMIT 30 """ try: records, _, _ = await neo4j_db.driver.execute_query( query, strand_name=strand_name.upper() ) return [record["skill_name"] for record in records] except Exception as e: logger.error(f"Failed to fetch existing skills: {str(e)}") return [] async def get_user_skill_web(user_id: str) -> dict | None: """ Builds the user's complete RPG profile directly from the Graph DB. Returns their Strand XP distribution and their highest-leveled Skills as structured JSON. """ if not neo4j_db.driver: logger.error("Neo4j driver not initialized.") return None # Query 1: Base Classes (Strands) xp_query = """ MATCH (u:User {id: $user_id})-[e:EXPLORED]->(s:Strand) RETURN s.name AS strand, e.total_xp AS xp """ # Query 2: Mastered Skills with Aggregated Domains skills_query = """ MATCH (u:User {id: $user_id})-[m:MASTERED]->(k:Skill) OPTIONAL MATCH (k)-[:BELONGS_TO]->(d:Domain)-[:FALLS_UNDER]->(s:Strand) RETURN k.name AS skill_name, collect(DISTINCT d.name) AS domains, collect(DISTINCT s.name) AS strands, m.level AS level, m.total_xp AS xp ORDER BY level DESC, xp DESC LIMIT 12 """ try: xp_records, _, _ = await neo4j_db.driver.execute_query( xp_query, user_id=user_id ) skill_records, _, _ = await neo4j_db.driver.execute_query( skills_query, user_id=user_id ) if not xp_records and not skill_records: return None # Format Strand distribution cleanly (e.g., {"stem": 1400, "abm": 50}) xp_distribution = { record["strand"].lower(): record["xp"] for record in xp_records } # Build strict JSON objects, eliminating frontend Regex parsing top_skills = [] for rec in skill_records: strands = rec["strands"] primary_strand = strands[0].lower() if strands else "stem" top_skills.append( { "skill_name": rec["skill_name"], "domains": rec["domains"], # Array of strings mapping many-to-many "strand": primary_strand, "level": rec["level"], "xp": rec["xp"], } ) return {"xp_distribution": xp_distribution, "top_skills": top_skills} except Exception as e: logger.error(f"Failed to fetch Skill Web from Neo4j: {str(e)}") return None async def save_skill_to_graph( user_id: str, strand_name: str, domain_name: str, skill_name: str, xp_awarded: int ) -> bool: """ The core RPG Engine. Builds the Strand <- Domain <- Skill hierarchy and awards the user XP/Levels across the chain. """ if not neo4j_db.driver: return False query = """ // 1. Ensure User and Base Strand exist MERGE (u:User {id: $user_id}) MERGE (s:Strand {name: $strand_name}) // 2. Ensure Domain exists and belongs to the Strand MERGE (d:Domain {name: $domain_name}) MERGE (d)-[:FALLS_UNDER]->(s) // 3. Ensure Skill exists and belongs to the Domain MERGE (k:Skill {name: $skill_name}) MERGE (k)-[:BELONGS_TO]->(d) // 4. Award XP to the Strand Edge MERGE (u)-[e:EXPLORED]->(s) ON CREATE SET e.total_xp = $xp_awarded ON MATCH SET e.total_xp = e.total_xp + $xp_awarded // 5. Award XP to the Domain Edge MERGE (u)-[std:STUDIED]->(d) ON CREATE SET std.total_xp = $xp_awarded ON MATCH SET std.total_xp = std.total_xp + $xp_awarded // 6. Level Up the specific Skill Edge MERGE (u)-[m:MASTERED]->(k) ON CREATE SET m.level = 1, m.total_xp = $xp_awarded ON MATCH SET m.level = m.level + 1, m.total_xp = m.total_xp + $xp_awarded RETURN u, s, d, k """ try: await neo4j_db.driver.execute_query( query, user_id=user_id, strand_name=strand_name.upper(), domain_name=domain_name, skill_name=skill_name, xp_awarded=xp_awarded, ) return True except Exception as e: logger.error(f"Failed to graph skill to Neo4j: {str(e)}") return False