import logging import re from app.models.nlp import ( NLPAnalyzeRequest, NLPAnalyzeResponse, PropertyTag, QualityScore, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Tag dictionaries — Vietnamese real-estate domain # --------------------------------------------------------------------------- AMENITY_TAGS: dict[str, list[str]] = { "hồ bơi": ["hồ bơi", "bể bơi", "pool"], "phòng gym": ["phòng gym", "gym", "phòng tập"], "sân vườn": ["sân vườn", "vườn", "garden"], "gara ô tô": ["gara", "garage", "nhà xe", "chỗ đậu xe"], "thang máy": ["thang máy", "elevator"], "ban công": ["ban công", "balcony", "lô gia", "logia"], "sân thượng": ["sân thượng", "rooftop"], "bảo vệ 24/7": ["bảo vệ 24", "an ninh 24", "security 24"], "khu vui chơi": ["khu vui chơi", "playground", "sân chơi trẻ em"], "hầm để xe": ["hầm để xe", "hầm xe", "tầng hầm"], "điều hòa": ["điều hòa", "máy lạnh"], "nội thất cao cấp": ["nội thất cao cấp", "full nội thất", "nội thất đầy đủ"], "camera an ninh": ["camera", "camera an ninh"], "sân tennis": ["sân tennis", "tennis"], "công viên nội khu": ["công viên nội khu", "công viên", "park"], } LOCATION_TAGS: dict[str, list[str]] = { "gần trường học": ["gần trường", "cạnh trường", "kế trường"], "gần bệnh viện": ["gần bệnh viện", "cạnh bệnh viện", "kế bệnh viện"], "gần chợ": ["gần chợ", "cạnh chợ", "kế chợ"], "gần siêu thị": ["gần siêu thị", "cạnh siêu thị", "siêu thị"], "mặt tiền đường": ["mặt tiền", "mặt đường", "mặt phố"], "gần metro": ["gần metro", "gần tàu điện", "cạnh metro"], "gần sân bay": ["gần sân bay", "cạnh sân bay"], "trung tâm thành phố": ["trung tâm", "trung tâm thành phố", "trung tâm tp"], "ven sông": ["ven sông", "view sông", "mặt sông"], "gần biển": ["gần biển", "view biển", "mặt biển", "sát biển"], "gần công viên": ["gần công viên", "cạnh công viên"], "khu dân cư": ["khu dân cư", "kdc"], } CONDITION_TAGS: dict[str, list[str]] = { "mới xây": ["mới xây", "xây mới", "mới hoàn thiện", "vừa xây xong"], "đã qua sử dụng": ["đã qua sử dụng", "đã sử dụng"], "cần sửa chữa": ["cần sửa", "cần cải tạo", "cần nâng cấp", "sửa chữa"], "đang xây dựng": ["đang xây", "đang thi công", "sắp bàn giao"], "sổ đỏ": ["sổ đỏ"], "sổ hồng": ["sổ hồng"], "chính chủ": ["chính chủ"], "pháp lý rõ ràng": ["pháp lý rõ", "pháp lý đầy đủ", "pháp lý sạch"], "hoàn thiện cơ bản": ["hoàn thiện cơ bản", "bàn giao thô"], "đầy đủ nội thất": ["đầy đủ nội thất", "full nội thất", "nội thất đầy đủ"], } # Categories for completeness scoring — which info fields are expected _COMPLETENESS_FIELDS = [ r"\d+(?:[.,]\d+)?\s*(?:m2|m²|mét vuông)", # area r"\d+\s*(?:phòng ngủ|pn|PN)", # bedrooms r"\d+\s*(?:tầng|lầu)", # floors r"(?:sổ đỏ|sổ hồng|chính chủ|pháp lý)", # legal r"\d+(?:[.,]\d+)?\s*(?:tỷ|tỉ|triệu)", # price r"(?:quận|huyện|phường|xã|đường|tp\.|thành phố)", # location r"(?:căn hộ|chung cư|nhà phố|biệt thự|đất|shophouse|nhà riêng)", # property type ] class NLPService: """Vietnamese NLP pipeline for property description analysis.""" def _match_tags( self, text_lower: str, tag_dict: dict[str, list[str]], category: str, ) -> list[PropertyTag]: tags: list[PropertyTag] = [] seen: set[str] = set() for tag_name, keywords in tag_dict.items(): for kw in keywords: idx = text_lower.find(kw) if idx != -1 and tag_name not in seen: seen.add(tag_name) # Extract actual matched text from original-case vicinity matched = text_lower[idx : idx + len(kw)] tags.append( PropertyTag( category=category, tag=tag_name, matched_text=matched, confidence=0.9 if len(kw) > 3 else 0.75, ) ) return tags def _compute_completeness(self, text: str) -> float: matched = sum( 1 for pattern in _COMPLETENESS_FIELDS if re.search(pattern, text, re.IGNORECASE) ) return round(matched / len(_COMPLETENESS_FIELDS), 3) def _compute_readability(self, text: str) -> float: sentences = [s.strip() for s in re.split(r"[.!?;\n]+", text) if s.strip()] if not sentences: return 0.0 avg_sentence_len = sum(len(s.split()) for s in sentences) / len(sentences) # Penalize very short or very long sentences if avg_sentence_len < 3: score = 0.4 elif avg_sentence_len > 40: score = 0.5 else: # Sweet spot: 8-20 words per sentence score = 1.0 - abs(avg_sentence_len - 14) / 30 score = max(0.3, min(1.0, score)) # Penalize excessive caps or repeated punctuation caps_ratio = sum(1 for c in text if c.isupper()) / max(len(text), 1) if caps_ratio > 0.3: score *= 0.7 return round(score, 3) def _compute_info_density(self, text: str, tag_count: int) -> float: word_count = len(text.split()) if word_count == 0: return 0.0 # More tags per word = higher density, capped at 1.0 density = min(1.0, (tag_count * 5) / word_count) return round(density, 3) def _tokenize(self, text: str) -> list[str]: try: from underthesea import word_tokenize return word_tokenize(text) except ImportError: logger.warning("underthesea not available — falling back to whitespace split") return text.split() def _sentence_split(self, text: str) -> list[str]: try: from underthesea import sent_tokenize return sent_tokenize(text) except ImportError: return [s.strip() for s in re.split(r"[.!?\n]+", text) if s.strip()] def _extract_noun_phrases(self, tokens: list[str], text: str) -> list[str]: """Extract key noun phrases using POS tagging when available.""" try: from underthesea import pos_tag tagged = pos_tag(text) phrases: list[str] = [] current_phrase: list[str] = [] for word, pos in tagged: if pos in ("N", "Np", "Nc", "Nu", "A"): current_phrase.append(word) else: if len(current_phrase) >= 2: phrases.append(" ".join(current_phrase)) current_phrase = [] if len(current_phrase) >= 2: phrases.append(" ".join(current_phrase)) return phrases[:20] # Cap at 20 phrases except ImportError: return [] def analyze(self, req: NLPAnalyzeRequest) -> NLPAnalyzeResponse: text = req.text text_lower = text.lower() # Auto-tag amenity_tags = self._match_tags(text_lower, AMENITY_TAGS, "amenity") location_tags = self._match_tags(text_lower, LOCATION_TAGS, "location") condition_tags = self._match_tags(text_lower, CONDITION_TAGS, "condition") all_tags = amenity_tags + location_tags + condition_tags # Tokenization tokens = self._tokenize(text) sentences = self._sentence_split(text) keyword_phrases = self._extract_noun_phrases(tokens, text) # Quality scoring completeness = self._compute_completeness(text) readability = self._compute_readability(text) info_density = self._compute_info_density(text, len(all_tags)) # Moderation integration moderation_score: float | None = None if req.include_moderation: from app.services.moderation_service import moderation_service from app.models.moderation import ModerationRequest mod_result = moderation_service.check(ModerationRequest(text=text, context="listing")) moderation_score = mod_result.score # Overall quality: weighted combination mod_penalty = (1 - moderation_score) if moderation_score is not None else 1.0 overall = round( (completeness * 0.4 + readability * 0.3 + info_density * 0.3) * mod_penalty, 3, ) quality = QualityScore( overall=overall, completeness=completeness, readability=readability, information_density=info_density, moderation_score=moderation_score, ) return NLPAnalyzeResponse( tags=all_tags, quality=quality, tokens=tokens, sentences=sentences, keyword_phrases=keyword_phrases, ) nlp_service = NLPService()