davidtran999 commited on
Commit
f1cd3b5
·
verified ·
1 Parent(s): 2465c76

Upload backend/chatbot/structured_legal.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. backend/chatbot/structured_legal.py +267 -0
backend/chatbot/structured_legal.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Structured legal answer helpers using LangChain output parsers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import textwrap
10
+ from functools import lru_cache
11
+ from typing import List, Optional, Sequence
12
+
13
+ from langchain.output_parsers import PydanticOutputParser
14
+ from langchain.schema import OutputParserException
15
+ from pydantic import BaseModel, Field
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class LegalCitation(BaseModel):
21
+ """Single citation item pointing back to a legal document."""
22
+
23
+ document_title: str = Field(..., description="Tên văn bản pháp luật.")
24
+ section_code: str = Field(..., description="Mã điều/khoản được trích dẫn.")
25
+ page_range: Optional[str] = Field(
26
+ None, description="Trang hoặc khoảng trang trong tài liệu."
27
+ )
28
+ summary: str = Field(
29
+ ...,
30
+ description="1-2 câu mô tả nội dung chính của trích dẫn, phải liên quan trực tiếp câu hỏi.",
31
+ )
32
+ snippet: str = Field(
33
+ ..., description="Trích đoạn ngắn gọn (≤500 ký tự) lấy từ tài liệu gốc."
34
+ )
35
+
36
+
37
+ class LegalAnswer(BaseModel):
38
+ """Structured answer returned by the LLM."""
39
+
40
+ summary: str = Field(
41
+ ...,
42
+ description="Đoạn mở đầu tóm tắt kết luận chính, phải nhắc văn bản áp dụng (ví dụ Quyết định 69/QĐ-TW).",
43
+ )
44
+ details: List[str] = Field(
45
+ ...,
46
+ description="Tối thiểu 2 gạch đầu dòng mô tả từng hình thức/điều khoản. Mỗi gạch đầu dòng phải nhắc mã điều hoặc tên văn bản.",
47
+ )
48
+ citations: List[LegalCitation] = Field(
49
+ ...,
50
+ description="Danh sách trích dẫn; phải có ít nhất 1 phần tử tương ứng với các tài liệu đã cung cấp.",
51
+ )
52
+
53
+
54
+ @lru_cache(maxsize=1)
55
+ def get_legal_output_parser() -> PydanticOutputParser:
56
+ """Return cached parser to enforce structured output."""
57
+
58
+ return PydanticOutputParser(pydantic_object=LegalAnswer)
59
+
60
+
61
+ def build_structured_legal_prompt(
62
+ query: str,
63
+ documents: Sequence,
64
+ parser: PydanticOutputParser,
65
+ prefill_summary: Optional[str] = None,
66
+ retry_hint: Optional[str] = None,
67
+ ) -> str:
68
+ """Construct prompt instructing the LLM to return structured JSON."""
69
+
70
+ doc_blocks = []
71
+ for idx, doc in enumerate(documents[:5], 1):
72
+ document = getattr(doc, "document", None)
73
+ title = getattr(document, "title", "") or "Không rõ tên văn bản"
74
+ code = getattr(document, "code", "") or "N/A"
75
+ section_code = getattr(doc, "section_code", "") or "Không rõ điều"
76
+ section_title = getattr(doc, "section_title", "") or ""
77
+ page_range = _format_page_range(doc)
78
+ content = getattr(doc, "content", "") or ""
79
+ snippet = (content[:800] + "...") if len(content) > 800 else content
80
+
81
+ block = textwrap.dedent(
82
+ f"""
83
+ TÀI LIỆU #{idx}
84
+ Văn bản: {title} (Mã: {code})
85
+ Điều/khoản: {section_code} - {section_title}
86
+ Trang: {page_range or 'Không rõ'}
87
+ Trích đoạn:
88
+ {snippet}
89
+ """
90
+ ).strip()
91
+ doc_blocks.append(block)
92
+
93
+ docs_text = "\n\n".join(doc_blocks)
94
+ reference_lines = []
95
+ title_section_pairs = []
96
+ for doc in documents[:5]:
97
+ document = getattr(doc, "document", None)
98
+ title = getattr(document, "title", "") or "Không rõ tên văn bản"
99
+ section_code = getattr(doc, "section_code", "") or "Không rõ điều"
100
+ reference_lines.append(f"- {title} | {section_code}")
101
+ title_section_pairs.append((title, section_code))
102
+ reference_text = "\n".join(reference_lines)
103
+ prefill_block = ""
104
+ if prefill_summary:
105
+ prefill_block = textwrap.dedent(
106
+ f"""
107
+ Bản tóm tắt tiếng Việt đã có sẵn (hãy dùng lại, diễn đạt ngắn gọn hơn, KHÔNG thêm thông tin mới):
108
+ {prefill_summary.strip()}
109
+ """
110
+ ).strip()
111
+ format_instructions = parser.get_format_instructions()
112
+ retry_hint_block = ""
113
+ if retry_hint:
114
+ retry_hint_block = textwrap.dedent(
115
+ f"""
116
+ Nhắc lại: {retry_hint.strip()}
117
+ """
118
+ ).strip()
119
+
120
+ prompt = textwrap.dedent(
121
+ f"""
122
+ Bạn là trợ lý pháp lý của Công an thành phố Huế. Nhiệm vụ: dựa trên các trích đoạn dưới đây để trả lời câu hỏi của người dân.
123
+
124
+ Quy tắc bắt buộc:
125
+ - Không được bịa đặt thông tin ngoài tài liệu.
126
+ - Phải nhắc rõ văn bản (ví dụ: Quyết định 69/QĐ-TW) và mã điều/khoản trong phần trả lời.
127
+ - Cấu trúc trả lời: SUMMARY ngắn gọn -> DETAILS dạng bullet -> CITATIONS chứa thông tin nguồn.
128
+ - Nếu không đủ thông tin, ghi rõ lý do ở phần summary và để danh sách citations rỗng.
129
+ - Tuyệt đối không chép lại schema hay thêm khóa "$defs"; chỉ xuất đối tượng JSON cuối cùng theo mẫu dưới đây.
130
+ - Chỉ in ra CHÍNH XÁC một JSON object, không được thêm chữ 'json', không dùng ``` hoặc văn bản thừa trước/sau.
131
+ - Mỗi bullet DETAILS bắt buộc phải chứa tên văn bản và mã điều/khoản đúng như trong “Bảng tham chiếu” phía dưới.
132
+ - Không được tạo thêm hình thức kỷ luật hoặc điều khoản không xuất hiện trong tài liệu. Nếu không thấy điều/khoản, ghi rõ “(không nêu điều cụ thể)”.
133
+ - Ví dụ định dạng:
134
+ {{
135
+ "summary": "Tóm tắt ...",
136
+ "details": ["- Điều 5 ...", "- Điều 7 ..."],
137
+ "citations": [
138
+ {{
139
+ "document_title": "Quyết định 69/QĐ-TW",
140
+ "section_code": "Điều 5",
141
+ "page_range": "1-2",
142
+ "summary": "Mô tả ngắn gọn",
143
+ "snippet": "Trích dẫn ≤500 ký tự"
144
+ }}
145
+ ]
146
+ }}
147
+
148
+ Câu hỏi người dùng: {query}
149
+
150
+ Bảng tham chiếu bắt buộc (chỉ sử dụng đúng tên/mã dưới đây):
151
+ {reference_text}
152
+
153
+ Các trích đoạn pháp luật:
154
+ {docs_text}
155
+
156
+ {prefill_block}
157
+
158
+ {retry_hint_block}
159
+
160
+ {format_instructions}
161
+ """
162
+ ).strip()
163
+
164
+ return prompt
165
+
166
+
167
+ def format_structured_legal_answer(answer: LegalAnswer) -> str:
168
+ """Convert structured answer into human-friendly text with citations."""
169
+
170
+ lines: List[str] = []
171
+ if answer.summary:
172
+ lines.append(answer.summary.strip())
173
+
174
+ if answer.details:
175
+ lines.append("")
176
+ lines.append("Chi tiết chính:")
177
+ for bullet in answer.details:
178
+ lines.append(f"- {bullet.strip()}")
179
+
180
+ if answer.citations:
181
+ lines.append("")
182
+ lines.append("Trích dẫn chi tiết:")
183
+ for idx, citation in enumerate(answer.citations, 1):
184
+ page_text = f" (Trang: {citation.page_range})" if citation.page_range else ""
185
+ lines.append(
186
+ f"{idx}. {citation.document_title} – {citation.section_code}{page_text}"
187
+ )
188
+ lines.append(f" Tóm tắt: {citation.summary.strip()}")
189
+ lines.append(f" Trích đoạn: {citation.snippet.strip()}")
190
+
191
+ return "\n".join(lines).strip()
192
+
193
+
194
+ def _format_page_range(doc: object) -> Optional[str]:
195
+ start = getattr(doc, "page_start", None)
196
+ end = getattr(doc, "page_end", None)
197
+ if start and end:
198
+ if start == end:
199
+ return str(start)
200
+ return f"{start}-{end}"
201
+ if start:
202
+ return str(start)
203
+ if end:
204
+ return str(end)
205
+ return None
206
+
207
+
208
+ def parse_structured_output(
209
+ parser: PydanticOutputParser, raw_output: str
210
+ ) -> Optional[LegalAnswer]:
211
+ """Parse raw LLM output to LegalAnswer if possible."""
212
+
213
+ if not raw_output:
214
+ return None
215
+ try:
216
+ return parser.parse(raw_output)
217
+ except OutputParserException:
218
+ snippet = raw_output.strip().replace("\n", " ")
219
+ logger.warning(
220
+ "[LLM] Structured parse failed. Preview: %s",
221
+ snippet[:400],
222
+ )
223
+ json_candidate = _extract_json_block(raw_output)
224
+ if json_candidate:
225
+ try:
226
+ return parser.parse(json_candidate)
227
+ except OutputParserException:
228
+ logger.warning("[LLM] JSON reparse also failed.")
229
+ return None
230
+ return None
231
+
232
+
233
+ def _extract_json_block(text: str) -> Optional[str]:
234
+ """
235
+ Best-effort extraction of the first JSON object within text.
236
+ """
237
+ stripped = text.strip()
238
+ if stripped.startswith("```"):
239
+ stripped = stripped.lstrip("`")
240
+ if stripped.lower().startswith("json"):
241
+ stripped = stripped[4:]
242
+ stripped = stripped.strip("`").strip()
243
+
244
+ start = text.find("{")
245
+ if start == -1:
246
+ return None
247
+
248
+ stack = 0
249
+ for idx in range(start, len(text)):
250
+ char = text[idx]
251
+ if char == "{":
252
+ stack += 1
253
+ elif char == "}":
254
+ stack -= 1
255
+ if stack == 0:
256
+ payload = text[start : idx + 1]
257
+ # Remove code fences if present
258
+ payload = payload.strip()
259
+ if payload.startswith("```"):
260
+ payload = payload.strip("`").strip()
261
+ try:
262
+ json.loads(payload)
263
+ return payload
264
+ except json.JSONDecodeError:
265
+ return None
266
+ return None
267
+