vwi_core/rules/
topic_comment.rs1use crate::segmentation::tokenize_viterbi;
2use crate::types::{Finding, PosTag, TokenSpan};
3use crate::unicode_utils::normalize_nfc;
4
5pub const CRIPPLED_TOPIC_RULE_ID: &str = "CRIPPLED_PREPOSITIONAL_TOPIC";
6pub const PREP_CONJ_RULE_ID: &str = "PREPOSITION_CONJUNCTION_CONFLICT";
7
8const GOVERNING_TRANSITIVE_VERBS: &[&str] = &[
12 "đòi hỏi",
13 "yêu cầu",
14 "cho thấy",
15 "chứng minh",
16 "khẳng định",
17 "chứng tỏ",
18 "buộc",
19 "khiến",
20 "giúp",
21 "làm cho",
22 "phát hiện",
23 "chỉ ra",
24 "nêu rõ",
25 "nhấn mạnh",
26 "minh chứng",
27 "đã chứng minh",
28 "đã giúp",
29 "đã buộc",
30 "đã phát hiện",
31 "đã chỉ ra",
32 "đã đòi hỏi",
33];
34
35pub fn parse_topic_comment(tokens: &[TokenSpan], norm_text: &str) -> Vec<Finding> {
41 if tokens.is_empty() {
42 return Vec::new();
43 }
44
45 let mut findings = Vec::new();
46
47 let mut sentences: Vec<&[TokenSpan]> = Vec::new();
49 let mut sent_start = 0;
50
51 for (i, tok) in tokens.iter().enumerate() {
52 let is_punct_term = tok.pos == Some(PosTag::Punct)
53 && (tok.text == "."
54 || tok.text == "?"
55 || tok.text == "!"
56 || tok.text == "..."
57 || tok.text == "…");
58
59 let is_newline_boundary = if i + 1 < tokens.len() {
60 let next_tok = &tokens[i + 1];
61 if next_tok.start > tok.end {
62 norm_text
63 .chars()
64 .skip(tok.end)
65 .take(next_tok.start - tok.end)
66 .any(|c| c == '\n' || c == '\r')
67 } else {
68 false
69 }
70 } else {
71 false
72 };
73
74 if is_punct_term || is_newline_boundary {
75 if i >= sent_start {
76 sentences.push(&tokens[sent_start..=i]);
77 }
78 sent_start = i + 1;
79 }
80 }
81
82 if sent_start < tokens.len() {
83 sentences.push(&tokens[sent_start..]);
84 }
85
86 for sent in sentences {
88 let lead_idx = sent.iter().position(|t| {
90 t.pos != Some(PosTag::Punct)
91 && !t
92 .text
93 .chars()
94 .all(|c| c.is_ascii_punctuation() || "“„”«»'\"()[]".contains(c))
95 });
96
97 let lead_idx = match lead_idx {
98 Some(idx) => idx,
99 None => continue,
100 };
101
102 let first_token = &sent[lead_idx];
103 let first_lower = first_token.text.to_lowercase();
104
105 let is_prep_opener = matches!(
107 first_lower.as_str(),
108 "trong" | "qua" | "bằng" | "với" | "với những" | "dưới" | "theo" | "ở" | "tại" | "từ"
109 );
110
111 if !is_prep_opener {
112 continue;
113 }
114
115 let mut inter_count = 0;
116 let mut prev_is_aspect = false;
117
118 for tok in &sent[lead_idx + 1..] {
120 let lower = tok.text.to_lowercase();
121
122 if tok.text == "," || tok.text == ";" || tok.text == "—" {
124 break;
125 }
126
127 if lower == "thì" || lower == "mà" {
130 break;
131 }
132
133 if (lower == "nhưng" || lower == "song") && inter_count >= 1 {
136 let start_char = first_token.start;
137 let end_char = tok.end;
138 let matched_span: String = norm_text
139 .chars()
140 .skip(start_char)
141 .take(end_char - start_char)
142 .collect();
143
144 findings.push(
145 Finding::new(
146 PREP_CONJ_RULE_ID,
147 "topic_comment",
148 "major",
149 start_char,
150 end_char,
151 0.95,
152 "Xung đột cú pháp: Giới từ mở đầu trạng ngữ không thể kết hợp trực tiếp với liên từ phản đề 'nhưng'.",
153 "linguistic",
154 matched_span,
155 )
156 .with_suggestion("Tách thành hai câu độc lập hoặc dùng cặp liên từ tương ứng 'Mặc dù... nhưng...' thay vì dùng giới từ 'Với/Bằng'"),
157 );
158 break;
159 }
160
161 let is_gov = GOVERNING_TRANSITIVE_VERBS
163 .iter()
164 .any(|&verb| lower == verb);
165
166 if (is_gov || (prev_is_aspect && is_gov)) && inter_count >= 1 {
167 let start_char = first_token.start;
168 let end_char = tok.end;
169 let matched_span: String = norm_text
170 .chars()
171 .skip(start_char)
172 .take(end_char - start_char)
173 .collect();
174
175 findings.push(
176 Finding::new(
177 CRIPPLED_TOPIC_RULE_ID,
178 "topic_comment",
179 "major",
180 start_char,
181 end_char,
182 0.95,
183 "Lẫn lộn Khung đề và Chủ đề: Giới từ biến cụm từ thành trạng ngữ/phương tiện, không thể làm chủ thể cho vị từ.",
184 "linguistic",
185 matched_span,
186 )
187 .with_suggestion("Bỏ giới từ mở đầu để biến Khung đề thành Chủ ngữ tự nhiên, hoặc bổ sung chủ ngữ sau dấu phẩy"),
188 );
189 break;
190 }
191
192 if lower == "đã" || lower == "đang" || lower == "sẽ" {
194 prev_is_aspect = true;
195 } else {
196 prev_is_aspect = false;
197 inter_count += 1;
198 }
199 }
200 }
201
202 findings
203}
204
205pub fn evaluate_crippled_prepositional_topic(text: &str) -> Vec<Finding> {
207 let norm = normalize_nfc(text);
208 let tokens = tokenize_viterbi(&norm);
209 parse_topic_comment(&tokens, &norm)
210 .into_iter()
211 .filter(|f| f.pattern_id == CRIPPLED_TOPIC_RULE_ID)
212 .collect()
213}
214
215pub fn evaluate_preposition_conjunction_conflict(text: &str) -> Vec<Finding> {
217 let norm = normalize_nfc(text);
218 let tokens = tokenize_viterbi(&norm);
219 parse_topic_comment(&tokens, &norm)
220 .into_iter()
221 .filter(|f| f.pattern_id == PREP_CONJ_RULE_ID)
222 .collect()
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_crippled_topic_detection() {
231 let text = "Trong tình hình kinh tế hiện nay đòi hỏi doanh nghiệp phải tái cơ cấu toàn diện.";
232 let findings = evaluate_crippled_prepositional_topic(text);
233 assert_eq!(findings.len(), 1);
234 assert_eq!(findings[0].pattern_id, CRIPPLED_TOPIC_RULE_ID);
235 assert_eq!(
236 findings[0].matched_span,
237 "Trong tình hình kinh tế hiện nay đòi hỏi"
238 );
239 }
240
241 #[test]
242 fn test_prep_conj_conflict_detection() {
243 let text = "Với những nỗ lực phi thường nhưng họ vẫn không thể hoàn thành dự án đúng hạn.";
244 let findings = evaluate_preposition_conjunction_conflict(text);
245 assert_eq!(findings.len(), 1);
246 assert_eq!(findings[0].pattern_id, PREP_CONJ_RULE_ID);
247 assert_eq!(
248 findings[0].matched_span,
249 "Với những nỗ lực phi thường nhưng"
250 );
251 }
252
253 #[test]
254 fn test_clean_topic_sentence() {
255 let text = "Tình hình kinh tế hiện nay đòi hỏi doanh nghiệp phải tái cơ cấu.";
256 let findings = evaluate_crippled_prepositional_topic(text);
257 assert!(findings.is_empty());
258 }
259
260 #[test]
261 fn test_clean_with_comma_boundary() {
262 let text = "Trong tình hình kinh tế hiện nay, các doanh nghiệp đang nỗ lực tái cơ cấu.";
263 let findings = evaluate_crippled_prepositional_topic(text);
264 assert!(findings.is_empty());
265 }
266
267 #[test]
268 fn test_clean_with_topic_marker_thi() {
269 let text = "Trong hoàn cảnh khó khăn này thì mọi người cần đoàn kết gắn bó.";
270 let findings = evaluate_crippled_prepositional_topic(text);
271 assert!(findings.is_empty());
272 }
273
274 #[test]
275 fn test_clean_existential_sentence() {
276 let text = "Trong nhà có năm người đang ngồi thảo luận kế hoạch.";
278 let findings = evaluate_crippled_prepositional_topic(text);
279 assert!(findings.is_empty());
280 }
281
282 #[test]
283 fn test_multi_sentence_detection() {
284 let text = "Hôm nay thời tiết rất đẹp. Qua phân tích chuỗi cung ứng linh kiện khiến các nhà máy phải điều chỉnh ca làm.";
285 let findings = evaluate_crippled_prepositional_topic(text);
286 assert_eq!(findings.len(), 1);
287 assert_eq!(
288 findings[0].matched_span,
289 "Qua phân tích chuỗi cung ứng linh kiện khiến"
290 );
291 }
292
293 #[test]
294 fn test_aspect_prefixed_verb() {
295 let text = "Bằng những số liệu thực nghiệm đã chứng minh giả thuyết của nhóm là chính xác.";
296 let findings = evaluate_crippled_prepositional_topic(text);
297 assert_eq!(findings.len(), 1);
298 assert_eq!(
299 findings[0].matched_span,
300 "Bằng những số liệu thực nghiệm đã chứng minh"
301 );
302 }
303}