Files
goodgo-platform/libs/ai-services/app/services/nlp_service.py
Ho Ngoc Hai ee3ae2e81d feat(ai-services): add Vietnamese NLP pipeline for property description analysis
Implement auto-tagging (amenities, location features, condition/legal),
content quality scoring with moderation integration, and FastAPI endpoints
for single and batch text analysis. Uses underthesea for Vietnamese
tokenization/POS when available, with regex fallback.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 22:42:31 +07:00

236 lines
9.3 KiB
Python

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()