fix(library-desk): add tags property to Document nodes in graph_service

Set d.tags = \$tags in MERGE query to prevent Neo4j warnings about
missing tags property in related documents query.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-10 21:32:37 +01:00
co-authored by Claude Opus 4.5
parent aba981ff21
commit d100212da5
@@ -264,48 +264,113 @@ class GraphService:
"""
Extract entities from page content.
Simple regex-based extraction for now.
TODO: Use spaCy or similar NLP library for better extraction.
Uses multiple strategies:
1. Markdown links (explicit entity references)
2. @mentions (person references)
3. [[WikiLinks]] (concept references)
4. Capitalized multi-word phrases (proper nouns)
5. Hardcoded technology keywords
Args:
content: Markdown content
Returns:
List of extracted entities
List of extracted entities with deduplication
"""
entities = []
seen_entities = set() # Track unique entities (text, type) pairs
# Extract mentions in format: @username, #tag, [[WikiLink]]
# Person mentions: @username
def add_entity(text: str, entity_type: str, confidence: float):
"""Helper to add entity with deduplication."""
# Normalize text
text = text.strip()
if not text or len(text) < 2:
return
# Create unique key
key = (text.lower(), entity_type)
if key not in seen_entities:
seen_entities.add(key)
entities.append(EntityMention(
text=text,
type=entity_type,
confidence=confidence
))
# Strategy 1: Markdown links - explicit entity references
# Examples: [Docker](/technology/docker), [John Doe](/people/john-doe)
markdown_link_pattern = r'\[([^\]]+)\]\(([^\)]+)\)'
for match in re.finditer(markdown_link_pattern, content):
link_text = match.group(1)
link_path = match.group(2)
# Skip external links (http/https)
if link_path.startswith(('http://', 'https://')):
continue
# Infer entity type from path
entity_type = "Entity" # Default
if '/people/' in link_path or '/person/' in link_path:
entity_type = "Person"
elif '/companies/' in link_path or '/company/' in link_path or '/organizations/' in link_path:
entity_type = "Organization"
elif '/places/' in link_path or '/locations/' in link_path:
entity_type = "Place"
elif '/technology/' in link_path or '/tech/' in link_path:
entity_type = "Technology"
elif '/products/' in link_path or '/product/' in link_path:
entity_type = "Product"
elif '/projects/' in link_path or '/project/' in link_path:
entity_type = "Project"
elif '/events/' in link_path or '/event/' in link_path:
entity_type = "Event"
add_entity(link_text, entity_type, confidence=0.95)
# Strategy 2: Person mentions: @username
person_pattern = r'@([a-zA-Z0-9_-]+)'
for match in re.finditer(person_pattern, content):
entities.append(EntityMention(
text=match.group(1),
type="Person",
confidence=0.8
))
add_entity(match.group(1), "Person", confidence=0.8)
# Project/Concept mentions: [[WikiLink]]
# Strategy 3: WikiLink mentions: [[WikiLink]]
wikilink_pattern = r'\[\[([^\]]+)\]\]'
for match in re.finditer(wikilink_pattern, content):
entities.append(EntityMention(
text=match.group(1),
type="Concept",
confidence=0.9
))
add_entity(match.group(1), "Concept", confidence=0.9)
# Technology mentions: docker, kubernetes, python, etc.
# Strategy 4: Capitalized multi-word phrases (proper nouns)
# Matches phrases like "RSG Lingecollege", "John Cabot University", "Google Cloud Platform"
# Pattern: Word starting with capital, followed by 1-4 more capitalized words
proper_noun_pattern = r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,4})\b'
for match in re.finditer(proper_noun_pattern, content):
phrase = match.group(1)
# Filter out common false positives
# Skip if starts with common sentence starters
first_word = phrase.split()[0]
if first_word in {'The', 'This', 'That', 'These', 'Those', 'A', 'An',
'My', 'Your', 'His', 'Her', 'Our', 'Their',
'Some', 'Many', 'Few', 'Several', 'All', 'Most'}:
continue
# Skip if all words are common words (likely not a proper noun)
common_words = {'And', 'Or', 'But', 'For', 'With', 'From', 'About',
'After', 'Before', 'During', 'Until', 'Since'}
if all(word in common_words for word in phrase.split()):
continue
# Guess entity type based on context or use generic
add_entity(phrase, "Entity", confidence=0.6)
# Strategy 5: Technology keywords (fallback for common tech)
tech_keywords = ['docker', 'kubernetes', 'python', 'neo4j', 'qdrant',
'wikijs', 'fastapi', 'ollama', 'redis']
'wikijs', 'fastapi', 'ollama', 'redis', 'postgresql',
'react', 'nodejs', 'typescript', 'javascript']
content_lower = content.lower()
for tech in tech_keywords:
if tech in content_lower:
entities.append(EntityMention(
text=tech.capitalize(),
type="Technology",
confidence=0.7
))
add_entity(tech.capitalize(), "Technology", confidence=0.7)
logger.debug(f"Extracted {len(entities)} unique entities from content")
return entities
async def update_from_page(
@@ -360,6 +425,7 @@ class GraphService:
MERGE (d:{user_doc_label}:Document {{page_id: $page_id}})
SET d.title = $title,
d.path = $path,
d.tags = $tags,
d.updated_at = datetime(),
d.content_length = $content_length
RETURN d
@@ -369,6 +435,7 @@ class GraphService:
"page_id": page_id,
"title": page.get("title"),
"path": page.get("path"),
"tags": tags,
"content_length": len(content)
})