Skip to main content

vwi_core/rules/
topic_comment.rs

1use 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
8/// Danh sách vị từ ngoại hướng / đòi hỏi chủ thể chi phối (Governing Transitive Verbs).
9/// Khi gắn trực tiếp sau khung giới từ mà không có dấu phẩy hoặc chủ ngữ thực sự,
10/// câu sẽ phạm lỗi "Khung đề què" (Crippled Prepositional Topic) theo GS. Cao Xuân Hạo.
11const 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
35/// Shallow Parser phân tích cấu trúc Đề - Thuyết (Topic-Comment Engine) dựa trên
36/// Ngữ pháp chức năng tiếng Việt của GS. Cao Xuân Hạo.
37///
38/// Phân tách câu thành Khung Đề (Prepositional/Circumstantial Frame) và Thuyết (Comment),
39/// kiểm tra tính toàn vẹn của vị từ nòng cốt mà không bị giới hạn số lượng token.
40pub 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    // 1. Phân đoạn danh sách token thành từng câu (dựa trên dấu chấm, hỏi, than hoặc xuống dòng)
48    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    // 2. Duyệt từng câu để phân tích cú pháp nông (Shallow Parsing)
87    for sent in sentences {
88        // Bỏ qua các dấu ngoặc kép hoặc ký tự mở đầu câu không mang ngữ nghĩa
89        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        // Kiểm tra xem câu có mở đầu bằng một Giới từ chỉ hoàn cảnh (Prepositional Frame Opener)
106        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        // Quét các token tiếp theo trong khung đề
119        for tok in &sent[lead_idx + 1..] {
120            let lower = tok.text.to_lowercase();
121
122            // A. Ranh giới dấu câu: Dấu phẩy hoặc chấm phẩy đóng Khung Đề hợp thức
123            if tok.text == "," || tok.text == ";" || tok.text == "—" {
124                break;
125            }
126
127            // B. Trợ từ phân cách Đề - Thuyết ("thì", "là", "mà")
128            // Theo GS. Cao Xuân Hạo, "thì" là hư từ đặc thù đánh dấu ranh giới Đề - Thuyết
129            if lower == "thì" || lower == "mà" {
130                break;
131            }
132
133            // C. Phát hiện xung đột giới từ - liên từ nghịch đối (PREPOSITION_CONJUNCTION_CONFLICT)
134            // Cụm mở đầu bằng "Với / Qua / Bằng" nhưng lại kết nối với "nhưng / song"
135            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            // D. Phát hiện vị từ ngoại hướng bị ép nhận Khung đề làm chủ thể (CRIPPLED_PREPOSITIONAL_TOPIC)
162            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            // Cập nhật trạng thái phó từ thời thể ("đã", "đang", "sẽ")
193            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
205/// Đánh giá lỗi Khung đề què (Crippled Prepositional Topic): Giới ngữ bị ép làm chủ thể cho vị từ.
206pub 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
215/// Đánh giá lỗi Xung đột Cú pháp (Preposition Conjunction Conflict): Giới từ mở đầu kết hợp với 'nhưng'.
216pub 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        // Cao Xuân Hạo: Vị từ tồn tại "có" đi với khung đề chỉ nơi chốn là hoàn toàn chuẩn mực
277        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}