Spaces:
Sleeping
Sleeping
| """ | |
| Structured legal answer helpers using LangChain output parsers. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import textwrap | |
| from functools import lru_cache | |
| from typing import List, Optional, Sequence | |
| from langchain.output_parsers import PydanticOutputParser | |
| from langchain.schema import OutputParserException | |
| from pydantic import BaseModel, Field | |
| logger = logging.getLogger(__name__) | |
| class LegalCitation(BaseModel): | |
| """Single citation item pointing back to a legal document.""" | |
| document_title: str = Field(..., description="Tên văn bản pháp luật.") | |
| section_code: str = Field(..., description="Mã điều/khoản được trích dẫn.") | |
| page_range: Optional[str] = Field( | |
| None, description="Trang hoặc khoảng trang trong tài liệu." | |
| ) | |
| summary: str = Field( | |
| ..., | |
| 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.", | |
| ) | |
| snippet: str = Field( | |
| ..., description="Trích đoạn ngắn gọn (≤500 ký tự) lấy từ tài liệu gốc." | |
| ) | |
| class LegalAnswer(BaseModel): | |
| """Structured answer returned by the LLM.""" | |
| summary: str = Field( | |
| ..., | |
| 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).", | |
| ) | |
| details: List[str] = Field( | |
| ..., | |
| 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.", | |
| ) | |
| citations: List[LegalCitation] = Field( | |
| ..., | |
| 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.", | |
| ) | |
| def get_legal_output_parser() -> PydanticOutputParser: | |
| """Return cached parser to enforce structured output.""" | |
| return PydanticOutputParser(pydantic_object=LegalAnswer) | |
| def build_structured_legal_prompt( | |
| query: str, | |
| documents: Sequence, | |
| parser: PydanticOutputParser, | |
| prefill_summary: Optional[str] = None, | |
| retry_hint: Optional[str] = None, | |
| ) -> str: | |
| """Construct prompt instructing the LLM to return structured JSON.""" | |
| doc_blocks = [] | |
| for idx, doc in enumerate(documents[:5], 1): | |
| document = getattr(doc, "document", None) | |
| title = getattr(document, "title", "") or "Không rõ tên văn bản" | |
| code = getattr(document, "code", "") or "N/A" | |
| section_code = getattr(doc, "section_code", "") or "Không rõ điều" | |
| section_title = getattr(doc, "section_title", "") or "" | |
| page_range = _format_page_range(doc) | |
| content = getattr(doc, "content", "") or "" | |
| snippet = (content[:800] + "...") if len(content) > 800 else content | |
| block = textwrap.dedent( | |
| f""" | |
| TÀI LIỆU #{idx} | |
| Văn bản: {title} (Mã: {code}) | |
| Điều/khoản: {section_code} - {section_title} | |
| Trang: {page_range or 'Không rõ'} | |
| Trích đoạn: | |
| {snippet} | |
| """ | |
| ).strip() | |
| doc_blocks.append(block) | |
| docs_text = "\n\n".join(doc_blocks) | |
| reference_lines = [] | |
| title_section_pairs = [] | |
| for doc in documents[:5]: | |
| document = getattr(doc, "document", None) | |
| title = getattr(document, "title", "") or "Không rõ tên văn bản" | |
| section_code = getattr(doc, "section_code", "") or "Không rõ điều" | |
| reference_lines.append(f"- {title} | {section_code}") | |
| title_section_pairs.append((title, section_code)) | |
| reference_text = "\n".join(reference_lines) | |
| prefill_block = "" | |
| if prefill_summary: | |
| prefill_block = textwrap.dedent( | |
| f""" | |
| 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): | |
| {prefill_summary.strip()} | |
| """ | |
| ).strip() | |
| format_instructions = parser.get_format_instructions() | |
| retry_hint_block = "" | |
| if retry_hint: | |
| retry_hint_block = textwrap.dedent( | |
| f""" | |
| Nhắc lại: {retry_hint.strip()} | |
| """ | |
| ).strip() | |
| prompt = textwrap.dedent( | |
| f""" | |
| 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. | |
| Quy tắc bắt buộc: | |
| - Không được bịa đặt thông tin ngoài tài liệu. | |
| - 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. | |
| - Cấu trúc trả lời: SUMMARY ngắn gọn -> DETAILS dạng bullet -> CITATIONS chứa thông tin nguồn. | |
| - Nếu không đủ thông tin, ghi rõ lý do ở phần summary và để danh sách citations rỗng. | |
| - 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. | |
| - 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. | |
| - 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. | |
| - 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ể)”. | |
| - Ví dụ định dạng: | |
| {{ | |
| "summary": "Tóm tắt ...", | |
| "details": ["- Điều 5 ...", "- Điều 7 ..."], | |
| "citations": [ | |
| {{ | |
| "document_title": "Quyết định 69/QĐ-TW", | |
| "section_code": "Điều 5", | |
| "page_range": "1-2", | |
| "summary": "Mô tả ngắn gọn", | |
| "snippet": "Trích dẫn ≤500 ký tự" | |
| }} | |
| ] | |
| }} | |
| Câu hỏi người dùng: {query} | |
| Bảng tham chiếu bắt buộc (chỉ sử dụng đúng tên/mã dưới đây): | |
| {reference_text} | |
| Các trích đoạn pháp luật: | |
| {docs_text} | |
| {prefill_block} | |
| {retry_hint_block} | |
| {format_instructions} | |
| """ | |
| ).strip() | |
| return prompt | |
| def format_structured_legal_answer(answer: LegalAnswer) -> str: | |
| """Convert structured answer into human-friendly text with citations.""" | |
| lines: List[str] = [] | |
| if answer.summary: | |
| lines.append(answer.summary.strip()) | |
| if answer.details: | |
| lines.append("") | |
| lines.append("Chi tiết chính:") | |
| for bullet in answer.details: | |
| lines.append(f"- {bullet.strip()}") | |
| if answer.citations: | |
| lines.append("") | |
| lines.append("Trích dẫn chi tiết:") | |
| for idx, citation in enumerate(answer.citations, 1): | |
| page_text = f" (Trang: {citation.page_range})" if citation.page_range else "" | |
| lines.append( | |
| f"{idx}. {citation.document_title} – {citation.section_code}{page_text}" | |
| ) | |
| lines.append(f" Tóm tắt: {citation.summary.strip()}") | |
| lines.append(f" Trích đoạn: {citation.snippet.strip()}") | |
| return "\n".join(lines).strip() | |
| def _format_page_range(doc: object) -> Optional[str]: | |
| start = getattr(doc, "page_start", None) | |
| end = getattr(doc, "page_end", None) | |
| if start and end: | |
| if start == end: | |
| return str(start) | |
| return f"{start}-{end}" | |
| if start: | |
| return str(start) | |
| if end: | |
| return str(end) | |
| return None | |
| def parse_structured_output( | |
| parser: PydanticOutputParser, raw_output: str | |
| ) -> Optional[LegalAnswer]: | |
| """Parse raw LLM output to LegalAnswer if possible.""" | |
| if not raw_output: | |
| return None | |
| try: | |
| return parser.parse(raw_output) | |
| except OutputParserException: | |
| snippet = raw_output.strip().replace("\n", " ") | |
| logger.warning( | |
| "[LLM] Structured parse failed. Preview: %s", | |
| snippet[:400], | |
| ) | |
| json_candidate = _extract_json_block(raw_output) | |
| if json_candidate: | |
| try: | |
| return parser.parse(json_candidate) | |
| except OutputParserException: | |
| logger.warning("[LLM] JSON reparse also failed.") | |
| return None | |
| return None | |
| def _extract_json_block(text: str) -> Optional[str]: | |
| """ | |
| Best-effort extraction of the first JSON object within text. | |
| """ | |
| stripped = text.strip() | |
| if stripped.startswith("```"): | |
| stripped = stripped.lstrip("`") | |
| if stripped.lower().startswith("json"): | |
| stripped = stripped[4:] | |
| stripped = stripped.strip("`").strip() | |
| start = text.find("{") | |
| if start == -1: | |
| return None | |
| stack = 0 | |
| for idx in range(start, len(text)): | |
| char = text[idx] | |
| if char == "{": | |
| stack += 1 | |
| elif char == "}": | |
| stack -= 1 | |
| if stack == 0: | |
| payload = text[start : idx + 1] | |
| # Remove code fences if present | |
| payload = payload.strip() | |
| if payload.startswith("```"): | |
| payload = payload.strip("`").strip() | |
| try: | |
| json.loads(payload) | |
| return payload | |
| except json.JSONDecodeError: | |
| return None | |
| return None | |