File size: 12,624 Bytes
b4f2238 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | """开庭时间提取 Mixin"""
import logging
from datetime import datetime
from typing import Any, cast
logger = logging.getLogger("apps.document_recognition")
# 开庭时间正则表达式模式
DATETIME_PATTERNS = [
(r"(\d{4})年(\d{1,2})月(\d{1,2})日\s+(上午|下午)\s*(\d{1,2})[时点](\d{1,2})分?", True),
(r"(\d{4})年(\d{1,2})月(\d{1,2})日(上午|下午)(\d{1,2})[时点](\d{1,2})分?", True),
(r"(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2})[时点](\d{1,2})分?", False),
(r"(\d{4})年(\d{1,2})月(\d{1,2})日(\d{1,2})[时点](\d{1,2})分?", False),
(r"(\d{4})年(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{1,2})", False),
(r"(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2})", False),
(r"(\d{4})/(\d{1,2})/(\d{1,2})\s+(\d{1,2}):(\d{1,2})", False),
]
HEARING_HIGH_WEIGHT_KEYWORDS = ["开庭", "庭审", "定于", "传唤"]
HEARING_MEDIUM_WEIGHT_KEYWORDS = [
"审理",
"出庭",
"到庭",
"准时到",
"按时到",
"应诉",
"参加诉讼",
"审判庭",
"法庭",
"第一审判庭",
"第二审判庭",
"第三审判庭",
"应到时间",
"到庭时间",
"开庭时间",
]
HEARING_LOW_WEIGHT_KEYWORDS = ["本院", "通知", "届时", "如期", "依法"]
HEARING_CONTEXT_KEYWORDS = HEARING_HIGH_WEIGHT_KEYWORDS + HEARING_MEDIUM_WEIGHT_KEYWORDS + HEARING_LOW_WEIGHT_KEYWORDS
class DatetimeExtractionMixin:
"""开庭时间提取 Mixin"""
import re as _re
def _parse_datetime_groups(self, groups: tuple[Any, ...], has_am_pm: bool, matched_text: str) -> datetime | None:
"""解析正则分组为 datetime"""
if has_am_pm and len(groups) == 6:
year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
am_pm, hour, minute = groups[3], int(groups[4]), int(groups[5])
if am_pm not in ("上午", "下午"):
logger.debug(f"无效的上午/下午标识: {am_pm}, 跳过匹配: {matched_text}")
return None
if am_pm == "下午" and hour < 12:
hour += 12
elif am_pm == "上午" and hour == 12:
hour = 0
elif len(groups) == 5:
year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
hour, minute = int(groups[3]), int(groups[4])
else:
logger.debug(f"未知的分组数量: {len(groups)}, 跳过匹配: {matched_text}")
return None
if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23 and 0 <= minute <= 59):
logger.debug(f"日期时间无效: {matched_text}")
return None
if not (2020 <= year <= 2030):
logger.debug(f"年份不合理: {year}, 跳过匹配: {matched_text}")
return None
return datetime(year, month, day, hour, minute)
def _extract_datetime_by_regex(self, text: str) -> list[tuple[datetime, str, float]]:
"""使用正则表达式从文本中提取日期时间"""
import re
results: list[tuple[datetime, str, float]] = []
for pattern, has_am_pm in DATETIME_PATTERNS:
for match in re.finditer(pattern, text):
try:
matched_text = match.group(0)
dt = self._parse_datetime_groups(match.groups(), has_am_pm, matched_text)
if dt is None:
continue
context_score = self._calculate_context_score(text, match.start())
if not any(abs((dt - existing[0]).total_seconds()) < 60 for existing in results):
results.append((dt, matched_text, context_score))
logger.debug(f"正则提取到时间: {dt}, 原文: {matched_text}, 上下文得分: {context_score}")
except (ValueError, IndexError) as e:
logger.debug(f"解析日期时间失败: {match.group(0)}, 错误: {e}")
continue
return results
def _calculate_context_score(self, text: str, match_position: int) -> int:
"""计算匹配位置的上下文得分(0-100)"""
context_start = max(0, match_position - 100)
context_end = min(len(text), match_position + 50)
context = text[context_start:context_end]
score = 0
for keyword in HEARING_HIGH_WEIGHT_KEYWORDS:
if keyword in context:
score += 25
for keyword in HEARING_MEDIUM_WEIGHT_KEYWORDS:
if keyword in context:
score += 15
for keyword in HEARING_LOW_WEIGHT_KEYWORDS:
if keyword in context:
score += 8
return min(score, 100)
def _score_days_diff(self, days_diff: int, score: int, reasons: list[str]) -> tuple[int, list[str]]:
"""评估天数差对分数的影响"""
if days_diff < -7:
reasons.append(f"时间已过去{abs(days_diff)}天")
score -= 30
elif days_diff < 0:
reasons.append(f"时间已过去{abs(days_diff)}天")
score -= 10
elif days_diff > 365 * 2:
reasons.append(f"时间在{days_diff}天后,超过2年")
score -= 40
elif days_diff > 365:
reasons.append(f"时间在{days_diff}天后")
score -= 15
elif days_diff > 180:
reasons.append(f"时间在{days_diff}天后")
score -= 5
else:
reasons.append(f"时间在{days_diff}天后")
score += 20
return score, reasons
def _validate_hearing_datetime(self, dt: datetime) -> tuple[bool, int, str]:
"""校验开庭时间的合理性"""
now = datetime.now()
score = 50
reasons: list[str] = []
days_diff = (dt.date() - now.date()).days
score, reasons = self._score_days_diff(days_diff, score, reasons)
hour = dt.hour
if 8 <= hour <= 18:
score += 15
reasons.append("工作时间内")
elif 7 <= hour < 8 or 18 < hour <= 20:
score += 5
reasons.append("边缘工作时间")
else:
score -= 20
reasons.append(f"非工作时间({hour}点)")
if dt.weekday() >= 5:
score -= 10
reasons.append("周末")
else:
score += 5
reasons.append("工作日")
if dt.minute in {0, 30}:
score += 10
reasons.append("整点/半点")
elif dt.minute in {15, 45}:
score += 5
reasons.append("刻钟")
score = max(0, min(100, score))
return score > 30, score, "; ".join(reasons)
def _validate_regex_results(self, regex_results: list[tuple[datetime, str, int | float]]) -> list[dict[str, Any]]:
"""对正则结果进行合理性校验和综合评分"""
validated: list[dict[str, Any]] = []
for dt, matched_text, context_score in regex_results:
is_valid, validity_score, validity_reason = self._validate_hearing_datetime(dt)
combined_score = context_score * 0.6 + validity_score * 0.4
validated.append(
{
"datetime": dt,
"matched_text": matched_text,
"context_score": context_score,
"validity_score": validity_score,
"validity_reason": validity_reason,
"combined_score": combined_score,
"is_valid": is_valid,
}
)
logger.debug(
f"正则候选: {dt}, 上下文={context_score}, 合理性={validity_score}({validity_reason}), "
f"综合={combined_score:.1f}, 有效={is_valid}"
)
return validated
def _select_best_from_conflict(
self,
best_regex_dt: datetime,
best_regex_combined: float,
ollama_datetime: datetime,
ollama_validity_score: int,
valid_regex_results: list[dict[str, Any]],
) -> tuple[datetime | None, str]:
"""处理正则与 Ollama 结果冲突"""
date_diff = abs((best_regex_dt.date() - ollama_datetime.date()).days)
time_diff = abs((best_regex_dt - ollama_datetime).total_seconds())
if date_diff == 0 and time_diff < 3600:
logger.info(f"正则和Ollama结果一致: {best_regex_dt}")
return best_regex_dt, "regex+ollama(一致)"
if date_diff == 0:
logger.warning(f"时间冲突: 正则={best_regex_dt}, Ollama={ollama_datetime}, 时间差={time_diff}秒")
if best_regex_combined >= 40:
return best_regex_dt, f"regex(score={best_regex_combined:.0f},时间冲突)"
return ollama_datetime, f"ollama(validity={ollama_validity_score},时间冲突,正则得分低)"
logger.warning(f"日期冲突: 正则={best_regex_dt.date()}, Ollama={ollama_datetime.date()}, 差异={date_diff}天")
for result in valid_regex_results[1:]:
result_dt = cast(datetime, result["datetime"])
if abs((result_dt.date() - ollama_datetime.date()).days) == 0:
logger.info(f"找到与Ollama一致的备选正则结果: {result['datetime']}")
return result_dt, f"regex(score={result['combined_score']:.0f},与ollama一致)"
if best_regex_combined >= 60:
return best_regex_dt, f"regex(score={best_regex_combined:.0f},日期冲突)"
if ollama_validity_score >= 50:
return ollama_datetime, f"ollama(validity={ollama_validity_score},日期冲突)"
if best_regex_combined >= ollama_validity_score:
return best_regex_dt, f"regex(score={best_regex_combined:.0f},低置信度)"
return ollama_datetime, f"ollama(validity={ollama_validity_score},低置信度)"
def _select_best_datetime(
self, regex_results: list[tuple[datetime, str, int | float]], ollama_datetime: datetime | None
) -> tuple[datetime | None, str]:
"""从正则提取结果和 Ollama 结果中选择最佳时间"""
if not regex_results and not ollama_datetime:
return None, "无法提取"
validated_regex_results = self._validate_regex_results(regex_results)
valid_regex_results = sorted(
[r for r in validated_regex_results if r["is_valid"]],
key=lambda x: float(x["combined_score"]),
reverse=True,
)
ollama_valid = False
ollama_validity_score = 0
ollama_validity_reason = ""
if ollama_datetime:
ollama_valid, ollama_validity_score, ollama_validity_reason = self._validate_hearing_datetime(
ollama_datetime
)
logger.debug(
f"Ollama候选: {ollama_datetime},"
f" 合理性={ollama_validity_score}({ollama_validity_reason}), 有效={ollama_valid}"
)
if not valid_regex_results:
if ollama_datetime and ollama_valid:
logger.info(f"仅使用 Ollama 结果: {ollama_datetime} (合理性={ollama_validity_score})")
return ollama_datetime, f"ollama(validity={ollama_validity_score})"
if ollama_datetime:
logger.warning(f"Ollama 结果合理性较低: {ollama_datetime} ({ollama_validity_reason})")
return ollama_datetime, f"ollama(低合理性:{ollama_validity_reason})"
if validated_regex_results:
best_invalid = max(validated_regex_results, key=lambda x: float(x["combined_score"]))
logger.warning(
f"所有正则结果合理性较低,使用最佳候选:"
f" {best_invalid['datetime']} ({best_invalid['validity_reason']})"
)
return (
cast(datetime, best_invalid["datetime"]),
f"regex(低合理性:{best_invalid['validity_reason']})",
)
return None, "无法提取"
best_regex = valid_regex_results[0]
best_regex_dt = cast(datetime, best_regex["datetime"])
best_regex_combined = float(best_regex["combined_score"])
if not ollama_datetime or not ollama_valid:
logger.info(f"使用正则结果: {best_regex_dt}, 综合得分={best_regex_combined:.1f}")
return best_regex_dt, f"regex(score={best_regex_combined:.0f})"
return self._select_best_from_conflict(
best_regex_dt, best_regex_combined, ollama_datetime, ollama_validity_score, valid_regex_results
)
|